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/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.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/.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/__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/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)) {
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;
},