fix: secure console operation posts
This commit is contained in:
+131
-18
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user