From 7a71c728ad116bd78699a258b2014ce9585729f5 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Mon, 13 Jul 2026 13:33:05 -0700 Subject: [PATCH 1/2] harden RSC CSRF codepaths (#15311) Co-authored-by: Matt Brophy --- integration/rsc-csrf-action-test.ts | 169 ++++++++++++++++++ .../react-router/.changes/patch.rsc-csrf.md | 1 + packages/react-router/lib/rsc/server.rsc.ts | 18 +- 3 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 integration/rsc-csrf-action-test.ts create mode 100644 packages/react-router/.changes/patch.rsc-csrf.md diff --git a/integration/rsc-csrf-action-test.ts b/integration/rsc-csrf-action-test.ts new file mode 100644 index 0000000000..642fa2ff2c --- /dev/null +++ b/integration/rsc-csrf-action-test.ts @@ -0,0 +1,169 @@ +import { test, expect, type Page } from "@playwright/test"; +import getPort from "get-port"; + +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { AppFixture, Fixture } from "./helpers/create-fixture.js"; +import { implementations, setupRscTest, validateRSCHtml } from "./rsc/utils.js"; + +const csrfActionRoute = js` + let actionCalls = 0; + + export function loader() { + return { actionCalls }; + } + + export async function action() { + actionCalls++; + return null; + } + + export default function Component({ loaderData }) { + return ( +

+ Action calls: {loaderData.actionCalls} +

+ ); + } +`; + +async function expectActionCalls(page: Page, count: string) { + await page.waitForSelector("[data-action-calls]"); + expect(await page.locator("[data-action-calls]").textContent()).toContain( + `Action calls: ${count}`, + ); + expect( + await page.locator("[data-action-calls]").getAttribute("data-action-calls"), + ).toBe(count); +} + +test.describe("RSC CSRF action protection", () => { + test.describe("RSC Framework", () => { + let fixture: Fixture; + let appFixture: AppFixture | undefined; + + test.beforeAll(async () => { + fixture = await createFixture({ + templateName: "rsc-vite-framework", + files: { + "app/routes/csrf-action.tsx": csrfActionRoute, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture?.close(); + }); + + test("does not call actions on cross-origin document POST requests", async ({ + page, + request, + }) => { + let app = new PlaywrightFixture(appFixture!, page); + + await app.goto("/csrf-action"); + await expectActionCalls(page, "0"); + validateRSCHtml(await page.content()); + + let response = await request.post( + `${appFixture!.serverUrl}/csrf-action`, + { + form: { intent: "mutate" }, + headers: { + Origin: "https://attacker.example", + }, + }, + ); + expect(response.status()).toBe(400); + + await app.goto("/csrf-action"); + await expectActionCalls(page, "0"); + }); + }); + + implementations.forEach((implementation) => { + test.describe(`RSC Data (${implementation.name})`, () => { + let port: number; + let stopAfterAll: () => void; + + test.beforeAll(async () => { + port = await getPort(); + stopAfterAll = await setupRscTest({ + implementation, + port, + files: { + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "csrf-action", + path: "csrf-action", + lazy: () => import("./routes/csrf-action"), + }, + ], + }, + ] satisfies RSCRouteConfig; + `, + + "src/routes/root.tsx": js` + import { Outlet } from "react-router"; + + export function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + } + + export default function RootRoute() { + return ; + } + `, + + "src/routes/csrf-action.tsx": csrfActionRoute, + }, + }); + }); + + test.afterAll(() => { + stopAfterAll?.(); + }); + + test("does not call actions on cross-origin document POST requests", async ({ + page, + request, + }) => { + await page.goto(`http://localhost:${port}/csrf-action`); + await expectActionCalls(page, "0"); + validateRSCHtml(await page.content()); + + let response = await request.post( + `http://localhost:${port}/csrf-action`, + { + form: { intent: "mutate" }, + headers: { + Origin: "https://attacker.example", + }, + }, + ); + expect(response.status()).toBe(400); + + await page.goto(`http://localhost:${port}/csrf-action`); + await expectActionCalls(page, "0"); + }); + }); + }); +}); diff --git a/packages/react-router/.changes/patch.rsc-csrf.md b/packages/react-router/.changes/patch.rsc-csrf.md new file mode 100644 index 0000000000..f778a415cf --- /dev/null +++ b/packages/react-router/.changes/patch.rsc-csrf.md @@ -0,0 +1 @@ +Harden RSC CSRF codepaths. diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index 80cc86fa04..a7db3796e1 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -857,7 +857,17 @@ async function generateRenderResponse( if (isMutationMethod(request.method)) { try { throwIfPotentialCSRFAttack(request, allowedActionOrigins); + } catch (error) { + onError?.(error); + potentialCSRFAttackError = error; + request = new Request(request.url, { + method: "GET", + headers: request.headers, + signal: request.signal, + }); + } + if (!potentialCSRFAttackError) { ctx.runningAction = true; let result = await processServerAction( request, @@ -900,18 +910,12 @@ async function generateRenderResponse( undefined, ); } - } catch (error) { - potentialCSRFAttackError = error; } } let staticContext = await query( request, - skipRevalidation || !!potentialCSRFAttackError - ? { - filterMatchesToLoad: () => false, - } - : undefined, + skipRevalidation ? { filterMatchesToLoad: () => false } : undefined, ); if (isResponse(staticContext)) { From 4f3d5ca5f7b28f12e66af30bee0449d7ef57578c Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 13 Jul 2026 17:08:47 -0400 Subject: [PATCH 2/2] Use random UUIDs for memory session ids (#15302) * Use random UUIDs for memory session ids * Simplify memory session id regex test Assisted-By: devx/e08f3dc8-1822-4fc8-80fc-fd19ca1ff100 --- docs/explanation/sessions-and-cookies.md | 2 +- .../.changes/patch.memory-session-random-uuid.md | 3 +++ .../__tests__/server-runtime/sessions-test.ts | 4 ++-- .../lib/server-runtime/sessions/memoryStorage.ts | 9 ++++----- 4 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 packages/react-router/.changes/patch.memory-session-random-uuid.md diff --git a/docs/explanation/sessions-and-cookies.md b/docs/explanation/sessions-and-cookies.md index 9beeb33a09..dacf9df18a 100644 --- a/docs/explanation/sessions-and-cookies.md +++ b/docs/explanation/sessions-and-cookies.md @@ -278,7 +278,7 @@ The `expires` argument to `createData` and `updateData` is the same `Date` at wh There are also several other session utilities available if you need them: - [`isSession`][is-session] -- [`createMemorySessionStorage`][create-memory-session-storage] +- [`createMemorySessionStorage`][create-memory-session-storage] (local dev and testing) - [`createSession`][create-session] (custom storage) - [`createFileSessionStorage`][create-file-session-storage] (node) - [`createWorkersKVSessionStorage`][create-workers-kv-session-storage] (Cloudflare Workers) diff --git a/packages/react-router/.changes/patch.memory-session-random-uuid.md b/packages/react-router/.changes/patch.memory-session-random-uuid.md new file mode 100644 index 0000000000..a201faead7 --- /dev/null +++ b/packages/react-router/.changes/patch.memory-session-random-uuid.md @@ -0,0 +1,3 @@ +Use `crypto.randomUUID()` for `createMemorySessionStorage` session ids + +- `createMemorySessionStorage` is only intended for local development and testing - sessions are lost when the server restarts diff --git a/packages/react-router/__tests__/server-runtime/sessions-test.ts b/packages/react-router/__tests__/server-runtime/sessions-test.ts index dd8a1e15f4..41fef683f0 100644 --- a/packages/react-router/__tests__/server-runtime/sessions-test.ts +++ b/packages/react-router/__tests__/server-runtime/sessions-test.ts @@ -66,7 +66,7 @@ describe("In-memory session storage", () => { expect(session.get("user")).toEqual("mjackson"); }); - it("uses random hash keys as session ids", async () => { + it("uses random UUIDs as session ids", async () => { let { getSession, commitSession } = createMemorySessionStorage({ cookie: { secrets: ["secret1"] }, }); @@ -74,7 +74,7 @@ describe("In-memory session storage", () => { session.set("user", "mjackson"); let setCookie = await commitSession(session); session = await getSession(getCookieFromSetCookie(setCookie)); - expect(session.id).toMatch(/^[a-z0-9]{8}$/); + expect(session.id).toMatch(/^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$/); }); }); diff --git a/packages/react-router/lib/server-runtime/sessions/memoryStorage.ts b/packages/react-router/lib/server-runtime/sessions/memoryStorage.ts index efabc5fc99..d39ea62c7a 100644 --- a/packages/react-router/lib/server-runtime/sessions/memoryStorage.ts +++ b/packages/react-router/lib/server-runtime/sessions/memoryStorage.ts @@ -15,11 +15,10 @@ interface MemorySessionStorageOptions { } /** - * Creates and returns a simple in-memory SessionStorage object, mostly useful - * for testing and as a reference implementation. + * Creates and returns a simple in-memory SessionStorage object. * - * Note: This storage does not scale beyond a single process, so it is not - * suitable for most production scenarios. + * Intended for local development and testing. It does not scale beyond a single + * process, and all session data is lost when the server process stops/restarts. */ export function createMemorySessionStorage< Data = SessionData, @@ -36,7 +35,7 @@ export function createMemorySessionStorage< return createSessionStorage({ cookie, async createData(data, expires) { - let id = Math.random().toString(36).substring(2, 10); + let id = crypto.randomUUID(); map.set(id, { data, expires }); return id; },