Skip to content

Commit ad96199

Browse files
authored
Merge branch 'remix-run:dev' into dev
2 parents a484b27 + 0ef10ab commit ad96199

File tree

16 files changed

+226
-26
lines changed

16 files changed

+226
-26
lines changed

.changeset/fuzzy-worms-decide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Escape HTML in scroll restoration keys

.changeset/hungry-pears-battle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Validate redirect locations

.changeset/spotty-masks-beg.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@react-router/dev": minor
3+
"react-router": minor
4+
---
5+
6+
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.

docs/api/rsc/matchRSCServerRequest.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ matchRSCServerRequest({
7070

7171
```tsx
7272
async function matchRSCServerRequest({
73+
allowedActionOrigins,
7374
createTemporaryReferenceSet,
7475
basename,
7576
decodeReply,
@@ -82,6 +83,7 @@ async function matchRSCServerRequest({
8283
routes,
8384
generateResponse,
8485
}: {
86+
allowedActionOrigins?: string[];
8587
createTemporaryReferenceSet: () => unknown;
8688
basename?: string;
8789
decodeReply?: DecodeReplyFunction;
@@ -107,6 +109,10 @@ async function matchRSCServerRequest({
107109

108110
## Params
109111

112+
### opts.allowedActionOrigins
113+
114+
Origin patterns that are allowed to execute actions.
115+
110116
### opts.basename
111117

112118
The basename to use when matching the request.

integration/vite-presets-test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ test.describe("Vite / presets", async () => {
238238
"serverBundles",
239239
"serverModuleFormat",
240240
"ssr",
241+
"allowedActionOrigins",
241242
"unstable_routeConfig",
242243
]);
243244

packages/react-router-dev/config/config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@ export type ReactRouterConfig = {
211211
* SPA without server-rendering. Default's to `true`.
212212
*/
213213
ssr?: boolean;
214+
215+
/**
216+
* The allowed origins for actions / mutations. Does not apply to routes
217+
* without a component. micromatch glob patterns are supported.
218+
*/
219+
allowedActionOrigins?: string[];
214220
};
215221

216222
export type ResolvedReactRouterConfig = Readonly<{
@@ -277,6 +283,11 @@ export type ResolvedReactRouterConfig = Readonly<{
277283
* SPA without server-rendering. Default's to `true`.
278284
*/
279285
ssr: boolean;
286+
/**
287+
* The allowed origins for actions / mutations. Does not apply to routes
288+
* without a component. micromatch glob patterns are supported.
289+
*/
290+
allowedActionOrigins: string[] | false;
280291
/**
281292
* The resolved array of route config entries exported from `routes.ts`
282293
*/
@@ -645,6 +656,8 @@ async function resolveConfig({
645656
userAndPresetConfigs.future?.v8_viteEnvironmentApi ?? false,
646657
};
647658

659+
let allowedActionOrigins = userAndPresetConfigs.allowedActionOrigins ?? false;
660+
648661
let reactRouterConfig: ResolvedReactRouterConfig = deepFreeze({
649662
appDirectory,
650663
basename,
@@ -658,6 +671,7 @@ async function resolveConfig({
658671
serverBundles,
659672
serverModuleFormat,
660673
ssr,
674+
allowedActionOrigins,
661675
unstable_routeConfig: routeConfig,
662676
} satisfies ResolvedReactRouterConfig);
663677

packages/react-router-dev/typegen/generate.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export function generateServerBuild(ctx: Context): VirtualFile {
4848
export const routeDiscovery: ServerBuild["routeDiscovery"];
4949
export const routes: ServerBuild["routes"];
5050
export const ssr: ServerBuild["ssr"];
51+
export const allowedActionOrigins: ServerBuild["allowedActionOrigins"];
5152
export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"];
5253
}
5354
`;

packages/react-router-dev/vite/plugin.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -871,7 +871,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
871871
}
872872
`
873873
: ""
874-
}`;
874+
}
875+
export const allowedActionOrigins = ${JSON.stringify(ctx.reactRouterConfig.allowedActionOrigins)};
876+
`;
875877
};
876878

877879
let loadViteManifest = async (directory: string) => {
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
export function throwIfPotentialCSRFAttack(
2+
headers: Headers,
3+
allowedActionOrigins: string[] | undefined,
4+
) {
5+
let originHeader = headers.get("origin");
6+
let originDomain =
7+
typeof originHeader === "string" && originHeader !== "null"
8+
? new URL(originHeader).host
9+
: originHeader;
10+
let host = parseHostHeader(headers);
11+
12+
if (originDomain && (!host || originDomain !== host.value)) {
13+
if (!isAllowedOrigin(originDomain, allowedActionOrigins)) {
14+
if (host) {
15+
// This seems to be an CSRF attack. We should not proceed with the action.
16+
throw new Error(
17+
`${host.type} header does not match \`origin\` header from a forwarded ` +
18+
`action request. Aborting the action.`,
19+
);
20+
} else {
21+
// This is an attack. We should not proceed with the action.
22+
throw new Error(
23+
"`x-forwarded-host` or `host` headers are not provided. One of these " +
24+
"is needed to compare the `origin` header from a forwarded action " +
25+
"request. Aborting the action.",
26+
);
27+
}
28+
}
29+
}
30+
}
31+
32+
// Implementation of micromatch by Next.js https://github.com/vercel/next.js/blob/ea927b583d24f42e538001bf13370e38c91d17bf/packages/next/src/server/app-render/csrf-protection.ts#L6
33+
function matchWildcardDomain(domain: string, pattern: string) {
34+
const domainParts = domain.split(".");
35+
const patternParts = pattern.split(".");
36+
37+
if (patternParts.length < 1) {
38+
// pattern is empty and therefore invalid to match against
39+
return false;
40+
}
41+
42+
if (domainParts.length < patternParts.length) {
43+
// domain has too few segments and thus cannot match
44+
return false;
45+
}
46+
47+
// Prevent wildcards from matching entire domains (e.g. '**' or '*.com')
48+
// This ensures wildcards can only match subdomains, not the main domain
49+
if (
50+
patternParts.length === 1 &&
51+
(patternParts[0] === "*" || patternParts[0] === "**")
52+
) {
53+
return false;
54+
}
55+
56+
while (patternParts.length) {
57+
const patternPart = patternParts.pop();
58+
const domainPart = domainParts.pop();
59+
60+
switch (patternPart) {
61+
case "": {
62+
// invalid pattern. pattern segments must be non empty
63+
return false;
64+
}
65+
case "*": {
66+
// wildcard matches anything so we continue if the domain part is non-empty
67+
if (domainPart) {
68+
continue;
69+
} else {
70+
return false;
71+
}
72+
}
73+
case "**": {
74+
// if this is not the last item in the pattern the pattern is invalid
75+
if (patternParts.length > 0) {
76+
return false;
77+
}
78+
// recursive wildcard matches anything so we terminate here if the domain part is non empty
79+
return domainPart !== undefined;
80+
}
81+
case undefined:
82+
default: {
83+
if (domainPart !== patternPart) {
84+
return false;
85+
}
86+
}
87+
}
88+
}
89+
90+
// We exhausted the pattern. If we also exhausted the domain we have a match
91+
return domainParts.length === 0;
92+
}
93+
94+
function isAllowedOrigin(
95+
originDomain: string,
96+
allowedActionOrigins: string[] | undefined = [],
97+
) {
98+
return allowedActionOrigins.some(
99+
(allowedOrigin) =>
100+
allowedOrigin &&
101+
(allowedOrigin === originDomain ||
102+
matchWildcardDomain(originDomain, allowedOrigin)),
103+
);
104+
}
105+
106+
function parseHostHeader(headers: Headers) {
107+
let forwardedHostHeader = headers.get("x-forwarded-host");
108+
let forwardedHostValue = forwardedHostHeader?.split(",")[0]?.trim();
109+
let hostHeader = headers.get("host");
110+
111+
return forwardedHostValue
112+
? {
113+
type: "x-forwarded-host",
114+
value: forwardedHostValue,
115+
}
116+
: hostHeader
117+
? {
118+
type: "host",
119+
value: hostHeader,
120+
}
121+
: undefined;
122+
}

packages/react-router/lib/dom/lib.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import {
9797
} from "../hooks";
9898
import type { SerializeFrom } from "../types/route-data";
9999
import type { unstable_ClientInstrumentation } from "../router/instrumentation";
100+
import { escapeHtml } from "./ssr/markup";
100101

101102
////////////////////////////////////////////////////////////////////////////////
102103
//#region Global Stuff
@@ -2033,9 +2034,9 @@ export function ScrollRestoration({
20332034
{...props}
20342035
suppressHydrationWarning
20352036
dangerouslySetInnerHTML={{
2036-
__html: `(${restoreScroll})(${JSON.stringify(
2037-
storageKey || SCROLL_RESTORATION_STORAGE_KEY,
2038-
)}, ${JSON.stringify(ssrKey)})`,
2037+
__html: `(${restoreScroll})(${escapeHtml(
2038+
JSON.stringify(storageKey || SCROLL_RESTORATION_STORAGE_KEY),
2039+
)}, ${escapeHtml(JSON.stringify(ssrKey))})`,
20392040
}}
20402041
/>
20412042
);

0 commit comments

Comments
 (0)