From 3e294aa23857f5e15000c7b020746f1abe91bf44 Mon Sep 17 00:00:00 2001 From: Oliver Pietsch Date: Wed, 20 May 2026 08:52:49 +0200 Subject: [PATCH 1/2] fix(history): use absolute path on http(s) to keep userinfo Chromium enforces userinfo equality on pushState/replaceState. Building the URL via `location.protocol + '//' + location.host` strips userinfo and trips SecurityError on basic-auth documents like http://user:pass@host/. Pass an absolute path on http(s) so the browser resolves it against the document URL. For file:// (no host) and `//`-prefixed paths (#261), still use createBaseLocation() but include userinfo so those branches don't strip it either. Refs #2714 --- packages/router/src/history/html5.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/router/src/history/html5.ts b/packages/router/src/history/html5.ts index afdc05cff..35b298686 100644 --- a/packages/router/src/history/html5.ts +++ b/packages/router/src/history/html5.ts @@ -19,7 +19,17 @@ import { assign } from '../utils' type PopStateListener = (this: Window, ev: PopStateEvent) => any -let createBaseLocation = () => location.protocol + '//' + location.host +let createBaseLocation = () => { + // location.host omits userinfo, but pushState/replaceState on Chromium + // requires the URL to match the document URL including userinfo (#2714). + // Parse it back via the URL constructor. + const { protocol, host } = location + const { username, password } = new URL(location.href) + const userinfo = username + ? username + (password ? ':' + password : '') + '@' + : '' + return protocol + '//' + userinfo + host +} interface StateEntry extends HistoryState { back: HistoryLocation | null @@ -227,7 +237,14 @@ function useHistoryStateNavigation(base: string) { ? (location.host && document.querySelector('base') ? base : base.slice(hashIndex)) + to - : createBaseLocation() + base + to + : // pass an absolute path on http(s) so the browser resolves it + // against the document URL — preserves userinfo and avoids + // Chromium's SecurityError on basic-auth URLs (#2714). The explicit + // origin is still needed for file:// (no host) and protocol-relative + // `//` paths (#261). + location.protocol === 'file:' || (base + to).startsWith('//') + ? createBaseLocation() + base + to + : base + to try { // BROWSER QUIRK // NOTE: Safari throws a SecurityError when calling this function 100 times in 30 seconds From 2d0dabe7225bc0a8a5905632ba01678e93bb2f79 Mon Sep 17 00:00:00 2001 From: Oliver Pietsch Date: Wed, 20 May 2026 08:52:50 +0200 Subject: [PATCH 2/2] test: cover userinfo, file://, and // in changeLocation --- .../router/__tests__/history/html5.spec.ts | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/router/__tests__/history/html5.spec.ts b/packages/router/__tests__/history/html5.spec.ts index d755a4cdc..7b22612c9 100644 --- a/packages/router/__tests__/history/html5.spec.ts +++ b/packages/router/__tests__/history/html5.spec.ts @@ -93,7 +93,7 @@ describe('History HTMl5', () => { expect(spy).toHaveBeenCalledWith( expect.anything(), expect.any(String), - 'http://localhost:3000/foo' + '/foo' ) history.push('//foo') expect(spy).toHaveBeenLastCalledWith( @@ -104,6 +104,39 @@ describe('History HTMl5', () => { spy.mockRestore() }) + it('passes an absolute path when document URL contains userinfo', () => { + getWindow().happyDOM.setURL('http://test:test@localhost:3000/') + const spy = vi.spyOn(window.history, 'replaceState') + createWebHistory() + expect(spy).toHaveBeenCalledWith(expect.anything(), expect.any(String), '/') + spy.mockRestore() + }) + + it('keeps userinfo when prepending the host for // urls', () => { + getWindow().happyDOM.setURL('http://test:test@localhost:3000/') + const history = createWebHistory() + const spy = vi.spyOn(window.history, 'pushState') + history.push('//foo') + expect(spy).toHaveBeenLastCalledWith( + expect.anything(), + expect.any(String), + 'http://test:test@localhost:3000//foo' + ) + spy.mockRestore() + }) + + it('prepends the file:// origin on file documents without a hash base', () => { + getWindow().happyDOM.setURL('file:///app/index.html') + const spy = vi.spyOn(window.history, 'replaceState') + createWebHistory() + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + 'file:///app/index.html' + ) + spy.mockRestore() + }) + describe('specific to base containing a hash', () => { it('calls push with hash part of the url with a base', () => { getWindow().happyDOM.setURL('file:///usr/etc/index.html')