Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-sanitizepath-backslash.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 7 additions & 4 deletions packages/history/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/history/tests/parseHref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down