* 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>
194 lines
5.6 KiB
TypeScript
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
|
|
}
|
|
}
|