From 12c1b80ec79a5f4a2e4b293634ade37885196bcb Mon Sep 17 00:00:00 2001 From: FroRaut <79215537+Froraut@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:15:49 +0530 Subject: [PATCH] fix(desktop): strip configured remote headers from out-of-scope redirect hops Chromium carries app-set request headers (Authorization, CF-Access-*) across redirects, so a gateway that 30x-redirects to another origin or a path outside the connection's scope leaked those credentials to the redirect target. applyRemoteRequestHeaders now remembers every configured header value the session injected and removes it from hops that are outside its gateway scope, re-applying only the headers resolved for the current URL. Unrelated requests that happen to use the same header name with a different value are untouched. Hand rebuild of #129625 onto current main: keeps the withEmbedRefererStamp wrapper on the single onBeforeSendHeaders listener, drops the live Electron fixture/test. Salvage of #129625. --- .../electron/remote-ws-headers.test.ts | 58 ++++++++++++++++--- apps/desktop/electron/remote-ws-headers.ts | 43 ++++++++++++-- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/apps/desktop/electron/remote-ws-headers.test.ts b/apps/desktop/electron/remote-ws-headers.test.ts index fb4e3c9db9..40fd9177b5 100644 --- a/apps/desktop/electron/remote-ws-headers.test.ts +++ b/apps/desktop/electron/remote-ws-headers.test.ts @@ -187,7 +187,7 @@ describe('OAuth login and registry extra headers', () => { }) }) - it('injects extra headers on an OAuth partition session, not only defaultSession', () => { + it('keeps configured headers inside their gateway path and strips them from redirects outside it', () => { const listeners = [] const oauthSession = { @@ -198,18 +198,60 @@ describe('OAuth login and registry extra headers', () => { } } + const scopedHeaders = { ...accessHeaders, Authorization: 'Bearer configured-secret' } + const sources = collectRemoteHeaderSources({ - connections: [{ kind: 'remote', url: 'https://gateway.example', headers: accessHeaders }] + connections: [{ kind: 'remote', url: 'https://gateway.example/hermes', headers: scopedHeaders }] }) attachRemoteRequestHeaderListener(oauthSession, url => resolveRemoteRequestHeaders(url, { sources })) - const callback = vi.fn() - listeners[0]({ url: 'https://gateway.example/login', requestHeaders: { Origin: 'app://hermes' } }, callback) - - expect(listeners).toHaveLength(1) - expect(callback).toHaveBeenCalledWith({ - requestHeaders: { Origin: 'app://hermes', ...accessHeaders } + const initial = vi.fn() + listeners[0]({ url: 'https://gateway.example/hermes/login', requestHeaders: { Origin: 'app://hermes' } }, initial) + expect(initial).toHaveBeenCalledWith({ + requestHeaders: { Origin: 'app://hermes', ...scopedHeaders } }) + + const sameScope = vi.fn() + listeners[0]( + { + url: 'https://gateway.example/hermes/ready', + requestHeaders: { Origin: 'app://hermes', Cookie: 'session=live', ...scopedHeaders } + }, + sameScope + ) + expect(sameScope).toHaveBeenCalledWith({ + requestHeaders: { Origin: 'app://hermes', Cookie: 'session=live', ...scopedHeaders } + }) + + for (const redirectUrl of ['https://gateway.example/login', 'https://identity.example/callback']) { + const redirected = vi.fn() + listeners[0]( + { + url: redirectUrl, + requestHeaders: { + Origin: 'app://hermes', + Cookie: 'idp-session=live', + authorization: scopedHeaders.Authorization, + 'cf-access-client-id': scopedHeaders['CF-Access-Client-Id'], + 'CF-Access-Client-Secret': scopedHeaders['CF-Access-Client-Secret'] + } + }, + redirected + ) + expect(redirected).toHaveBeenCalledWith({ + requestHeaders: { Origin: 'app://hermes', Cookie: 'idp-session=live' } + }) + } + + const unrelatedAuthorization = vi.fn() + listeners[0]( + { + url: 'https://identity.example/token', + requestHeaders: { Authorization: 'Bearer identity-provider-token' } + }, + unrelatedAuthorization + ) + expect(unrelatedAuthorization).toHaveBeenCalledWith({}) }) }) diff --git a/apps/desktop/electron/remote-ws-headers.ts b/apps/desktop/electron/remote-ws-headers.ts index 5fae666f18..f4f3cb0be1 100644 --- a/apps/desktop/electron/remote-ws-headers.ts +++ b/apps/desktop/electron/remote-ws-headers.ts @@ -112,8 +112,14 @@ export function attachRemoteRequestHeaderListener( sessionLike: SessionLike, headersForRequest: (requestUrl: string) => Record ) { + // Chromium carries arbitrary app-set headers across redirects. Remember + // every configured header value this session has injected so a later hop can + // remove that secret outside its gateway scope without stripping an + // unrelated request that happens to use the same common header name. + const managedHeaderValues = new Map>() + sessionLike?.webRequest?.onBeforeSendHeaders?.((details, callback) => { - applyRemoteRequestHeaders(details, callback, headersForRequest) + applyRemoteRequestHeaders(details, callback, headersForRequest, managedHeaderValues) }) } @@ -158,17 +164,46 @@ export function createRemoteWsHeaderStore(limit = 100) { export function applyRemoteRequestHeaders( details: RemoteRequestDetails, callback: RemoteRequestCallback, - headersForRequest: (requestUrl: string) => Record + headersForRequest: (requestUrl: string) => Record, + managedHeaderValues: Map> = new Map>() ) { const headers = headersForRequest(details.url) + const allowedHeaderNames = new Set() - if (Object.keys(headers).length === 0) { + for (const [name, value] of Object.entries(headers)) { + const normalizedName = name.toLowerCase() + const values = managedHeaderValues.get(normalizedName) || new Set() + + values.add(String(value)) + managedHeaderValues.set(normalizedName, values) + allowedHeaderNames.add(normalizedName) + } + + const requestHeaders: Record = {} + let changed = false + + for (const [name, value] of Object.entries(details.requestHeaders || {})) { + const normalizedName = name.toLowerCase() + + if (allowedHeaderNames.has(normalizedName) || managedHeaderValues.get(normalizedName)?.has(String(value))) { + changed = true + } else { + requestHeaders[name] = value + } + } + + for (const [name, value] of Object.entries(headers)) { + requestHeaders[name] = value + changed = true + } + + if (!changed) { callback({}) return } - callback({ requestHeaders: { ...details.requestHeaders, ...headers } }) + callback({ requestHeaders }) } export function createRegistryGatewayWsUrlHandler(dependencies: RegistryGatewayWsUrlDependencies) {