Skip to content

Commit 96289bf

Browse files
authored
fix(next): block encoded and escaped open redirects in getSafeRedirect (#11907)
### What This PR improves the `getSafeRedirect` utility to improve security around open redirect handling. ### How - Normalizes and decodes the redirect path using `decodeURIComponent` - Catches malformed encodings with a try/catch fallback - Blocks open redirects
1 parent a6f7ef8 commit 96289bf

File tree

3 files changed

+70
-3
lines changed

3 files changed

+70
-3
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { getSafeRedirect } from './getSafeRedirect'
2+
3+
const fallback = '/admin' // default fallback if the input is unsafe or invalid
4+
5+
describe('getSafeRedirect', () => {
6+
// Valid - safe redirect paths
7+
it.each([['/dashboard'], ['/admin/settings'], ['/projects?id=123'], ['/hello-world']])(
8+
'should allow safe relative path: %s',
9+
(input) => {
10+
// If the input is a clean relative path, it should be returned as-is
11+
expect(getSafeRedirect(input, fallback)).toBe(input)
12+
},
13+
)
14+
15+
// Invalid types or empty inputs
16+
it.each(['', null, undefined, 123, {}, []])(
17+
'should fallback on invalid or non-string input: %s',
18+
(input) => {
19+
// If the input is not a valid string, it should return the fallback
20+
expect(getSafeRedirect(input as any, fallback)).toBe(fallback)
21+
},
22+
)
23+
24+
// Unsafe redirect patterns
25+
it.each([
26+
'//example.com', // protocol-relative URL
27+
'/javascript:alert(1)', // JavaScript scheme
28+
'/JavaScript:alert(1)', // case-insensitive JavaScript
29+
'/http://unknown.com', // disguised external redirect
30+
'/https://unknown.com', // disguised external redirect
31+
'/%2Funknown.com', // encoded slash — could resolve to //
32+
'/\\/unknown.com', // escaped slash
33+
'/\\\\unknown.com', // double escaped slashes
34+
'/\\unknown.com', // single escaped slash
35+
'%2F%2Funknown.com', // fully encoded protocol-relative path
36+
'%2Fjavascript:alert(1)', // encoded JavaScript scheme
37+
])('should block unsafe redirect: %s', (input) => {
38+
// All of these should return the fallback because they’re unsafe
39+
expect(getSafeRedirect(input, fallback)).toBe(fallback)
40+
})
41+
42+
// Input with extra spaces should still be properly handled
43+
it('should trim whitespace before evaluating', () => {
44+
// A valid path with surrounding spaces should still be accepted
45+
expect(getSafeRedirect(' /dashboard ', fallback)).toBe('/dashboard')
46+
47+
// An unsafe path with spaces should still be rejected
48+
expect(getSafeRedirect(' //example.com ', fallback)).toBe(fallback)
49+
})
50+
51+
// If decoding the input fails (e.g., invalid percent encoding), it should not crash
52+
it('should return fallback on invalid encoding', () => {
53+
expect(getSafeRedirect('%E0%A4%A', fallback)).toBe(fallback)
54+
})
55+
})

packages/next/src/utilities/getSafeRedirect.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,25 @@ export const getSafeRedirect = (
66
return fallback
77
}
88

9-
// Ensures that any leading or trailing whitespace doesn’t affect the checks
10-
const redirectPath = redirectParam.trim()
9+
// Normalize and decode the path
10+
let redirectPath: string
11+
try {
12+
redirectPath = decodeURIComponent(redirectParam.trim())
13+
} catch {
14+
return fallback // invalid encoding
15+
}
1116

1217
const isSafeRedirect =
1318
// Must start with a single forward slash (e.g., "/admin")
1419
redirectPath.startsWith('/') &&
15-
// Prevent protocol-relative URLs (e.g., "//evil.com")
20+
// Prevent protocol-relative URLs (e.g., "//example.com")
1621
!redirectPath.startsWith('//') &&
22+
// Prevent encoded slashes that could resolve to protocol-relative
23+
!redirectPath.startsWith('/%2F') &&
24+
// Prevent backslash-based escape attempts (e.g., "/\\/example.com", "/\\\\example.com", "/\\example.com")
25+
!redirectPath.startsWith('/\\/') &&
26+
!redirectPath.startsWith('/\\\\') &&
27+
!redirectPath.startsWith('/\\') &&
1728
// Prevent javascript-based schemes (e.g., "/javascript:alert(1)")
1829
!redirectPath.toLowerCase().startsWith('/javascript:') &&
1930
// Prevent attempts to redirect to full URLs using "/http:" or "/https:"

test/admin-root/payload-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export type SupportedTimezones =
5454
| 'Asia/Singapore'
5555
| 'Asia/Tokyo'
5656
| 'Asia/Seoul'
57+
| 'Australia/Brisbane'
5758
| 'Australia/Sydney'
5859
| 'Pacific/Guam'
5960
| 'Pacific/Noumea'

0 commit comments

Comments
 (0)