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:
@@ -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({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user