Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/explanation/sessions-and-cookies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
169 changes: 169 additions & 0 deletions integration/rsc-csrf-action-test.ts
Original file line number Diff line number Diff line change
@@ -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 (
<p data-action-calls={loaderData.actionCalls}>
Action calls: {loaderData.actionCalls}
</p>
);
}
`;

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 (
<html>
<body>{children}</body>
</html>
);
}

export default function RootRoute() {
return <Outlet />;
}
`,

"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");
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions packages/react-router/.changes/patch.rsc-csrf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Harden RSC CSRF codepaths.
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,15 @@ 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"] },
});
let session = await getSession();
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}$/);
});
});

Expand Down
18 changes: 11 additions & 7 deletions packages/react-router/lib/rsc/server.rsc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
},
Expand Down