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
This commit is contained in:
Patrik
2025-03-31 13:11:34 -04:00
committed by GitHub
parent a6f7ef837a
commit 96289bf555
3 changed files with 70 additions and 3 deletions

View File

@@ -0,0 +1,55 @@
import { getSafeRedirect } from './getSafeRedirect'
const fallback = '/admin' // default fallback if the input is unsafe or invalid
describe('getSafeRedirect', () => {
// Valid - safe redirect paths
it.each([['/dashboard'], ['/admin/settings'], ['/projects?id=123'], ['/hello-world']])(
'should allow safe relative path: %s',
(input) => {
// If the input is a clean relative path, it should be returned as-is
expect(getSafeRedirect(input, fallback)).toBe(input)
},
)
// Invalid types or empty inputs
it.each(['', null, undefined, 123, {}, []])(
'should fallback on invalid or non-string input: %s',
(input) => {
// If the input is not a valid string, it should return the fallback
expect(getSafeRedirect(input as any, fallback)).toBe(fallback)
},
)
// Unsafe redirect patterns
it.each([
'//example.com', // protocol-relative URL
'/javascript:alert(1)', // JavaScript scheme
'/JavaScript:alert(1)', // case-insensitive JavaScript
'/http://unknown.com', // disguised external redirect
'/https://unknown.com', // disguised external redirect
'/%2Funknown.com', // encoded slash — could resolve to //
'/\\/unknown.com', // escaped slash
'/\\\\unknown.com', // double escaped slashes
'/\\unknown.com', // single escaped slash
'%2F%2Funknown.com', // fully encoded protocol-relative path
'%2Fjavascript:alert(1)', // encoded JavaScript scheme
])('should block unsafe redirect: %s', (input) => {
// All of these should return the fallback because theyre unsafe
expect(getSafeRedirect(input, fallback)).toBe(fallback)
})
// Input with extra spaces should still be properly handled
it('should trim whitespace before evaluating', () => {
// A valid path with surrounding spaces should still be accepted
expect(getSafeRedirect(' /dashboard ', fallback)).toBe('/dashboard')
// An unsafe path with spaces should still be rejected
expect(getSafeRedirect(' //example.com ', fallback)).toBe(fallback)
})
// If decoding the input fails (e.g., invalid percent encoding), it should not crash
it('should return fallback on invalid encoding', () => {
expect(getSafeRedirect('%E0%A4%A', fallback)).toBe(fallback)
})
})

View File

@@ -6,14 +6,25 @@ export const getSafeRedirect = (
return fallback return fallback
} }
// Ensures that any leading or trailing whitespace doesnt affect the checks // Normalize and decode the path
const redirectPath = redirectParam.trim() let redirectPath: string
try {
redirectPath = decodeURIComponent(redirectParam.trim())
} catch {
return fallback // invalid encoding
}
const isSafeRedirect = const isSafeRedirect =
// Must start with a single forward slash (e.g., "/admin") // Must start with a single forward slash (e.g., "/admin")
redirectPath.startsWith('/') && redirectPath.startsWith('/') &&
// Prevent protocol-relative URLs (e.g., "//evil.com") // Prevent protocol-relative URLs (e.g., "//example.com")
!redirectPath.startsWith('//') && !redirectPath.startsWith('//') &&
// Prevent encoded slashes that could resolve to protocol-relative
!redirectPath.startsWith('/%2F') &&
// Prevent backslash-based escape attempts (e.g., "/\\/example.com", "/\\\\example.com", "/\\example.com")
!redirectPath.startsWith('/\\/') &&
!redirectPath.startsWith('/\\\\') &&
!redirectPath.startsWith('/\\') &&
// Prevent javascript-based schemes (e.g., "/javascript:alert(1)") // Prevent javascript-based schemes (e.g., "/javascript:alert(1)")
!redirectPath.toLowerCase().startsWith('/javascript:') && !redirectPath.toLowerCase().startsWith('/javascript:') &&
// Prevent attempts to redirect to full URLs using "/http:" or "/https:" // Prevent attempts to redirect to full URLs using "/http:" or "/https:"

View File

@@ -54,6 +54,7 @@ export type SupportedTimezones =
| 'Asia/Singapore' | 'Asia/Singapore'
| 'Asia/Tokyo' | 'Asia/Tokyo'
| 'Asia/Seoul' | 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney' | 'Australia/Sydney'
| 'Pacific/Guam' | 'Pacific/Guam'
| 'Pacific/Noumea' | 'Pacific/Noumea'