Skip to content

Commit 1c80b66

Browse files
authored
fix: Improve middleware pathname validation (#2304)
1 parent b65f8c4 commit 1c80b66

4 files changed

Lines changed: 94 additions & 5 deletions

File tree

β€Žpackages/next-intl/.size-limit.tsβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const config: SizeLimitConfig = [
4242
{
4343
name: "import * from 'next-intl/middleware'",
4444
path: 'dist/esm/production/middleware.js',
45-
limit: '10.1 KB'
45+
limit: '10.12 KB'
4646
},
4747
{
4848
name: "import * from 'next-intl/routing'",

β€Žpackages/next-intl/src/middleware/middleware.test.tsxβ€Ž

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,44 @@ describe('prefix-based routing', () => {
219219
);
220220
});
221221

222+
describe('open redirect prevention', () => {
223+
it('redirects to a same-origin URL when the path contains a TAB after decodeURI', () => {
224+
middleware(createMockRequest('/en/\t/example.org'));
225+
expect(MockedNextResponse.next).not.toHaveBeenCalled();
226+
expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
227+
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
228+
'http://localhost:3000/example.org'
229+
);
230+
});
231+
232+
it('redirects to a same-origin URL when the path contains an encoded backslash', () => {
233+
middleware(createMockRequest('/en/%5Cexample.org'));
234+
expect(MockedNextResponse.next).not.toHaveBeenCalled();
235+
expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
236+
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
237+
'http://localhost:3000/%5Cexample.org'
238+
);
239+
});
240+
241+
it('redirects to a same-origin URL when the path contains excess slashes before a segment', () => {
242+
middleware(createMockRequest('/en///example.org'));
243+
expect(MockedNextResponse.next).not.toHaveBeenCalled();
244+
expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
245+
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
246+
'http://localhost:3000/example.org'
247+
);
248+
});
249+
250+
it('redirects to a same-origin URL when TAB is double-encoded as %2509', () => {
251+
middleware(createMockRequest('/en/%2509/some-page'));
252+
expect(MockedNextResponse.next).not.toHaveBeenCalled();
253+
expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
254+
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
255+
'http://localhost:3000/%09/some-page'
256+
);
257+
});
258+
});
259+
222260
it('redirects requests for the default locale when prefixed at sub paths', () => {
223261
middleware(createMockRequest('/en/about'));
224262
expect(MockedNextResponse.next).not.toHaveBeenCalled();

β€Žpackages/next-intl/src/middleware/utils.test.tsxβ€Ž

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,49 @@ import {
44
getInternalTemplate,
55
getNormalizedPathname,
66
getPathnameMatch,
7-
getRouteParams
7+
getRouteParams,
8+
sanitizePathname
89
} from './utils.js';
910

11+
describe('sanitizePathname', () => {
12+
it('leaves normal pathnames unchanged', () => {
13+
expect(sanitizePathname('/en/about')).toBe('/en/about');
14+
expect(sanitizePathname('/ja/%E7%B4%84')).toBe('/ja/%E7%B4%84');
15+
expect(sanitizePathname('/')).toBe('/');
16+
});
17+
18+
it('encodes backslashes to prevent scheme-relative redirect via \\host', () => {
19+
expect(sanitizePathname('/en/\\example.org')).toBe('/en/%5Cexample.org');
20+
expect(sanitizePathname('/en/%5Cexample.org')).toBe('/en/%5Cexample.org');
21+
});
22+
23+
it('collapses consecutive slashes to prevent scheme-relative redirect via //host', () => {
24+
expect(sanitizePathname('/en////example.org')).toBe('/en/example.org');
25+
expect(sanitizePathname('//example.org')).toBe('/example.org');
26+
});
27+
28+
it('strips TAB (U+0009) to prevent //host collapse', () => {
29+
expect(sanitizePathname('/en/\t/example.org')).toBe('/en/example.org');
30+
expect(sanitizePathname('\t//example.org')).toBe('/example.org');
31+
});
32+
33+
it('strips LF (U+000A)', () => {
34+
expect(sanitizePathname('/en/\n/example.org')).toBe('/en/example.org');
35+
});
36+
37+
it('strips CR (U+000D)', () => {
38+
expect(sanitizePathname('/en/\r/example.org')).toBe('/en/example.org');
39+
});
40+
41+
it('strips multiple whitespace characters in combination', () => {
42+
expect(sanitizePathname('/en/\t\r\n/example.org')).toBe('/en/example.org');
43+
});
44+
45+
it('applies replacements in the correct order: backslash before slash collapse', () => {
46+
expect(sanitizePathname('/en\\/example.org')).toBe('/en%5C/example.org');
47+
});
48+
});
49+
1050
describe('getNormalizedPathname', () => {
1151
it('should return the normalized pathname', () => {
1252
function getResult(pathname: string) {

β€Žpackages/next-intl/src/middleware/utils.tsxβ€Ž

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,18 @@ export function getLocaleAsPrefix<AppLocales extends Locales>(
322322

323323
export function sanitizePathname(pathname: string) {
324324
// Sanitize malicious URIs, e.g.:
325-
// '/en/\\example.org β†’ /en/%5C%5Cexample.org'
326-
// '/en////example.org β†’ /en/example.org'
327-
return pathname.replace(/\\/g, '%5C').replace(/\/+/g, '/');
325+
// '/en/\\example.org' β†’ '/en/%5Cexample.org' (backslash β†’ %5C)
326+
// '/en/\t/example.org' β†’ '/en/example.org' (WHATWG-stripped TAB)
327+
// '/en/\n/example.org' β†’ '/en/example.org' (WHATWG-stripped LF)
328+
// '/en/\r/example.org' β†’ '/en/example.org' (WHATWG-stripped CR)
329+
// '/en////example.org' β†’ '/en/example.org' (consecutive slashes)
330+
//
331+
// U+0009/000A/000D are silently stripped by the WHATWG URL parser
332+
// (https://url.spec.whatwg.org/#concept-url-parser). Without removing
333+
// them here, a decoded TAB in a segment separator position causes
334+
// new URL("/\t/host", base) to collapse to "//host" β†’ open redirect.
335+
return pathname
336+
.replace(/\\/g, '%5C')
337+
.replace(/[\t\n\r]/g, '')
338+
.replace(/\/+/g, '/');
328339
}

0 commit comments

Comments
Β (0)