diff --git a/bun.lock b/bun.lock index 3bb664a4..16b5366e 100644 --- a/bun.lock +++ b/bun.lock @@ -268,7 +268,7 @@ }, "packages/cli": { "name": "@wrnexus/cli", - "version": "0.8.23", + "version": "0.8.24", "bin": { "wrnexus": "src/index.ts", }, @@ -332,7 +332,7 @@ }, "packages/dev-server": { "name": "@wrnexus/dev-server", - "version": "0.8.22", + "version": "0.8.23", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/cache": "workspace:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index 8a349cd5..4b949216 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.8.23", + "version": "0.8.24", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 91254cf2..7069a33e 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.8.22", + "version": "0.8.23", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index 45466a7a..b3b8ab7b 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -57,6 +57,18 @@ export { getWrnCompileMetrics, resetWrnCompileMetrics } from "./pipeline.ts"; export type { WrnCompileMetrics } from "./pipeline.ts"; import { resetDevCache } from "./cache.ts"; +export function validateRpcCsrf(request: Request): boolean { + const url = new URL(request.url); + const origin = request.headers.get("origin"); + if (origin && origin !== url.origin) return false; + const cookieHeader = request.headers.get("cookie") ?? ""; + const cookieToken = + /(?:^|;\s*)wrn-csrf=([^;]+)/.exec(cookieHeader)?.[1] ?? + /(?:^|;\s*)wrnexus_csrf=([^;]+)/.exec(cookieHeader)?.[1]; + const headerToken = request.headers.get("x-csrf-token") ?? request.headers.get("x-wrnexus-csrf"); + return Boolean(cookieToken && headerToken && decodeURIComponent(cookieToken) === headerToken); +} + import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types"; import { createPluginRunner, discoverPlugins, type PluginInput } from "@wrnexus/plugin"; import type { ObservabilityConfig, TenancyConfig } from "@wrnexus/styles"; @@ -561,18 +573,7 @@ export async function startServer(opts: ServeOptions): Promise { } return null; }, - validateCsrf(request) { - const url = new URL(request.url); - const origin = request.headers.get("origin"); - if (origin && origin !== url.origin) return false; - const cookieHeader = request.headers.get("cookie") ?? ""; - const cookieToken = - /(?:^|;\s*)wrn-csrf=([^;]+)/.exec(cookieHeader)?.[1] ?? - /(?:^|;\s*)wrnexus_csrf=([^;]+)/.exec(cookieHeader)?.[1]; - const headerToken = - request.headers.get("x-csrf-token") ?? request.headers.get("x-wrnexus-csrf") ?? ""; - return !cookieToken || decodeURIComponent(cookieToken) === headerToken; - }, + validateCsrf: validateRpcCsrf, }); const server = Bun.serve({ diff --git a/packages/dev-server/test/rpc-csrf.test.ts b/packages/dev-server/test/rpc-csrf.test.ts new file mode 100644 index 00000000..0d49f424 --- /dev/null +++ b/packages/dev-server/test/rpc-csrf.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test"; +import { validateRpcCsrf } from "../src/index.ts"; + +function request(headers: Record = {}): Request { + return new Request("https://app.example/__wrnexus/rpc", { + method: "POST", + headers, + }); +} + +test("RPC CSRF requires a matching cookie and header from the same origin", () => { + expect(validateRpcCsrf(request())).toBe(false); + expect(validateRpcCsrf(request({ cookie: "wrn-csrf=token" }))).toBe(false); + expect(validateRpcCsrf(request({ "x-csrf-token": "token" }))).toBe(false); + expect(validateRpcCsrf(request({ cookie: "wrn-csrf=token", "x-csrf-token": "forged" }))).toBe( + false, + ); + expect( + validateRpcCsrf( + request({ + cookie: "wrn-csrf=token", + "x-csrf-token": "token", + origin: "https://evil.example", + }), + ), + ).toBe(false); + expect( + validateRpcCsrf( + request({ + cookie: "wrn-csrf=encoded%20token", + "x-csrf-token": "encoded token", + origin: "https://app.example", + }), + ), + ).toBe(true); +});