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
6 changes: 6 additions & 0 deletions .changeset/spotty-masks-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@react-router/dev": minor
"react-router": minor
---

Add additional layer of CSRF protection by rejecting submissions to UI routes from external origins. If you need to permit access to specific external origins, you can specify them in the `react-router.config.ts` config `allowedActionOrigins` field.
1 change: 1 addition & 0 deletions integration/vite-presets-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ test.describe("Vite / presets", async () => {
"serverBundles",
"serverModuleFormat",
"ssr",
"allowedActionOrigins",
"unstable_routeConfig",
]);

Expand Down
14 changes: 14 additions & 0 deletions packages/react-router-dev/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ export type ReactRouterConfig = {
* SPA without server-rendering. Default's to `true`.
*/
ssr?: boolean;

/**
* The allowed origins for actions / mutations. Does not apply to routes
* without a component. micromatch glob patterns are supported.
*/
allowedActionOrigins?: string[];
};

export type ResolvedReactRouterConfig = Readonly<{
Expand Down Expand Up @@ -277,6 +283,11 @@ export type ResolvedReactRouterConfig = Readonly<{
* SPA without server-rendering. Default's to `true`.
*/
ssr: boolean;
/**
* The allowed origins for actions / mutations. Does not apply to routes
* without a component. micromatch glob patterns are supported.
Copy link

@krissrex krissrex Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This documentation could be better.

The host header includes protocol (https://), but the comparison happens without.
The docs could state that origins should be provided without protocol.

Also, globs wont match if the origin is "null": *, ** and n* doesn't match.

I'm not sure how to allow if the origin header is completely absent.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can get the docs updated to be more specific there - how about "The allowed origins (excluding protocol) for actions / mutations on UI routes (i.e., those with a UI component). Micromatch glob patterns are supported."?

We can also look into the glob matching for null - I would think that should be matched by *.

I'm not sure how to allow if the origin header is completely absent.

If the header is absent, this check won't even happen?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This caught me off-guard today too. Omitting the protocol from the origin feels like it might be a Bad Idea™, and I'm wondering if RR should be fairly strict about this considering it's meant to mitigate a security issue.

*/
allowedActionOrigins: string[] | false;
/**
* The resolved array of route config entries exported from `routes.ts`
*/
Expand Down Expand Up @@ -645,6 +656,8 @@ async function resolveConfig({
userAndPresetConfigs.future?.v8_viteEnvironmentApi ?? false,
};

let allowedActionOrigins = userAndPresetConfigs.allowedActionOrigins ?? false;

let reactRouterConfig: ResolvedReactRouterConfig = deepFreeze({
appDirectory,
basename,
Expand All @@ -658,6 +671,7 @@ async function resolveConfig({
serverBundles,
serverModuleFormat,
ssr,
allowedActionOrigins,
unstable_routeConfig: routeConfig,
} satisfies ResolvedReactRouterConfig);

Expand Down
1 change: 1 addition & 0 deletions packages/react-router-dev/typegen/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function generateServerBuild(ctx: Context): VirtualFile {
export const routeDiscovery: ServerBuild["routeDiscovery"];
export const routes: ServerBuild["routes"];
export const ssr: ServerBuild["ssr"];
export const allowedActionOrigins: ServerBuild["allowedActionOrigins"];
export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"];
}
`;
Expand Down
4 changes: 3 additions & 1 deletion packages/react-router-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -871,7 +871,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
}
`
: ""
}`;
}
export const allowedActionOrigins = ${JSON.stringify(ctx.reactRouterConfig.allowedActionOrigins)};
`;
};

let loadViteManifest = async (directory: string) => {
Expand Down
122 changes: 122 additions & 0 deletions packages/react-router/lib/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
export function throwIfPotentialCSRFAttack(
headers: Headers,
allowedActionOrigins: string[] | undefined,
) {
let originHeader = headers.get("origin");
let originDomain =
typeof originHeader === "string" && originHeader !== "null"
? new URL(originHeader).host

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, a coworker said he experienced exceptions from cases where Origin was not a valid URL.

I'm not sure what cases this happen in, but it happened.

TypeError with code: ERR_INVALID_URL.

: originHeader;
let host = parseHostHeader(headers);

if (originDomain && (!host || originDomain !== host.value)) {
if (!isAllowedOrigin(originDomain, allowedActionOrigins)) {
if (host) {
// This seems to be an CSRF attack. We should not proceed with the action.
throw new Error(
`${host.type} header does not match \`origin\` header from a forwarded ` +
`action request. Aborting the action.`,
);
} else {
// This is an attack. We should not proceed with the action.
throw new Error(
"`x-forwarded-host` or `host` headers are not provided. One of these " +
"is needed to compare the `origin` header from a forwarded action " +
"request. Aborting the action.",
);
}
}
}
}

// Implementation of micromatch by Next.js https://github.com/vercel/next.js/blob/ea927b583d24f42e538001bf13370e38c91d17bf/packages/next/src/server/app-render/csrf-protection.ts#L6
function matchWildcardDomain(domain: string, pattern: string) {
const domainParts = domain.split(".");
const patternParts = pattern.split(".");

if (patternParts.length < 1) {
// pattern is empty and therefore invalid to match against
return false;
}

if (domainParts.length < patternParts.length) {
// domain has too few segments and thus cannot match
return false;
}

// Prevent wildcards from matching entire domains (e.g. '**' or '*.com')
// This ensures wildcards can only match subdomains, not the main domain
if (
patternParts.length === 1 &&
(patternParts[0] === "*" || patternParts[0] === "**")
) {
return false;
}

while (patternParts.length) {
const patternPart = patternParts.pop();
const domainPart = domainParts.pop();

switch (patternPart) {
case "": {
// invalid pattern. pattern segments must be non empty
return false;
}
case "*": {
// wildcard matches anything so we continue if the domain part is non-empty
if (domainPart) {
continue;
} else {
return false;
}
}
case "**": {
// if this is not the last item in the pattern the pattern is invalid
if (patternParts.length > 0) {
return false;
}
// recursive wildcard matches anything so we terminate here if the domain part is non empty
return domainPart !== undefined;
}
case undefined:
default: {
if (domainPart !== patternPart) {
return false;
}
}
}
}

// We exhausted the pattern. If we also exhausted the domain we have a match
return domainParts.length === 0;
}

function isAllowedOrigin(
originDomain: string,
allowedActionOrigins: string[] | undefined = [],
) {
return allowedActionOrigins.some(
(allowedOrigin) =>
allowedOrigin &&
(allowedOrigin === originDomain ||
matchWildcardDomain(originDomain, allowedOrigin)),
);
}

function parseHostHeader(headers: Headers) {
let forwardedHostHeader = headers.get("x-forwarded-host");
let forwardedHostValue = forwardedHostHeader?.split(",")[0]?.trim();
let hostHeader = headers.get("host");

return forwardedHostValue
? {
type: "x-forwarded-host",
value: forwardedHostValue,
}
: hostHeader
? {
type: "host",
value: hostHeader,
}
: undefined;
}
8 changes: 8 additions & 0 deletions packages/react-router/lib/rsc/server.rsc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from "../router/utils";
import { getDocumentHeadersImpl } from "../server-runtime/headers";
import { SINGLE_FETCH_REDIRECT_STATUS } from "../dom/ssr/single-fetch";
import { throwIfPotentialCSRFAttack } from "../actions";
import type { RouteMatch, RouteObject } from "../context";
import invariant from "../server-runtime/invariant";

Expand Down Expand Up @@ -331,6 +332,7 @@ export type LoadServerActionFunction = (id: string) => Promise<Function>;
* @category RSC
* @mode data
* @param opts Options
* @param opts.allowedActionOrigins Origin patterns that are allowed to execute actions.
* @param opts.basename The basename to use when matching the request.
* @param opts.createTemporaryReferenceSet A function that returns a temporary
* reference set for the request, used to track temporary references in the [RSC](https://react.dev/reference/rsc/server-components)
Expand Down Expand Up @@ -361,6 +363,7 @@ export type LoadServerActionFunction = (id: string) => Promise<Function>;
* data for hydration.
*/
export async function matchRSCServerRequest({
allowedActionOrigins,
createTemporaryReferenceSet,
basename,
decodeReply,
Expand All @@ -373,6 +376,7 @@ export async function matchRSCServerRequest({
routes,
generateResponse,
}: {
allowedActionOrigins?: string[];
createTemporaryReferenceSet: () => unknown;
basename?: string;
decodeReply?: DecodeReplyFunction;
Expand Down Expand Up @@ -477,6 +481,7 @@ export async function matchRSCServerRequest({
onError,
generateResponse,
temporaryReferences,
allowedActionOrigins,
);
// The front end uses this to know whether a 4xx/5xx status came from app code
// or never reached the origin server
Expand Down Expand Up @@ -754,6 +759,7 @@ async function generateRenderResponse(
},
) => Response,
temporaryReferences: unknown,
allowedActionOrigins: string[] | undefined,
): Promise<Response> {
// If this is a RR submission, we just want the `actionData` but don't want
// to call any loaders or render any components back in the response - that
Expand Down Expand Up @@ -799,6 +805,8 @@ async function generateRenderResponse(
let formState: unknown;
let skipRevalidation = false;
if (request.method === "POST") {
throwIfPotentialCSRFAttack(request.headers, allowedActionOrigins);

ctx.runningAction = true;
let result = await processServerAction(
request,
Expand Down
7 changes: 2 additions & 5 deletions packages/react-router/lib/server-runtime/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@ import type {
import type { ServerRouteManifest } from "./routes";
import type { AppLoadContext } from "./data";
import type { MiddlewareEnabled } from "../types/future";
import type {
unstable_InstrumentRequestHandlerFunction,
unstable_InstrumentRouteFunction,
unstable_ServerInstrumentation,
} from "../router/instrumentation";
import type { unstable_ServerInstrumentation } from "../router/instrumentation";

type OptionalCriticalCss = CriticalCss | undefined;

Expand Down Expand Up @@ -46,6 +42,7 @@ export interface ServerBuild {
mode: "lazy" | "initial";
manifestPath: string;
};
allowedActionOrigins?: string[] | false;
}

export interface HandleDocumentRequestFunction {
Expand Down
9 changes: 9 additions & 0 deletions packages/react-router/lib/server-runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type { MiddlewareEnabled } from "../types/future";
import { getManifestPath } from "../dom/ssr/fog-of-war";
import type { unstable_InstrumentRequestHandlerFunction } from "../router/instrumentation";
import { instrumentHandler } from "../router/instrumentation";
import { throwIfPotentialCSRFAttack } from "../actions";

export type RequestHandler = (
request: Request,
Expand Down Expand Up @@ -481,6 +482,14 @@ async function handleDocumentRequest(
criticalCss?: CriticalCss,
) {
try {
if (request.method === "POST") {
throwIfPotentialCSRFAttack(
request.headers,
Array.isArray(build.allowedActionOrigins)
? build.allowedActionOrigins
: [],
);
}
let result = await staticHandler.query(request, {
requestContext: loadContext,
generateMiddlewareResponse: build.future.v8_middleware
Expand Down
8 changes: 8 additions & 0 deletions packages/react-router/lib/server-runtime/single-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { sanitizeError, sanitizeErrors } from "./errors";
import { ServerMode } from "./mode";
import { getDocumentHeaders } from "./headers";
import type { ServerBuild } from "./build";
import { throwIfPotentialCSRFAttack } from "../actions";

// Add 304 for server side - that is not included in the client side logic
// because the browser should fill those responses with the cached data
Expand All @@ -42,6 +43,13 @@ export async function singleFetchAction(
handleError: (err: unknown) => void,
): Promise<Response> {
try {
throwIfPotentialCSRFAttack(
request.headers,
Array.isArray(build.allowedActionOrigins)
? build.allowedActionOrigins
: [],
);

let handlerRequest = new Request(handlerUrl, {
method: request.method,
body: request.body,
Expand Down