Prevent remote H5 tokens from leaking into local browser sessions

Browser H5 startup now binds the in-memory bearer token only when the selected server URL is non-loopback, so stored remote credentials cannot follow a later localhost, 127.0.0.1, or IPv6 loopback startup. Token verification errors also use the structured HTTP status when available instead of relying only on message text.

Constraint: Browser H5 may reuse localStorage across remote and local server URLs.

Rejected: Clear all remembered H5 connection state on every loopback startup | preserving the remote URL is useful for the next remote retry and the token alone is the leak risk.

Confidence: high

Scope-risk: narrow

Directive: Keep browser auth token assignment gated by requiresH5AuthForServerUrl before adding new bootstrap paths.

Tested: cd desktop && bunx vitest run src/api/client.test.ts src/api/websocket.test.ts src/lib/desktopRuntime.test.ts src/components/layout/AppShell.test.tsx
This commit is contained in:
程序员阿江(Relakkes) 2026-05-10 00:55:30 +08:00
parent 5c1583f852
commit b4242d894d
2 changed files with 22 additions and 3 deletions

View File

@ -56,7 +56,9 @@ describe('desktopRuntime browser H5 bootstrap', () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
) as typeof fetch
clientMocks.postVerify.mockRejectedValueOnce(new Error('Unauthorized'))
clientMocks.postVerify.mockRejectedValueOnce(
Object.assign(new Error('Invalid or missing H5 access token'), { status: 401 }),
)
await expect(initializeDesktopServerUrl()).rejects.toMatchObject({
name: 'H5ConnectionRequiredError',
@ -70,6 +72,20 @@ describe('desktopRuntime browser H5 bootstrap', () => {
expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBeNull()
})
it('does not reuse stored remote H5 tokens for loopback browser startup', async () => {
window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F%5B%3A%3A1%5D%3A3456')
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'remote-token')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
) as typeof fetch
await expect(initializeDesktopServerUrl()).resolves.toBe('http://[::1]:3456')
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://[::1]:3456')
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null)
expect(clientMocks.postVerify).not.toHaveBeenCalled()
})
it('normalizes unreachable remote browser startup into a recoverable H5 error', async () => {
vi.useFakeTimers()
window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Funreachable.example.com')

View File

@ -135,7 +135,7 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
const browserH5Runtime = requiresH5AuthForServerUrl(requestedUrl)
setBaseUrl(requestedUrl)
setAuthToken(token)
setAuthToken(browserH5Runtime ? token : null)
if (browserH5Runtime) {
rememberStoredH5ServerUrl(requestedUrl)
}
@ -247,7 +247,10 @@ function normalizeBrowserH5Error(error: unknown, serverUrl: string) {
const message =
error instanceof Error ? error.message : 'Unable to verify the H5 access token.'
const unauthorized = message.includes('401') || message.toLowerCase().includes('unauthorized')
const status = typeof error === 'object' && error !== null && 'status' in error
? (error as { status?: unknown }).status
: undefined
const unauthorized = status === 401 || message.includes('401') || message.toLowerCase().includes('unauthorized')
return new H5ConnectionRequiredError(
unauthorized ? 'The saved H5 token is no longer valid.' : 'Unable to verify the H5 access token.',
serverUrl,