Skip to content

Commit ce06bee

Browse files
committed
fix(url): harden redirect path validation
Add safe redirect path helpers and reject protocol-relative or backslash-based redirect targets before appending redirect parameters. Tested: pnpm run lint && pnpm run typecheck && pnpm test tests/url.test.ts
1 parent df4320a commit ce06bee

2 files changed

Lines changed: 88 additions & 8 deletions

File tree

src/url.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,15 @@
55
*
66
* @param {string} url - The target URL to modify
77
* @param {string} redirectPath - The path to use as the redirect destination
8-
* (must be an absolute path starting with '/')
8+
* (must be a safe same-origin application path starting with `/`)
99
*
1010
* @returns {string} A new URL string with the `redirect` query parameter
1111
*
12-
* @throws {Error} If redirectPath is not a valid relative path (must start with '/')
12+
* @throws {Error} If redirectPath is not a safe same-origin application path
1313
*/
1414
export function appendRedirectParamToUrl(url: string, redirectPath: string) {
15-
if (!redirectPath.startsWith('/')) {
16-
throw new Error(
17-
`Invalid redirect path: "${redirectPath}". Redirect paths must be absolute paths starting with '/'.`,
18-
);
19-
}
15+
// eslint-disable-next-line style/max-len
16+
if (!isSafeRedirectPath(redirectPath)) throw new Error(`Invalid redirect path: "${redirectPath}". Redirect paths must be safe same-origin paths starting with '/'.`);
2017

2118
const [baseAndHash, rawQuery = ''] = url.split('?');
2219
const [base, hash] = (baseAndHash || '').split('#');
@@ -25,3 +22,38 @@ export function appendRedirectParamToUrl(url: string, redirectPath: string) {
2522
const queryString = searchParams.toString();
2623
return hash ? `${base}?${queryString}#${hash}` : `${base}?${queryString}`;
2724
}
25+
26+
/**
27+
* Returns whether a value is a safe same-origin redirect path.
28+
*
29+
* Safe redirect paths are absolute application paths such as `/dashboard`.
30+
* Protocol-relative URLs (`//example.com`), absolute URLs, backslash paths,
31+
* and non-string values are rejected.
32+
*
33+
* @param {unknown} value - The value to check
34+
*
35+
* @returns {boolean} Whether the value is safe to use as an application redirect path
36+
*/
37+
export function isSafeRedirectPath(value: unknown): value is string {
38+
if (typeof value !== 'string') return false;
39+
if (!value.startsWith('/')) return false;
40+
if (value.startsWith('//')) return false;
41+
if (value.includes('\\')) return false;
42+
return true;
43+
}
44+
45+
/**
46+
* Normalizes a redirect value into a safe same-origin application path.
47+
*
48+
* If an array is provided, the first value is checked. Unsafe values fall back
49+
* to the provided fallback path.
50+
*
51+
* @param {unknown} value - The redirect value to normalize
52+
* @param {string} fallback - The safe fallback path
53+
*
54+
* @returns {string} A safe redirect path
55+
*/
56+
export function normalizeRedirectPath(value: unknown, fallback = '/') {
57+
const redirectPath = Array.isArray(value) ? value[0] : value;
58+
return isSafeRedirectPath(redirectPath) ? redirectPath : fallback;
59+
}

tests/url.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,47 @@ import {
33
it,
44
} from 'vitest';
55

6-
import { appendRedirectParamToUrl } from '../src/url';
6+
import {
7+
appendRedirectParamToUrl,
8+
isSafeRedirectPath,
9+
normalizeRedirectPath,
10+
} from '../src/url';
11+
12+
describe.concurrent('isSafeRedirectPath', () => {
13+
it('should accept same-origin application paths', ({ expect }) => {
14+
expect(isSafeRedirectPath('/')).toBe(true);
15+
expect(isSafeRedirectPath('/dashboard')).toBe(true);
16+
expect(isSafeRedirectPath('/dashboard?tab=home#section')).toBe(true);
17+
});
18+
19+
it('should reject unsafe redirect paths', ({ expect }) => {
20+
expect(isSafeRedirectPath('')).toBe(false);
21+
expect(isSafeRedirectPath('dashboard')).toBe(false);
22+
expect(isSafeRedirectPath('https://evil.com')).toBe(false);
23+
expect(isSafeRedirectPath('http://evil.com')).toBe(false);
24+
expect(isSafeRedirectPath('//evil.com')).toBe(false);
25+
expect(isSafeRedirectPath('/\\evil.com')).toBe(false);
26+
expect(isSafeRedirectPath(123)).toBe(false);
27+
expect(isSafeRedirectPath(undefined)).toBe(false);
28+
});
29+
});
30+
31+
describe.concurrent('normalizeRedirectPath', () => {
32+
it('should return safe redirect paths', ({ expect }) => {
33+
expect(normalizeRedirectPath('/dashboard')).toBe('/dashboard');
34+
expect(normalizeRedirectPath([
35+
'/dashboard',
36+
'//evil.com',
37+
])).toBe('/dashboard');
38+
});
39+
40+
it('should return fallback for unsafe redirect paths', ({ expect }) => {
41+
expect(normalizeRedirectPath('//evil.com')).toBe('/');
42+
expect(normalizeRedirectPath('https://evil.com')).toBe('/');
43+
expect(normalizeRedirectPath('/\\evil.com')).toBe('/');
44+
expect(normalizeRedirectPath(undefined, '/fallback')).toBe('/fallback');
45+
});
46+
});
747

848
describe.concurrent('appendRedirectParamToUrl', () => {
949
it('should append redirect param to URL with no existing query', ({ expect }) => {
@@ -35,6 +75,14 @@ describe.concurrent('appendRedirectParamToUrl', () => {
3575
expect(() => appendRedirectParamToUrl('/login', 'http://evil.com')).toThrow();
3676
});
3777

78+
it('should throw error for protocol-relative URL as redirectPath', ({ expect }) => {
79+
expect(() => appendRedirectParamToUrl('/login', '//evil.com')).toThrow();
80+
});
81+
82+
it('should throw error for backslash redirectPath', ({ expect }) => {
83+
expect(() => appendRedirectParamToUrl('/login', '/\\evil.com')).toThrow();
84+
});
85+
3886
it('should handle URL starting with question mark', ({ expect }) => {
3987
const result = appendRedirectParamToUrl('?foo=bar', '/dashboard');
4088
expect(result).toBe('?foo=bar&redirect=%2Fdashboard');

0 commit comments

Comments
 (0)