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.
This commit is contained in:
FroRaut
2026-10-01 21:15:49 +05:30
committed by kshitij
parent f15c19c25f
commit 12c1b80ec7
2 changed files with 89 additions and 12 deletions

View File

@@ -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({})
})
})

View File

@@ -112,8 +112,14 @@ export function attachRemoteRequestHeaderListener(
sessionLike: SessionLike,
headersForRequest: (requestUrl: string) => Record<string, string>
) {
// 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<string, Set<string>>()
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<string, string>
headersForRequest: (requestUrl: string) => Record<string, string>,
managedHeaderValues: Map<string, Set<string>> = new Map<string, Set<string>>()
) {
const headers = headersForRequest(details.url)
const allowedHeaderNames = new Set<string>()
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<string>()
values.add(String(value))
managedHeaderValues.set(normalizedName, values)
allowedHeaderNames.add(normalizedName)
}
const requestHeaders: Record<string, string> = {}
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) {