Files
hermes-agent/apps/desktop/electron/remote-ws-headers.ts
Austin Pickett 03b0c79472 fix(desktop): send Cloudflare Access headers on OAuth login (#110987)
* test(desktop): cover access-proxy headers on OAuth login URLs

Connections extra headers must apply to /login and OAuth-partition
requests, not only an exact remembered WebSocket URL.

* fix(desktop): send extra gateway headers on OAuth login

Cloudflare Access service tokens never reached the OAuth sign-in
window because they were injected only on defaultSession and v1
remote config. Attach them on OAuth partitions and loadURL too.

* fix(desktop): strip CR/LF from remote gateway header values

The PR this builds on sanitized header values in remote-ws-headers.ts, on
the resolution path only. That left the sibling consumers of
decryptRemoteHeaders untouched: buildRemoteConnection and both
connection-test paths still handed raw values to setHeader.

Sanitize on the canonical normalizer instead, where header NAMES are
already validated against REMOTE_HEADER_NAME_RE, so every consumer
inherits it. normalizeRemoteHeaders only trim()'d, which strips a
trailing newline but leaves an EMBEDDED CR/LF intact — the actual
request-splitting vector.

* refactor(desktop): drop the duplicate header normalizer from remote-ws-headers

Header values now arrive sanitized from decryptRemoteHeaders, so the
second sanitizeRemoteHeaderValue/sanitizeRemoteHeaderMap pair here was a
duplicate of the canonical one in connection-config.

The routing test keeps its original job (Connections headers reach
/login, longest base URL wins); the CR/LF assertions move to
connection-config.test.ts where the stripping now lives.

* perf(desktop): memoize decrypted remote header sources

onBeforeSendHeaders is now attached to every OAuth partition as well as
defaultSession, and headersForRemoteRequest decrypts EVERY registry
connection's headers. A safeStorage-encoded value costs a keychain
round-trip per read, so that was N decryptions per subresource request
where main previously did at most one behind a modeIsRemoteLike gate.

Key the collected sources off the connection config and registry cache
objects, which both readers already refresh on mtime change.

---------

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
2026-09-16 09:46:44 -04:00

194 lines
5.6 KiB
TypeScript

import { remoteRequestMatchesBaseUrl } from './connection-config'
import { registryGatewayWsUrl } from './plugin-profile-routes'
export interface RegistryGatewayWsConnection {
authMode: string
baseUrl: string
wsUrl: string
headers?: Record<string, string>
profile?: null | string
sharedRemote?: boolean
}
interface RegistryGatewayWsUrlDependencies {
ensureBackend: (connectionId: unknown, profile: unknown) => Promise<RegistryGatewayWsConnection>
mintTicket: (baseUrl: string, headers?: Record<string, string>) => Promise<string>
buildTicketUrl: (baseUrl: string, ticket: string) => string
rememberHeaders: (wsUrl: string, headers?: Record<string, string>) => void
}
interface RemoteRequestDetails {
url: string
requestHeaders?: Record<string, string>
}
type RemoteRequestCallback = (result: { requestHeaders?: Record<string, string> }) => void
export interface RemoteHeaderSource {
headers?: Record<string, string>
kind?: string
url?: string
}
interface SessionLike {
webRequest?: {
onBeforeSendHeaders?: (
listener: (details: RemoteRequestDetails, callback: RemoteRequestCallback) => void
) => void
}
}
/**
* Header blocks that Chromium (login window, renderer WS) may attach to a
* remote gateway request. Registry Connections are the live source; the v1
* single-remote block is fallback. Longer base URLs win so a path-prefixed
* gateway is not shadowed by its origin sibling.
*
* Values arrive already sanitized: decryptRemoteHeaders in main.ts strips
* CR/LF on the single decrypt funnel, so there is no second normalizer here.
*/
export function collectRemoteHeaderSources(input: {
connections?: RemoteHeaderSource[]
v1Remote?: null | RemoteHeaderSource
}): RemoteHeaderSource[] {
const sources: RemoteHeaderSource[] = []
for (const connection of input.connections || []) {
if (connection.kind && connection.kind !== 'remote' && connection.kind !== 'cloud') {
continue
}
if (!connection.url || !connection.headers || Object.keys(connection.headers).length === 0) {
continue
}
sources.push({ headers: connection.headers, url: connection.url })
}
if (input.v1Remote?.url && input.v1Remote.headers && Object.keys(input.v1Remote.headers).length > 0) {
sources.push({
headers: input.v1Remote.headers,
url: input.v1Remote.url
})
}
return sources.sort((a, b) => String(b.url || '').length - String(a.url || '').length)
}
export function resolveRemoteRequestHeaders(
requestUrl: string,
options: { exactHeaders?: Record<string, string>; sources?: RemoteHeaderSource[] } = {}
): Record<string, string> {
const exact = options.exactHeaders || {}
if (Object.keys(exact).length > 0) {
return exact
}
for (const source of options.sources || []) {
if (!source.url || !source.headers) {
continue
}
if (Object.keys(source.headers).length > 0 && remoteRequestMatchesBaseUrl(requestUrl, source.url)) {
return source.headers
}
}
return {}
}
export function formatLoadUrlExtraHeaders(headers: Record<string, string> = {}): string {
return Object.entries(headers)
.map(([name, value]) => `${name}: ${value}`)
.join('\n')
}
export function oauthLoginLoadUrlOptions(headers: Record<string, string> = {}): { extraHeaders?: string } {
const extraHeaders = formatLoadUrlExtraHeaders(headers)
return extraHeaders ? { extraHeaders } : {}
}
export function attachRemoteRequestHeaderListener(
sessionLike: SessionLike,
headersForRequest: (requestUrl: string) => Record<string, string>
) {
sessionLike?.webRequest?.onBeforeSendHeaders?.((details, callback) => {
applyRemoteRequestHeaders(details, callback, headersForRequest)
})
}
export function createRemoteWsHeaderStore(limit = 100) {
const headersByUrl = new Map<string, Record<string, string>>()
const remember = (wsUrl: string, headers: Record<string, string> = {}) => {
if (!wsUrl || Object.keys(headers).length === 0) {
return
}
headersByUrl.set(String(wsUrl), headers)
while (headersByUrl.size > limit) {
const oldest = headersByUrl.keys().next().value
if (!oldest) {
break
}
headersByUrl.delete(oldest)
}
}
const headersFor = (requestUrl: string): Record<string, string> => {
const key = String(requestUrl)
const headers = headersByUrl.get(key)
if (!headers) {
return {}
}
headersByUrl.delete(key)
headersByUrl.set(key, headers)
return headers
}
return { headersFor, remember }
}
export function applyRemoteRequestHeaders(
details: RemoteRequestDetails,
callback: RemoteRequestCallback,
headersForRequest: (requestUrl: string) => Record<string, string>
) {
const headers = headersForRequest(details.url)
if (Object.keys(headers).length === 0) {
callback({})
return
}
callback({ requestHeaders: { ...details.requestHeaders, ...headers } })
}
export function createRegistryGatewayWsUrlHandler(dependencies: RegistryGatewayWsUrlDependencies) {
return async (payload: unknown): Promise<string> => {
const { connectionId, profile } = payload && typeof payload === 'object' ? (payload as any) : ({} as any)
const connection = await dependencies.ensureBackend(connectionId, profile)
let wsUrl = connection.wsUrl
if (connection.authMode === 'oauth') {
const ticket = await dependencies.mintTicket(connection.baseUrl, connection.headers)
wsUrl = dependencies.buildTicketUrl(connection.baseUrl, ticket)
}
const finalWsUrl = registryGatewayWsUrl(connection, wsUrl)
dependencies.rememberHeaders(finalWsUrl, connection.headers)
return finalWsUrl
}
}