Skip to content

Commit e4b70d6

Browse files
authored
fix: validate schemeful action origins (#15420)
Assisted-By: devx/597728f0-1b3f-4153-b929-18dbff6cd683
1 parent b137cab commit e4b70d6

3 files changed

Lines changed: 47 additions & 14 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improve validation of action request origins

packages/react-router/__tests__/server-runtime/actions-test.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,23 @@ describe("throwIfPotentialCSRFAttack", () => {
4141
},
4242
});
4343
expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow(
44-
"`request.url` host does not match `origin` header from a forwarded action request",
44+
"`request.url` origin does not match `origin` header from a forwarded action request",
4545
);
4646
});
47+
48+
it("should compare complete origins", () => {
49+
for (let [origin, requestUrl] of [
50+
["http://example.com", "https://example.com/action"],
51+
["https://example.com", "http://example.com/action"],
52+
]) {
53+
let request = new Request(requestUrl, {
54+
method: "POST",
55+
headers: { origin },
56+
});
57+
58+
expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow();
59+
}
60+
});
4761
});
4862

4963
describe("with allowed origins", () => {
@@ -59,6 +73,18 @@ describe("throwIfPotentialCSRFAttack", () => {
5973
).not.toThrow();
6074
});
6175

76+
it("should support explicitly allowed hosts", () => {
77+
let request = new Request("https://example.com/action", {
78+
method: "POST",
79+
headers: {
80+
origin: "http://example.com",
81+
},
82+
});
83+
expect(() =>
84+
throwIfPotentialCSRFAttack(request, ["example.com"]),
85+
).not.toThrow();
86+
});
87+
6288
it("should not throw when origin matches a wildcard pattern", () => {
6389
let request = new Request("https://example.com/action", {
6490
method: "POST",
@@ -93,7 +119,7 @@ describe("throwIfPotentialCSRFAttack", () => {
93119
expect(() =>
94120
throwIfPotentialCSRFAttack(request, ["trusted.com", "*.safe.com"]),
95121
).toThrow(
96-
"`request.url` host does not match `origin` header from a forwarded action request",
122+
"`request.url` origin does not match `origin` header from a forwarded action request",
97123
);
98124
});
99125

@@ -134,7 +160,7 @@ describe("throwIfPotentialCSRFAttack", () => {
134160
},
135161
});
136162
expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow(
137-
"`request.url` host does not match `origin` header from a forwarded action request",
163+
"`request.url` origin does not match `origin` header from a forwarded action request",
138164
);
139165
});
140166

@@ -181,7 +207,7 @@ describe("throwIfPotentialCSRFAttack", () => {
181207
},
182208
});
183209
expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow(
184-
"`request.url` host does not match `origin` header from a forwarded action request",
210+
"`request.url` origin does not match `origin` header from a forwarded action request",
185211
);
186212
});
187213

@@ -195,7 +221,7 @@ describe("throwIfPotentialCSRFAttack", () => {
195221
expect(() =>
196222
throwIfPotentialCSRFAttack(request, ["", "other.com"]),
197223
).toThrow(
198-
"`request.url` host does not match `origin` header from a forwarded action request",
224+
"`request.url` origin does not match `origin` header from a forwarded action request",
199225
);
200226
});
201227

@@ -219,7 +245,7 @@ describe("throwIfPotentialCSRFAttack", () => {
219245
},
220246
});
221247
expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow(
222-
"`request.url` host does not match `origin` header from a forwarded action request",
248+
"`request.url` origin does not match `origin` header from a forwarded action request",
223249
);
224250
});
225251

@@ -243,7 +269,7 @@ describe("throwIfPotentialCSRFAttack", () => {
243269
},
244270
});
245271
expect(() => throwIfPotentialCSRFAttack(request, ["*"])).toThrow(
246-
"`request.url` host does not match `origin` header from a forwarded action request",
272+
"`request.url` origin does not match `origin` header from a forwarded action request",
247273
);
248274
});
249275

packages/react-router/lib/actions.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,30 @@ export function throwIfPotentialCSRFAttack(
44
) {
55
let originHeader = request.headers.get("origin");
66
let originDomain: string | null = null;
7+
let originUrl: URL | null = null;
78

89
try {
9-
originDomain =
10-
typeof originHeader === "string" && originHeader !== "null"
11-
? new URL(originHeader).host
12-
: originHeader;
10+
if (typeof originHeader === "string" && originHeader !== "null") {
11+
originUrl = new URL(originHeader);
12+
originDomain = originUrl.host;
13+
} else {
14+
originDomain = originHeader;
15+
}
1316
} catch {
1417
throw new Error(
1518
`\`origin\` header is not a valid URL. Aborting the action.`,
1619
);
1720
}
18-
let host = new URL(request.url).host;
21+
let requestUrl = new URL(request.url);
22+
let originMatchesRequest = originUrl
23+
? originUrl.origin === requestUrl.origin
24+
: originDomain === requestUrl.host;
1925

20-
if (originDomain && originDomain !== host) {
26+
if (originDomain && !originMatchesRequest) {
2127
if (!isAllowedOrigin(originDomain, allowedActionOrigins)) {
2228
// This seems to be an CSRF attack. We should not proceed with the action.
2329
throw new Error(
24-
"The `request.url` host does not match `origin` header from a forwarded " +
30+
"The `request.url` origin does not match `origin` header from a forwarded " +
2531
"action request. Aborting the action.",
2632
);
2733
}

0 commit comments

Comments
 (0)