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) {