diff --git a/.changeset/fix-sanitizepath-backslash.md b/.changeset/fix-sanitizepath-backslash.md new file mode 100644 index 00000000000..1c823672048 --- /dev/null +++ b/.changeset/fix-sanitizepath-backslash.md @@ -0,0 +1,5 @@ +--- +'@tanstack/history': patch +--- + +Prevent open redirects via backslash protocol-relative URLs. `sanitizePath` only collapsed leading forward slashes, but browsers treat backslashes as forward slashes in the authority, so hrefs like `\\evil.com`, `/\evil.com` and `\/evil.com` bypassed the guard. Leading runs of two or more slashes/backslashes are now collapsed to a single slash. diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts index 063174c7328..60e77c7c59c 100644 --- a/packages/history/src/index.ts +++ b/packages/history/src/index.ts @@ -632,10 +632,13 @@ function sanitizePath(path: string): string { // eslint-disable-next-line no-control-regex let sanitized = path.replace(/[\x00-\x1f\x7f]/g, '') - // Prevent open redirect via protocol-relative URLs (e.g. "//evil.com") - // Collapse leading double slashes to a single slash - if (sanitized.startsWith('//')) { - sanitized = '/' + sanitized.replace(/^\/+/, '') + // Prevent open redirect via protocol-relative URLs (e.g. "//evil.com"). + // Per the WHATWG URL spec, browsers treat backslashes as forward slashes in + // the authority, so "\\evil.com", "/\evil.com" and "\/evil.com" are + // equivalent to "//evil.com". Collapse any run of two or more leading + // slashes/backslashes to a single slash so all of these stay same-origin. + if (/^[/\\]{2,}/.test(sanitized)) { + sanitized = '/' + sanitized.replace(/^[/\\]+/, '') } return sanitized diff --git a/packages/history/tests/parseHref.test.ts b/packages/history/tests/parseHref.test.ts index e1b8db9c1a3..2ff0f00bef6 100644 --- a/packages/history/tests/parseHref.test.ts +++ b/packages/history/tests/parseHref.test.ts @@ -38,6 +38,17 @@ describe('parseHref', () => { expect(parsed.pathname).toBe('/evil.com/path') }) + test('collapses leading backslashes to prevent protocol-relative URLs', () => { + // Browsers treat backslashes as forward slashes in the authority, so + // these are equivalent to "//evil.com" and would otherwise redirect. + for (const href of ['\\\\evil.com/path', '/\\evil.com/path', '\\/evil.com/path']) { + const parsed = parseHref(href, undefined) + expect(parsed.pathname).toBe('/evil.com/path') + const url = new URL(parsed.href, 'http://localhost:3000') + expect(url.origin).toBe('http://localhost:3000') + } + }) + test('sanitized href resolves safely to same origin', () => { const parsed = parseHref('/\r/evil.com/', undefined) const url = new URL(parsed.href, 'http://localhost:3000')