fix: secure console operation posts

This commit is contained in:
lda
2026-08-11 22:01:21 +07:00 Verified
parent 6a73a5ae12
commit e017cc0a97
5 changed files with 243 additions and 23 deletions
+131 -18
View File
@@ -56,6 +56,11 @@ const makeApp = (
const app = makeApp({ runOperation: okRunner });
const validConsoleHeaders = {
"content-type": "application/json",
"x-workflow-console": "1",
} as const;
describe("GET /api/health", () => {
it("returns 200 with ok status", async () => {
const res = await app.request("/api/health");
@@ -69,7 +74,7 @@ describe("POST /api/connect", () => {
it("calls workflow.health and returns connected DTO", async () => {
const res = await app.request("/api/connect", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({ target: "http://127.0.0.1:8000/rpc" }),
});
expect(res.status).toBe(200);
@@ -89,7 +94,7 @@ describe("POST /api/connect", () => {
it("returns 400 when target is missing", async () => {
const res = await app.request("/api/connect", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
@@ -104,7 +109,7 @@ describe("POST /api/connect", () => {
});
const res = await malformedApp.request("/api/connect", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({ target: "http://127.0.0.1:8000/rpc" }),
});
expect(res.status).toBe(502);
@@ -114,6 +119,114 @@ describe("POST /api/connect", () => {
});
});
describe("console POST request boundary", () => {
const routes = [
{
path: "/api/connect",
body: { target: "http://127.0.0.1:8000/rpc" },
},
{
path: "/api/rpc",
body: {
operation: "workflow.health",
target: "http://127.0.0.1:8000/rpc",
params: {},
},
},
] as const;
it.each(routes)("rejects text/plain at $path before invoking the runner", async ({
path: requestPath,
body,
}) => {
const runOperation = vi.fn<RunOperation>(async (operation) =>
makeExchange({ operation }),
);
const guardedApp = makeApp({ runOperation });
const res = await guardedApp.request(requestPath, {
method: "POST",
headers: {
"content-type": "text/plain",
"x-workflow-console": "1",
},
body: JSON.stringify(body),
});
expect(res.status).toBe(415);
expect(runOperation).not.toHaveBeenCalled();
});
it.each(routes)("rejects a missing console header at $path before invoking the runner", async ({
path: requestPath,
body,
}) => {
const runOperation = vi.fn<RunOperation>(async (operation) =>
makeExchange({ operation }),
);
const guardedApp = makeApp({ runOperation });
const res = await guardedApp.request(requestPath, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
expect(res.status).toBe(403);
expect(runOperation).not.toHaveBeenCalled();
});
it.each([
{
label: "foreign Origin",
headers: { ...validConsoleHeaders, origin: "https://foreign.example" },
},
{
label: "foreign Sec-Fetch-Site",
headers: { ...validConsoleHeaders, "sec-fetch-site": "cross-site" },
},
])("rejects $label before either runner is invoked", async ({ headers }) => {
const runOperation = vi.fn<RunOperation>(async (operation) =>
makeExchange({ operation }),
);
const guardedApp = makeApp({ runOperation });
for (const route of routes) {
const res = await guardedApp.request(route.path, {
method: "POST",
headers,
body: JSON.stringify(route.body),
});
expect(res.status).toBe(403);
}
expect(runOperation).not.toHaveBeenCalled();
});
it.each(routes)("allows a same-origin console request through $path", async ({
path: requestPath,
body,
}) => {
const runOperation = vi.fn<RunOperation>(async (operation) =>
makeExchange({ operation }),
);
const guardedApp = makeApp({ runOperation });
const res = await guardedApp.request(`http://localhost:5173${requestPath}`, {
method: "POST",
headers: {
...validConsoleHeaders,
origin: "http://localhost:5173",
"sec-fetch-site": "same-origin",
},
body: JSON.stringify(body),
});
expect(res.status).toBe(200);
expect(runOperation).toHaveBeenCalledTimes(1);
});
});
describe("POST /api/rpc", () => {
it.each([
{ operation: "workflow.capabilities.list", params: {} },
@@ -195,7 +308,7 @@ describe("POST /api/rpc", () => {
}) => {
const res = await app.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({
operation,
target: "http://127.0.0.1:8000/rpc",
@@ -217,7 +330,7 @@ describe("POST /api/rpc", () => {
it("invokes the requested operation", async () => {
const res = await app.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({
operation: "workflow.sources.list",
target: "http://127.0.0.1:8000/rpc",
@@ -235,7 +348,7 @@ describe("POST /api/rpc", () => {
const disabledApp = makeApp({ runOperation });
const res = await disabledApp.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({
operation: "workflow.capabilities.call",
target: "http://127.0.0.1:8000/rpc",
@@ -269,7 +382,7 @@ describe("POST /api/rpc", () => {
const params = { capability_name: "local.example.echo", input: {} };
const res = await enabledApp.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({
operation: "workflow.capabilities.call",
target: "http://127.0.0.1:8000/rpc",
@@ -290,7 +403,7 @@ describe("POST /api/rpc", () => {
const rejectedApp = makeApp({ runOperation });
const res = await rejectedApp.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({
operation: "foo.bar",
target: "http://127.0.0.1:8000/rpc",
@@ -306,7 +419,7 @@ describe("POST /api/rpc", () => {
it("does not authorize generated operations outside the console boundary", async () => {
const res = await app.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({
operation: "workflow.admin.auth.list",
target: "http://127.0.0.1:8000/rpc",
@@ -321,7 +434,7 @@ describe("POST /api/rpc", () => {
it("does not authorize generic draft workspace mutations", async () => {
const res = await app.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({
operation: "workflow.draft_workspaces.replace_document",
target: "http://127.0.0.1:8000/rpc",
@@ -336,7 +449,7 @@ describe("POST /api/rpc", () => {
it("returns 400 for invalid JSON body", async () => {
const res = await app.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: "not json",
});
expect(res.status).toBe(400);
@@ -348,7 +461,7 @@ describe("POST body size limit", () => {
const bigBody = JSON.stringify({ data: "x".repeat(257 * 1024) });
const res = await app.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: bigBody,
});
expect(res.status).toBe(413);
@@ -362,7 +475,7 @@ describe("error mapping", () => {
});
const res = await timeoutApp.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({
operation: "workflow.health",
target: "http://127.0.0.1:8000/rpc",
@@ -385,7 +498,7 @@ describe("error mapping", () => {
});
const res = await connApp.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({
operation: "workflow.health",
target: "http://127.0.0.1:8000/rpc",
@@ -404,7 +517,7 @@ describe("error mapping", () => {
});
const res = await remoteApp.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({
operation: "workflow.health",
target: "http://127.0.0.1:8000/rpc",
@@ -423,7 +536,7 @@ describe("error mapping", () => {
});
const res = await invalidApp.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({
operation: "workflow.health",
target: "not-a-url",
@@ -442,7 +555,7 @@ describe("error mapping", () => {
});
const res = await unknownApp.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({
operation: "workflow.health",
target: "http://127.0.0.1:8000/rpc",
@@ -464,7 +577,7 @@ describe("error mapping", () => {
for (const a of apps) {
const res = await a.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
headers: validConsoleHeaders,
body: JSON.stringify({
operation: "workflow.health",
target: "http://127.0.0.1:8000/rpc",
+94
View File
@@ -20,6 +20,7 @@ export type RunOperation = (
type BrowserErrorCode =
| "invalid_target"
| "request_rejected"
| "unknown_operation"
| "operation_disabled"
| "upstream_unreachable"
@@ -29,6 +30,93 @@ type BrowserErrorCode =
| "rpc_decode_error"
| "response_too_large";
const CONSOLE_REQUEST_HEADER = "x-workflow-console";
type ConsoleRequestRejection = {
readonly status: 403 | 415;
readonly message: string;
};
/**
* Blocks browser-simple and cross-origin POSTs before they can reach an RPC.
* The Vite proxy preserves the console-facing origin in the request URL, so
* comparing against it keeps development and production on the same contract.
*/
const consoleRequestRejection = (
request: Request,
): ConsoleRequestRejection | null => {
const contentType = request.headers
.get("content-type")
?.split(";", 1)[0]
?.trim()
.toLowerCase();
if (contentType !== "application/json") {
return {
status: 415,
message: "console POST requests require application/json",
};
}
if (request.headers.get(CONSOLE_REQUEST_HEADER) !== "1") {
return {
status: 403,
message: "console POST request header is missing or invalid",
};
}
const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
if (
fetchSite !== undefined &&
fetchSite !== "same-origin" &&
fetchSite !== "none"
) {
return { status: 403, message: "cross-origin console POST rejected" };
}
const origin = request.headers.get("origin");
if (origin !== null) {
let parsedOrigin: URL;
try {
parsedOrigin = new URL(origin);
} catch {
return { status: 403, message: "invalid console request origin" };
}
if (parsedOrigin.origin !== new URL(request.url).origin) {
return { status: 403, message: "cross-origin console POST rejected" };
}
}
return null;
};
const rejectInvalidConsoleRequest = (
request: Request,
):
| {
readonly status: 403 | 415;
readonly body: {
readonly ok: false;
readonly error: {
readonly code: "request_rejected";
readonly message: string;
};
readonly exchange: {
readonly request: null;
readonly response: null;
};
};
}
| null => {
const rejection = consoleRequestRejection(request);
if (rejection === null) return null;
return {
status: rejection.status,
body: {
ok: false,
error: { code: "request_rejected", message: rejection.message },
exchange: { request: null, response: null },
},
};
};
const isHealthInterpreted = (
value: unknown,
): value is WorkflowHealthInterpreted =>
@@ -89,6 +177,9 @@ export function createApp(dependencies: {
app.use("/api/connect", bodyLimit({ maxSize: 256 * 1024 }));
app.post("/api/connect", async (c) => {
const rejected = rejectInvalidConsoleRequest(c.req.raw);
if (rejected !== null) return c.json(rejected.body, rejected.status);
let body: { target?: string };
try {
body = await c.req.json();
@@ -163,6 +254,9 @@ export function createApp(dependencies: {
app.use("/api/rpc", bodyLimit({ maxSize: 256 * 1024 }));
app.post("/api/rpc", async (c) => {
const rejected = rejectInvalidConsoleRequest(c.req.raw);
if (rejected !== null) return c.json(rejected.body, rejected.status);
let body: { operation?: string; target?: string; params?: unknown };
try {
body = await c.req.json();