diff --git a/src/azure.ts b/src/azure.ts index c60271712..9b86af6cc 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -1,6 +1,12 @@ import type { RequestInit, RequestInfo, Response } from './internal/builtin-types'; import type { NullableHeaders } from './internal/headers'; -import { buildHeaders } from './internal/headers'; +import { + buildAzureAuthenticationHeaders, + buildHeaders, + materializeAzureAuthenticationHeaders, + protectAzureRequestHeaders, + withAzureRequestHeaderSnapshot, +} from './internal/headers'; import * as Errors from './error'; import type { FinalRequestOptions } from './internal/request-options'; import { isObj, readEnv } from './internal/utils'; @@ -139,6 +145,7 @@ export class AzureOpenAI extends OpenAI { throw new Errors.OpenAIError('baseURL and endpoint are mutually exclusive'); } + protectAzureAmbientHeaders(opts); super({ apiKey: azureADTokenProvider ?? apiKey, baseURL, @@ -173,20 +180,91 @@ export class AzureOpenAI extends OpenAI { /** Request timeout in milliseconds. */ timeout: number; }> { - if (_deployments_endpoints.has(options.path) && options.method === 'post' && options.body !== undefined) { - if (!isObj(options.body)) { - throw new Error('Expected request body to be an object'); + prepareAzureDeploymentRequest(options, this.deploymentName, this.baseURL); + const preprocessesHeaders = shouldProtectAzureRequestHeaders(options); + const { + copied, + headers, + options: requestOptions = options, + resolve, + restore, + } = snapshotAzureRequestOptionsHeaders(options); + const accessorSnapshot = azureRequestHeadersAccessorSnapshots.get(options); + const accessorIndex = (accessorSnapshot?.snapshots.length ?? 0) - 1; + const accessorEntry = accessorSnapshot?.snapshots[accessorIndex]; + let protection: ReturnType; + + try { + protection = protectAzureRequestHeaders(headers, options); + if (!preprocessesHeaders) { + protection?.deactivate(); } - const model = this.deploymentName || options.body['model'] || options.__metadata?.['model']; - if (model !== undefined && !this.baseURL.includes('/deployments')) { - options.path = path`/deployments/${model}` + options.path; + let pending: ReturnType; + let restoreBody: (() => void) | undefined; + try { + restoreBody = snapshotAzureRequestBodyAccessor(options); + const requestHeaders = (): FinalRequestOptions['headers'] => { + if (resolve !== undefined) { + return resolve(); + } + if (accessorEntry !== undefined) { + try { + if (accessorSnapshot?.invalidated) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + if (descriptor?.get === accessorSnapshot?.getter) { + return accessorEntry.headers; + } + if (accessorSnapshot !== undefined && accessorSnapshot.snapshots.length !== 1) { + accessorSnapshot.invalidated = true; + } + if (accessorSnapshot?.snapshots.length === 1 && descriptor && 'value' in descriptor) { + return descriptor.value; + } + } catch { + // A replaced snapshot is untrusted when its effective descriptor cannot be inspected. + } + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + if (requestOptions === options) { + return options.headers; + } + return headers; + }; + pending = withAzureRequestHeaderSnapshot( + this, + requestOptions, + options, + requestHeaders, + protection, + () => super.buildRequest(requestOptions, props), + ); + } finally { + try { + restoreBody?.(); + } finally { + protection?.deactivate(); + } } + + const built = await pending.catch((error: unknown) => { + if ( + (accessorSnapshot?.descriptor.enumerable && accessorEntry?.copied === false) || + copied?.value === false + ) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + throw error; + }); + if (built.req.headers.has('api-key')) { + built.req.redirect = 'manual'; + } + return built; + } finally { + protection?.release(); + restore?.(); } - const built = await super.buildRequest(options, props); - if (built.req.headers.has('api-key')) { - built.req.redirect = 'manual'; - } - return built; } protected override async fetchWithAuth( @@ -196,7 +274,13 @@ export class AzureOpenAI extends OpenAI { controller: AbortController, schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, ): Promise { - if (new Headers(init.headers).has('api-key')) { + const suppliedHeaders = init.headers; + const safeHeaders = snapshotCrossRealmHeaders(suppliedHeaders); + const headers = buildHeaders([buildAzureAuthenticationHeaders(), safeHeaders]).values; + if (!hasIntrinsicHeadersIdentity(suppliedHeaders)) { + init.headers = headers; + } + if (headers.has('api-key')) { init.redirect = 'manual'; } @@ -209,9 +293,648 @@ export class AzureOpenAI extends OpenAI { ): Promise { const security = schemes ?? { bearerAuth: true, adminAPIKeyAuth: true }; if (security.bearerAuth && typeof this._options.apiKey === 'string') { - return buildHeaders([{ 'api-key': this.apiKey }]); + return materializeAzureAuthenticationHeaders( + buildAzureAuthenticationHeaders([['api-key', this.apiKey]]), + ); + } + + return materializeAzureAuthenticationHeaders( + buildAzureAuthenticationHeaders( + security.bearerAuth ? await this.bearerAuth(opts) : undefined, + security.adminAPIKeyAuth ? await this.adminAPIKeyAuth(opts) : undefined, + ), + ); + } + + protected override async bearerAuth(_opts: FinalRequestOptions): Promise { + if (this.apiKey === null || this.apiKey === undefined) { + return undefined; + } + return materializeAzureAuthenticationHeaders( + buildAzureAuthenticationHeaders([['Authorization', `Bearer ${this.apiKey}`]]), + ); + } + + protected override async adminAPIKeyAuth(_opts: FinalRequestOptions): Promise { + if (this.adminAPIKey === null || this.adminAPIKey === undefined) { + return undefined; + } + return materializeAzureAuthenticationHeaders( + buildAzureAuthenticationHeaders([['Authorization', `Bearer ${this.adminAPIKey}`]]), + ); + } +} + +function prepareAzureDeploymentRequest( + options: FinalRequestOptions, + deployment: string | undefined, + baseURL: string, +): void { + if (!_deployments_endpoints.has(options.path) || options.method !== 'post') { + return; + } + let body: unknown; + try { + ({ body } = options); + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + if (body === undefined) { + return; + } + if (!isObj(body)) { + throw new Error('Expected request body to be an object'); + } + const model = deployment || body['model'] || options.__metadata?.['model']; + if (model !== undefined && !baseURL.includes('/deployments')) { + options.path = path`/deployments/${model}` + options.path; + } +} + +function shouldProtectAzureRequestHeaders(options: FinalRequestOptions): boolean { + try { + let owner: object | null = options; + let descriptor = Object.getOwnPropertyDescriptor(options, 'body'); + if (descriptor === undefined) { + for (let depth = 0; depth < 32 && owner !== null; depth += 1) { + owner = Object.getPrototypeOf(owner) as object | null; + if (owner === null) { + break; + } + descriptor = Object.getOwnPropertyDescriptor(owner, 'body'); + if (descriptor !== undefined) { + break; + } + } + } + if (typeof descriptor?.get === 'function') { + return true; + } + if (descriptor === undefined && owner !== null) { + return true; + } + const { body } = options; + return body === undefined ? 'body' in options : Boolean(body); + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } +} + +function snapshotAzureRequestBodyAccessor(options: FinalRequestOptions): (() => void) | undefined { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(options, 'body'); + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + if (!descriptor?.enumerable || !descriptor.configurable || typeof descriptor.get !== 'function') { + return undefined; + } + + const originalDescriptor: PropertyDescriptor = descriptor; + const original = descriptor.get; + const getter = function getter(this: FinalRequestOptions): unknown { + try { + return Reflect.apply(original, this, []); + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + }; + + const restore = (): void => { + if (Object.getOwnPropertyDescriptor(options, 'body')?.get !== getter) { + return; + } + Object.defineProperty(options, 'body', originalDescriptor); + }; + try { + Object.defineProperty(options, 'body', { ...originalDescriptor, get: getter }); + } catch { + try { + restore(); + } catch { + // A hostile proxy can prevent restoration after forwarding its first trap. + } + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + + return () => { + try { + restore(); + } catch { + try { + restore(); + } catch { + // Preserve the original descriptor when repeated hostile hooks prevent restoration. + } + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + }; +} + +interface AzureRequestHeadersAccessorSnapshot { + descriptor: PropertyDescriptor; + getter: () => FinalRequestOptions['headers']; + inherited: boolean; + invalidated: boolean; + snapshots: { copied: boolean; headers: FinalRequestOptions['headers'] }[]; +} + +interface AzureImmutableRequestHeadersSnapshot { + contended: boolean; +} + +interface AzureRequestOptionsHeadersSnapshot { + copied?: { value: boolean }; + headers: FinalRequestOptions['headers']; + options?: FinalRequestOptions; + resolve?: () => FinalRequestOptions['headers']; + restore?: () => void; +} + +const azureRequestHeadersAccessorSnapshots = new WeakMap< + FinalRequestOptions, + AzureRequestHeadersAccessorSnapshot +>(); +const azureImmutableRequestHeadersSnapshots = new WeakMap< + FinalRequestOptions, + Set +>(); + +function restoreAzureRequestHeadersAccessor( + options: FinalRequestOptions, + snapshot: AzureRequestHeadersAccessorSnapshot, +): void { + if (Object.getOwnPropertyDescriptor(options, 'headers')?.get !== snapshot.getter) { + azureRequestHeadersAccessorSnapshots.delete(options); + return; + } + if (snapshot.inherited) { + if (!Reflect.deleteProperty(options, 'headers')) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + } else { + Object.defineProperty(options, 'headers', snapshot.descriptor); + } + azureRequestHeadersAccessorSnapshots.delete(options); +} + +function findAzureRequestHeadersDescriptor(options: FinalRequestOptions): + | { + descriptor: PropertyDescriptor; + inherited: boolean; + } + | undefined { + let prototype: object | null = options; + for (let depth = 0; depth < 256 && prototype !== null; depth += 1) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'headers'); + if (descriptor !== undefined) { + return { descriptor, inherited: prototype !== options }; + } + prototype = Object.getPrototypeOf(prototype) as object | null; + } + if (prototype !== null) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + return undefined; +} + +function snapshotAzureRequestOptionsHeaders( + options: FinalRequestOptions, +): AzureRequestOptionsHeadersSnapshot { + try { + let active = azureRequestHeadersAccessorSnapshots.get(options); + if ( + active !== undefined && + active.snapshots.length === 0 && + Object.getOwnPropertyDescriptor(options, 'headers')?.get !== active.getter + ) { + azureRequestHeadersAccessorSnapshots.delete(options); + active = undefined; + } + const found = active ?? findAzureRequestHeadersDescriptor(options); + const descriptor = found?.descriptor; + if (descriptor === undefined || 'value' in descriptor) { + return snapshotAzureRequestDataHeaders(options, descriptor, found?.inherited === true); + } + if (found?.inherited ? !Object.isExtensible(options) : !descriptor.configurable) { + const { headers } = options; + const copied = { value: false }; + const requestOptions = snapshotAzureRequestOptions(options, headers, copied); + const immutable = + typeof descriptor.set === 'function' + ? snapshotAzureImmutableRequestHeaders(options, descriptor, headers) + : undefined; + return { + ...(descriptor.enumerable ? { copied } : {}), + headers, + options: requestOptions, + ...(immutable === undefined ? {} : { resolve: immutable.resolve, restore: immutable.restore }), + }; + } + + const headers = active === undefined ? options.headers : descriptor.get?.call(options); + return { headers, restore: snapshotAzureRequestHeadersAccessor(options, headers, descriptor) }; + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } +} + +function snapshotAzureRequestDataHeaders( + options: FinalRequestOptions, + descriptor: PropertyDescriptor | undefined, + inherited: boolean, +): AzureRequestOptionsHeadersSnapshot { + const { headers } = options; + const copied = { value: false }; + const requestOptions = snapshotAzureRequestOptions(options, headers, copied); + const stable = descriptor === undefined ? headers === undefined : descriptor.value === headers; + + return { + ...(descriptor?.enumerable && !inherited ? { copied } : {}), + headers, + options: requestOptions, + resolve: () => { + if (!stable) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + try { + const current = Object.getOwnPropertyDescriptor(options, 'headers'); + return current !== undefined && 'value' in current ? current.value : headers; + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + }, + }; +} + +function snapshotAzureRequestOptions( + options: FinalRequestOptions, + headers: FinalRequestOptions['headers'], + copied: { value: boolean }, +): FinalRequestOptions { + return new Proxy(options, { + get(target, property) { + if (property === 'headers') { + copied.value = true; + return headers; + } + return Reflect.get(target, property, target); + }, + set(target, property, value) { + return Reflect.set(target, property, value, target); + }, + }); +} + +function snapshotAzureImmutableRequestHeaders( + options: FinalRequestOptions, + descriptor: PropertyDescriptor, + headers: FinalRequestOptions['headers'], +): { + resolve: () => FinalRequestOptions['headers']; + restore: () => void; +} { + let snapshots = azureImmutableRequestHeadersSnapshots.get(options); + if (snapshots === undefined) { + snapshots = new Set(); + azureImmutableRequestHeadersSnapshots.set(options, snapshots); + } + const active = snapshots; + const snapshot = { contended: active.size !== 0 }; + if (snapshot.contended) { + for (const current of active) { + current.contended = true; + } + } + active.add(snapshot); + + return { + resolve: () => { + try { + const current = descriptor.get === undefined ? headers : descriptor.get.call(options); + if (snapshot.contended && current !== headers) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + return current; + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + }, + restore: () => { + active.delete(snapshot); + if (active.size === 0) { + azureImmutableRequestHeadersSnapshots.delete(options); + } + }, + }; +} + +function snapshotAzureRequestHeadersAccessor( + options: FinalRequestOptions, + headers: FinalRequestOptions['headers'], + descriptor: PropertyDescriptor, +): () => void { + let snapshot = azureRequestHeadersAccessorSnapshots.get(options); + if (snapshot === undefined) { + const inherited = Object.getOwnPropertyDescriptor(options, 'headers') === undefined; + const snapshots: { copied: boolean; headers: FinalRequestOptions['headers'] }[] = []; + const latestSnapshot = () => { + const index = snapshots.length - 1; + return snapshots[index]; + }; + const getter = () => { + const current = latestSnapshot(); + if (current !== undefined) { + current.copied = true; + } + return current?.headers; + }; + const originalSetter = descriptor.set; + const setter = + originalSetter === undefined + ? undefined + : function setHeaders(this: FinalRequestOptions, value: FinalRequestOptions['headers']): void { + if (snapshots.length !== 1) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + const [current] = snapshots; + originalSetter.call(this, value); + if (current !== undefined) { + try { + current.headers = descriptor.get?.call(this); + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + } + }; + snapshot = { descriptor, getter, inherited, invalidated: false, snapshots }; + azureRequestHeadersAccessorSnapshots.set(options, snapshot); + try { + Object.defineProperty(options, 'headers', { + ...descriptor, + configurable: true, + get: getter, + ...(setter === undefined ? {} : { set: setter }), + }); + } catch { + try { + restoreAzureRequestHeadersAccessor(options, snapshot); + } catch { + // Retain the original snapshot when hostile hooks also prevent restoration. + } + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + } + + const active = snapshot; + const entry = { copied: false, headers }; + active.snapshots.push(entry); + let restored = false; + return () => { + if (restored) { + return; + } + restored = true; + const index = active.snapshots.indexOf(entry); + if (index !== -1) { + active.snapshots.splice(index, 1); + } + if (active.snapshots.length !== 0) { + return; + } + + try { + restoreAzureRequestHeadersAccessor(options, active); + } catch { + try { + restoreAzureRequestHeadersAccessor(options, active); + } catch { + // Retain the original snapshot when hostile hooks also prevent restoration. + } + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + }; +} + +const intrinsicHeadersPrototype = Headers.prototype; +const intrinsicHeadersHas = intrinsicHeadersPrototype.has; +const intrinsicHeadersOperations = [ + 'append', + 'delete', + 'entries', + 'forEach', + 'get', + 'getSetCookie', + 'has', + 'keys', + 'set', + 'values', + Symbol.iterator, +] as const; +const intrinsicHeadersDescriptors = new Map( + intrinsicHeadersOperations.map((operation) => [ + operation, + Object.getOwnPropertyDescriptor(intrinsicHeadersPrototype, operation)?.value, + ]), +); + +function hasIntrinsicHeadersIdentity(headers: RequestInit['headers']): headers is Headers { + if (!(headers instanceof Headers)) { + return false; + } + + try { + intrinsicHeadersHas.call(headers, 'api-key'); + + let prototype: object | null = headers; + for (let depth = 0; depth < 32 && prototype !== null; depth++) { + if (prototype === intrinsicHeadersPrototype) { + return intrinsicHeadersOperations.every( + (operation) => + Object.getOwnPropertyDescriptor(intrinsicHeadersPrototype, operation)?.value === + intrinsicHeadersDescriptors.get(operation), + ); + } + + const currentPrototype = prototype; + if ( + intrinsicHeadersOperations.some( + (operation) => Object.getOwnPropertyDescriptor(currentPrototype, operation) !== undefined, + ) + ) { + return false; + } + + prototype = Object.getPrototypeOf(currentPrototype) as object | null; + } + } catch { + return false; + } + + return false; +} + +function snapshotSameRealmHeaders(headers: Headers): RequestInit['headers'] { + if (hasIntrinsicHeadersIdentity(headers)) { + return headers; + } + const intrinsicEntries = intrinsicHeadersDescriptors.get('entries'); + if (typeof intrinsicEntries !== 'function') { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + try { + const [entriesDescriptor, iteratorDescriptor] = (['entries', Symbol.iterator] as const).map( + (operation) => { + let prototype: object | null = headers; + for (let depth = 0; depth < 32 && prototype !== null; depth += 1) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, operation); + if (descriptor !== undefined) { + return descriptor; + } + prototype = Object.getPrototypeOf(prototype) as object | null; + } + return null; + }, + ); + const entries = + typeof entriesDescriptor?.value === 'function' && entriesDescriptor.value === iteratorDescriptor?.value + ? entriesDescriptor.value + : intrinsicEntries; + const snapshot = Reflect.apply(entries, headers, []) as ReturnType; + if (entries === intrinsicEntries) { + return [...snapshot] as [string, string][]; } - return super.authHeaders(opts, security); + let intrinsicHeaderCount = 0; + for (const _header of Reflect.apply(intrinsicEntries, headers, []) as Iterable) { + intrinsicHeaderCount += 1; + } + const maximumHeaders = Math.max(1024, intrinsicHeaderCount); + const snapshots: [string, string][] = []; + for (const row of snapshot as Iterable) { + if (snapshots.length >= maximumHeaders || !Array.isArray(row)) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + const name: unknown = Reflect.get(row, 0); + const value: unknown = Reflect.get(row, 1); + if (typeof name !== 'string' || typeof value !== 'string') { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + snapshots.push([name, value]); + } + return snapshots; + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } +} + +function hasCanonicalCrossRealmHeaders(headers: object, prototype: object): boolean { + const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor')?.value; + if (typeof constructor !== 'function') { + return false; + } + if (Object.getOwnPropertyDescriptor(constructor, 'prototype')?.value !== prototype) { + return false; + } + const iterator = Object.getOwnPropertyDescriptor(prototype, Symbol.iterator)?.value; + const entries = Object.getOwnPropertyDescriptor(prototype, 'entries')?.value; + const has = Object.getOwnPropertyDescriptor(prototype, 'has')?.value; + if (iterator !== entries || typeof has !== 'function') { + return false; + } + try { + return typeof has.call(headers, 'api-key') === 'boolean'; + } catch { + return false; + } +} + +function snapshotAzureHeaderRecord(headers: object): Record { + const snapshot: Record = {}; + for (const name of Object.keys(headers)) { + Object.defineProperty(snapshot, name, { + configurable: true, + enumerable: true, + get: () => Reflect.get(headers, name) as string, + }); + } + return snapshot; +} + +function snapshotCrossRealmHeaders(headers: RequestInit['headers']): RequestInit['headers'] { + if (typeof headers !== 'object' || headers === null) { + return headers; + } + try { + if (headers instanceof Headers) { + return snapshotSameRealmHeaders(headers); + } + if (Array.isArray(headers)) { + return headers; + } + + const operations = [Symbol.iterator, 'entries', 'get', 'has'] as const; + let prototype = Object.getPrototypeOf(headers) as object | null; + let trustedPrototype: object | undefined; + let hasOverriddenOperation = operations.some( + (operation) => Object.getOwnPropertyDescriptor(headers, operation) !== undefined, + ); + + for (let depth = 0; depth < 32 && prototype !== null; depth++) { + if (Object.getOwnPropertyDescriptor(prototype, Symbol.toStringTag)?.value === 'Headers') { + trustedPrototype = prototype; + break; + } + + const currentPrototype = prototype; + const overriddenOperation = operations.some( + (operation) => Object.getOwnPropertyDescriptor(currentPrototype, operation) !== undefined, + ); + if (overriddenOperation) { + hasOverriddenOperation = true; + } + prototype = Object.getPrototypeOf(currentPrototype) as object | null; + } + + if (trustedPrototype === undefined) { + return snapshotAzureHeaderRecord(headers); + } + + const headerPrototype = trustedPrototype; + const valid = + !hasOverriddenOperation && + operations.every( + (operation) => + typeof Object.getOwnPropertyDescriptor(headerPrototype, operation)?.value === 'function', + ); + if (!valid) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + + const iterator = Object.getOwnPropertyDescriptor(headerPrototype, Symbol.iterator) as PropertyDescriptor; + // Foreign realm brands can be forged, so retain a generous denial-of-service bound. + const maximumHeaders = hasCanonicalCrossRealmHeaders(headers, headerPrototype) ? 65_536 : 1024; + const snapshots: [string, string][] = []; + for (const row of iterator.value.call(headers) as Iterable) { + if (snapshots.length >= maximumHeaders || !Array.isArray(row)) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + const name: unknown = Reflect.get(row, 0); + const value: unknown = Reflect.get(row, 1); + if (typeof name !== 'string' || typeof value !== 'string') { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + snapshots.push([name, value]); + } + return snapshots; + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } +} + +function protectAzureAmbientHeaders(options: Pick): void { + if (readEnv('OPENAI_CUSTOM_HEADERS')) { + options.defaultHeaders = buildAzureAuthenticationHeaders(options.defaultHeaders); } } diff --git a/src/beta/realtime/websocket.ts b/src/beta/realtime/websocket.ts index dfa10a3b7..9fa94e0a8 100644 --- a/src/beta/realtime/websocket.ts +++ b/src/beta/realtime/websocket.ts @@ -1,4 +1,5 @@ import type { AzureOpenAI } from '../../index'; +import { safeAzureCredentialHeaderValue } from '../../internal/azure'; import { assertBedrockWebSocketOrigin } from '../../internal/bedrock'; import { OpenAI } from '../../index'; import { OpenAIError } from '../../error'; @@ -119,11 +120,12 @@ function createAzureWebSocket( throw new Error('Azure OpenAI Realtime requires an API key'); } + const credential = safeAzureCredentialHeaderValue(apiKey); redactAzureCredentials(url, isBearerToken); const socketURL = new URL(url); socketURL.searchParams.delete('api-key'); socketURL.searchParams.delete('Authorization'); - const headers = isBearerToken ? { Authorization: `Bearer ${apiKey}` } : { 'api-key': apiKey }; + const headers = isBearerToken ? { Authorization: `Bearer ${credential}` } : { 'api-key': credential }; // @ts-ignore return new WebSocket(socketURL.toString(), { protocols, headers }); @@ -186,10 +188,11 @@ export class OpenAIRealtimeWebSocket extends OpenAIRealtimeEmitter { ) { super(); const hasProvider = typeof (client as any)?._options?.apiKey === 'function'; + const apiKey = client?.apiKey; const dangerouslyAllowBrowser = props.dangerouslyAllowBrowser ?? (client as any)?._options?.dangerouslyAllowBrowser ?? - (client?.apiKey?.startsWith('ek_') ? true : null); + (typeof apiKey === 'string' && apiKey.startsWith('ek_') ? true : null); if (!dangerouslyAllowBrowser && isRunningInBrowserOrBrowserWorker()) { throw new OpenAIError( "It looks like you're running in a browser-like environment.\n\nThis is disabled by default, as it risks exposing your secret API credentials to attackers.\n\nYou can avoid this error by creating an ephemeral session token:\nhttps://platform.openai.com/docs/api-reference/realtime-sessions\n", diff --git a/src/beta/realtime/ws.ts b/src/beta/realtime/ws.ts index 20d2de8ff..b2dff5e4a 100644 --- a/src/beta/realtime/ws.ts +++ b/src/beta/realtime/ws.ts @@ -1,4 +1,5 @@ import * as WS from 'ws'; +import { safeAzureCredentialHeaderValue, safeAzureWebSocketHeaders } from '../../internal/azure'; import { assertBedrockWebSocketOrigin } from '../../internal/bedrock'; import { protectWebSocketOptionsFromCredentialRedirects } from '../../internal/ws'; import type { AzureOpenAI } from '../../index'; @@ -67,9 +68,14 @@ export class OpenAIRealtimeWS extends OpenAIRealtimeEmitter { this.url = buildRealtimeURL(client, props); assertTrustedRealtimeURL(client, this.url); assertBedrockWebSocketOrigin(client, this.url); + const azure = isAzure(client); const headers = { ...props.options?.headers, - ...(isAzure(client) && !props.__resolvedApiKey ? {} : { Authorization: `Bearer ${client.apiKey}` }), + ...(azure && !props.__resolvedApiKey + ? {} + : { + Authorization: `Bearer ${azure ? safeAzureCredentialHeaderValue(client.apiKey) : client.apiKey}`, + }), 'OpenAI-Beta': 'realtime=v1', }; @@ -77,7 +83,7 @@ export class OpenAIRealtimeWS extends OpenAIRealtimeEmitter { this.url, protectWebSocketOptionsFromCredentialRedirects({ ...props.options, - headers, + headers: azure ? safeAzureWebSocketHeaders(headers) : headers, }), ); diff --git a/src/client.ts b/src/client.ts index a43437538..1b8e15830 100644 --- a/src/client.ts +++ b/src/client.ts @@ -251,7 +251,7 @@ import { } from './resources/chat/completions/completions'; import { type Fetch } from './internal/builtin-types'; import { isRunningInBrowser } from './internal/detect-platform'; -import { HeadersLike, NullableHeaders, buildHeaders } from './internal/headers'; +import { HeadersLike, NullableHeaders, buildHeaders, captureAzureHeaders } from './internal/headers'; import { configureProvider, type Provider, type ProviderRuntime } from './internal/provider'; import { FinalRequestOptions, RequestOptions } from './internal/request-options'; import { readEnv } from './internal/utils/env'; @@ -1777,6 +1777,7 @@ export class OpenAI { x509Timeout: number | undefined; x509Tenant?: { organization: string | null; project: string | null } | undefined; }): Promise { + const azureRequestHeaders = captureAzureHeaders(this, options); let idempotencyHeaders: HeadersLike = {}; if (this.idempotencyHeader && method !== 'get') { if (!options.idempotencyKey) options.idempotencyKey = this.defaultIdempotencyKey(); @@ -1799,10 +1800,15 @@ export class OpenAI { }, this._provider || this.#x509Authentication?.isPlanningRequest() ? undefined - : await this.authHeaders(options, options.__security ?? { bearerAuth: true }), + : azureRequestHeaders + ? await azureRequestHeaders.authenticate( + this.authHeaders, + options.__security ?? { bearerAuth: true }, + ) + : await this.authHeaders(options, options.__security ?? { bearerAuth: true }), x509Headers?.defaultHeaders ?? this._options.defaultHeaders, bodyHeaders, - x509Headers?.requestHeaders ?? options.headers, + x509Headers?.requestHeaders ?? (azureRequestHeaders ? azureRequestHeaders.headers() : options.headers), ]); if (!this._provider && !this.#x509Authentication?.isPlanningRequest()) { diff --git a/src/internal/azure.ts b/src/internal/azure.ts new file mode 100644 index 000000000..b5c6bad4f --- /dev/null +++ b/src/internal/azure.ts @@ -0,0 +1,84 @@ +/** Rejects invalid HTTP-field bytes without exposing a private Azure credential. */ +export function assertAzureCredentialHeaderValue(value: string): void { + for (const character of value) { + const code = character.codePointAt(0) ?? 0; + if ((code < 0x20 && code !== 0x09) || code === 0x7f || code > 0xff) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + } +} + +/** Coerces and validates an Azure credential without exposing errors from caller-defined hooks. */ +export function safeAzureCredentialHeaderValue(value: unknown): string { + let credential: string; + try { + credential = String(value); + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + assertAzureCredentialHeaderValue(credential); + return credential; +} + +/** Identifies the two credential-bearing Azure HTTP header fields. */ +export function isAzureAuthenticationHeader(name: string): boolean { + const normalized = name.toLowerCase(); + return normalized === 'authorization' || normalized === 'api-key'; +} + +/** + * Collapses case-insensitive WebSocket credential overrides before validating + * only the effective values. Callers supply SDK-created plain header records. + */ +export function safeAzureWebSocketHeaders>( + headers: Headers, +): Headers { + const safeHeaders = new Map(); + const authenticationNames = new Map(); + + for (const [name, value] of Object.entries(headers)) { + if (!isAzureAuthenticationHeader(name)) { + safeHeaders.set(name, value); + continue; + } + + const normalized = name.toLowerCase(); + const previousName = authenticationNames.get(normalized); + if (previousName !== undefined) { + safeHeaders.delete(previousName); + authenticationNames.delete(normalized); + } + if (value === null || value === undefined) { + continue; + } + safeHeaders.set(name, value); + authenticationNames.set(normalized, name); + } + + for (const name of authenticationNames.values()) { + const value = safeHeaders.get(name); + try { + if (Array.isArray(value)) { + const { length } = value; + if (!Number.isSafeInteger(length) || length < 0 || length > 1024) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + const snapshot: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const entry = value[index]; + if (entry === null || entry === undefined) { + snapshot.push(entry); + continue; + } + snapshot.push(safeAzureCredentialHeaderValue(entry)); + } + safeHeaders.set(name, snapshot); + } else { + safeHeaders.set(name, safeAzureCredentialHeaderValue(value)); + } + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + } + return Object.fromEntries(safeHeaders) as Headers; +} diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 216523774..b4a5d7dc1 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -1,3 +1,5 @@ +import { assertAzureCredentialHeaderValue, isAzureAuthenticationHeader } from './azure'; +import type { FinalRequestOptions } from './request-options'; import { isReadonlyArray } from './utils/values'; type HeaderValue = string | undefined | null; @@ -11,6 +13,7 @@ export type HeadersLike = const brand_privateNullableHeaders = /* @__PURE__ */ Symbol('brand.privateNullableHeaders'); const httpTokenHeaderName = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const intrinsicSetSize = Object.getOwnPropertyDescriptor(Set.prototype, 'size')?.get; /** * @internal @@ -26,12 +29,1044 @@ export type NullableHeaders = { nulls: Set; }; -function* iterateHeaders(headers: HeadersLike): IterableIterator { +type AzureAuthenticationValues = ReadonlyArray; +type AzureAuthenticationLayer = ReadonlyArray; +type AzureAuthenticationRecordSnapshot = { + descriptors: Record; + keys: readonly string[]; +}; +type AzureAuthenticationHeaderMutation = { + kind: 'append' | 'replace' | 'delete'; + values: string[]; +}; +type AzureAuthenticationHeaderIteratorResult = IteratorResult<[string, string] | string>; + +type AzureRequestHeaderMarker = { + active: boolean; + reserved: boolean; + registration: AzureRequestHeaderRegistration; +}; +type AzureRequestHeaderRegistration = { + carrier: NullableHeaders; + headers: object; + owner: object | undefined; + record: AzureAuthenticationRecordSnapshot | undefined; +}; +type AzureRequestHeaderRegistrations = { + references: number; + markers: AzureRequestHeaderMarker[]; + registrations: Set; +}; +type AzureRequestHeaderProtection = { + bind: (carrier: NullableHeaders) => NullableHeaders; + deactivate: () => void; + release: () => void; + snapshot: () => void; +}; +type AzureRequestHeaderSnapshot = { + authenticate: ( + authentication: ( + options: FinalRequestOptions, + schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, + ) => Promise, + schemes: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, + ) => Promise; + headers: () => HeadersLike; +}; +type AzureRequestHeaderSnapshotContext = { + client: object; + snapshot: AzureRequestHeaderSnapshot; +}; + +// Object-identity branding cannot be forged by caller-provided header records. +const azureAuthenticationHeaders = new WeakMap(); +const azureAuthenticationHeaderCarriers = new WeakMap(); +const azureAuthenticationHeaderSnapshots = new WeakMap< + NullableHeaders, + ReadonlyArray +>(); +const azureAuthenticationHeaderRecordSnapshots = new WeakMap< + NullableHeaders, + WeakMap +>(); +const azureAuthenticationHeaderMutations = new WeakMap< + Headers, + Map +>(); +const azureAuthenticationMaterializedHeaders = new WeakMap>(); +const azureAuthenticationUnmaterializedHeaders = new WeakMap>(); +const azureAuthenticationMutationNativeValues = new WeakMap>(); +const azureAuthenticationHeaderMutationVersions = new WeakMap(); +const azureAuthenticationHeaderIteratorStates = new WeakMap< + object, + () => AzureAuthenticationHeaderIteratorResult +>(); +const azureAuthenticationHeaderIteratorPrototypes = new WeakMap(); + +const azureAuthenticationNullCarriers = new WeakMap, NullableHeaders>(); +const azureRequestHeaders = new WeakMap(); +const azureRequestAuthenticationHeaders = new WeakMap(); +const azureRequestHeaderSnapshots = new WeakMap(); + +const snapshotAzureAuthenticationHeaders = ( + carrier: NullableHeaders, + registration?: AzureRequestHeaderRegistration, +): ReadonlyArray | undefined => { + const headers = azureAuthenticationHeaders.get(carrier); + if (headers === undefined) return undefined; + const records = azureAuthenticationHeaderRecordSnapshots.get(carrier); + + let layers = azureAuthenticationHeaderSnapshots.get(carrier); + if (!layers) { + layers = Object.freeze( + headers.map((layer) => { + const record = layer !== null && typeof layer === 'object' ? records?.get(layer) : undefined; + return Object.freeze( + Array.from(iterateHeaders(layer, registration, record), ([name, value]) => { + const normalized = name.toLowerCase(); + return registration !== undefined && + value !== null && + typeof value !== 'string' && + isAzureAuthenticationHeader(normalized) && + !hasRemainingAzureAuthenticationOverride(layer, name, normalized, registration, record) + ? ([name, coerceAzureCredentialHeaderValue(value)] as const) + : ([name, value] as const); + }), + ); + }), + ); + azureAuthenticationHeaderSnapshots.set(carrier, layers); + } + return layers; +}; + +const coerceAzureCredentialHeaderValue = (value: unknown): string => { + try { + return String(value); + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } +}; + +const azureAuthenticationTupleName = (entry: object): unknown => { + const descriptor = Object.getOwnPropertyDescriptor(entry, 0); + if (descriptor === undefined || !('value' in descriptor)) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + return descriptor.value; +}; + +const invalidateAzureAuthenticationHeaderIterators = (headers: Headers): void => { + const version = azureAuthenticationHeaderMutationVersions.get(headers) ?? 0; + azureAuthenticationHeaderMutationVersions.set(headers, version + 1); +}; + +const azureAuthenticationHeaderIteratorPrototype = (iterator: object): object => { + const intrinsic = Object.getPrototypeOf(iterator) as object; + let prototype = azureAuthenticationHeaderIteratorPrototypes.get(intrinsic); + if (prototype !== undefined) return prototype; + + const nativeNext = Reflect.get(intrinsic, 'next') as () => AzureAuthenticationHeaderIteratorResult; + prototype = Object.create(intrinsic) as object; + Object.defineProperty(prototype, 'next', { + configurable: true, + enumerable: true, + writable: true, + value: function next(this: object): AzureAuthenticationHeaderIteratorResult { + const advance = azureAuthenticationHeaderIteratorStates.get(this); + return advance === undefined ? Reflect.apply(nativeNext, this, []) : advance(); + }, + }); + azureAuthenticationHeaderIteratorPrototypes.set(intrinsic, prototype); + return prototype; +}; + +class DeferredAzureAuthenticationHeaders extends Headers { + constructor() { + super(); + azureAuthenticationHeaderMutations.set(this, new Map()); + azureAuthenticationMutationNativeValues.set(this, new Map()); + } + + static { + Object.defineProperties(this.prototype, { + get: { + configurable: true, + writable: true, + value(this: DeferredAzureAuthenticationHeaders, name: string): string | null { + const normalized = String(name).toLowerCase(); + Headers.prototype.has.call(this, normalized); + return this.current().get(normalized) ?? null; + }, + }, + getSetCookie: { + configurable: true, + writable: true, + value(this: DeferredAzureAuthenticationHeaders): string[] { + return this.cookieValues(); + }, + }, + has: { + configurable: true, + writable: true, + value(this: DeferredAzureAuthenticationHeaders, name: string): boolean { + const normalized = String(name).toLowerCase(); + Headers.prototype.has.call(this, normalized); + return this.current().has(normalized); + }, + }, + entries: { + configurable: true, + writable: true, + value(this: DeferredAzureAuthenticationHeaders): ReturnType { + return this.iterator('entries'); + }, + }, + keys: { + configurable: true, + writable: true, + value(this: DeferredAzureAuthenticationHeaders): ReturnType { + return this.iterator('keys'); + }, + }, + values: { + configurable: true, + writable: true, + value(this: DeferredAzureAuthenticationHeaders): ReturnType { + return this.iterator('values'); + }, + }, + [Symbol.iterator]: { + configurable: true, + writable: true, + value(this: DeferredAzureAuthenticationHeaders): ReturnType { + return this.entries(); + }, + }, + forEach: { + configurable: true, + writable: true, + value( + this: DeferredAzureAuthenticationHeaders, + callback: (value: string, key: string, parent: Headers) => void, + thisArg?: unknown, + ): void { + for (const [name, value] of this.entries()) { + callback.call(thisArg, value, name, this); + } + }, + }, + append: { + configurable: true, + writable: true, + value(this: DeferredAzureAuthenticationHeaders, name: string, value: string): void { + this.update(name, value, 'append'); + }, + }, + set: { + configurable: true, + writable: true, + value(this: DeferredAzureAuthenticationHeaders, name: string, value: string): void { + this.update(name, value, 'replace'); + }, + }, + delete: { + configurable: true, + writable: true, + value(this: DeferredAzureAuthenticationHeaders, name: string): void { + const normalized = String(name).toLowerCase(); + const mutations = azureAuthenticationHeaderMutations.get(this); + const previous = mutations?.get(normalized); + const existed = Headers.prototype.has.call(this, normalized); + Headers.prototype.delete.call(this, normalized); + mutations?.set(normalized, { kind: 'delete', values: [] }); + azureAuthenticationMutationNativeValues.get(this)?.set(normalized, null); + if (existed || previous?.kind !== 'delete') { + invalidateAzureAuthenticationHeaderIterators(this); + } + }, + }, + }); + } + + private cookieValues(): string[] { + Headers.prototype.has.call(this, 'set-cookie'); + const carrier = azureAuthenticationHeaderCarriers.get(this); + const source = carrier ? iterateHeaders(carrier) : Headers.prototype.entries.call(this); + const cookies: string[] = []; + + for (const [name, value] of source) { + if (name.toLowerCase() !== 'set-cookie') { + continue; + } + if (value === null) { + cookies.length = 0; + continue; + } + const normalized = new Headers([['set-cookie', value]]).get('set-cookie'); + if (normalized !== null) { + cookies.push(normalized); + } + } + + return cookies; + } + + private iterator(kind: 'entries'): ReturnType; + private iterator(kind: 'keys'): ReturnType; + private iterator(kind: 'values'): ReturnType; + private iterator( + kind: 'entries' | 'keys' | 'values', + ): ReturnType | ReturnType | ReturnType { + const iterator = + kind === 'entries' + ? Headers.prototype.entries.call(this) + : kind === 'keys' + ? Headers.prototype.keys.call(this) + : Headers.prototype.values.call(this); + let entries: [string, string][] = []; + let nativeEntries: [string, string][] = []; + let nativeValues = new Map(); + let nativeObserver = Headers.prototype.entries.call(this); + let nativeIndex = 0; + let nullNames = new Set(); + let nullCount = 0; + let version: number | undefined; + let index = 0; + const carrier = azureAuthenticationHeaderCarriers.get(this); + const readNullCount = (): number => + carrier !== undefined && intrinsicSetSize !== undefined + ? (Reflect.apply(intrinsicSetSize, carrier.nulls, []) as number) + : 0; + + const next = (): AzureAuthenticationHeaderIteratorResult => { + const currentVersion = azureAuthenticationHeaderMutationVersions.get(this) ?? 0; + let changed = version !== currentVersion || readNullCount() !== nullCount; + if (!changed) { + const observed = nativeObserver.next(); + const expected = nativeEntries[nativeIndex]; + changed = observed.done + ? expected !== undefined + : expected === undefined || observed.value[0] !== expected[0] || observed.value[1] !== expected[1]; + if (!changed && !observed.done && carrier !== undefined) { + changed = + nullNames.has(observed.value[0]) !== Set.prototype.has.call(carrier.nulls, observed.value[0]); + } + if (!observed.done) nativeIndex += 1; + + for (const candidate of [entries[index], entries[index - 1]]) { + if (changed || candidate === undefined) continue; + const previous = nativeValues.get(candidate[0]) ?? null; + const hidden = carrier === undefined ? false : Set.prototype.has.call(carrier.nulls, candidate[0]); + changed = + Headers.prototype.get.call(this, candidate[0]) !== previous || + nullNames.has(candidate[0]) !== hidden; + } + } + if (changed) { + entries = [...this.current()]; + const cookies = entries.findIndex(([name]) => name === 'set-cookie'); + if (cookies !== -1) { + entries.splice( + cookies, + 1, + ...this.cookieValues().map((value): [string, string] => ['set-cookie', value]), + ); + } + nativeEntries = [...Headers.prototype.entries.call(this)]; + nativeValues = new Map(nativeEntries); + const nativeCookies = Headers.prototype.get.call(this, 'set-cookie'); + if (nativeCookies !== null) { + nativeValues.set('set-cookie', nativeCookies); + } + nativeObserver = Headers.prototype.entries.call(this); + nativeIndex = 0; + nullNames = new Set(carrier === undefined ? [] : Set.prototype.values.call(carrier.nulls)); + nullCount = readNullCount(); + version = azureAuthenticationHeaderMutationVersions.get(this) ?? currentVersion; + } + + const entry = entries[index]; + if (entry === undefined) { + return { value: undefined, done: true }; + } + if (changed) { + const consumed = new Set(entries.slice(0, index + 1).map(([name]) => name)); + let nativeEntry = nativeEntries[nativeIndex]; + while (nativeEntry !== undefined && consumed.has(nativeEntry[0])) { + nativeObserver.next(); + nativeIndex += 1; + nativeEntry = nativeEntries[nativeIndex]; + } + } + index += 1; + const value = kind === 'keys' ? entry[0] : kind === 'values' ? entry[1] : entry; + return { value, done: false }; + }; + Object.setPrototypeOf(iterator, azureAuthenticationHeaderIteratorPrototype(iterator)); + azureAuthenticationHeaderIteratorStates.set(iterator, next); + + return iterator; + } + + private current(): Map { + const carrier = azureAuthenticationHeaderCarriers.get(this); + const source = carrier ? iterateHeaders(carrier) : Headers.prototype.entries.call(this); + const effective = new Map(); + for (const [name, value] of source) { + const normalized = name.toLowerCase(); + if (value === null) { + effective.delete(normalized); + continue; + } + const normalizedValue = isAzureAuthenticationHeader(normalized) + ? coerceAzureCredentialHeaderValue(value).replace(/^[\t ]+|[\t ]+$/g, '') + : value; + const previous = effective.get(normalized); + effective.set(normalized, previous === undefined ? normalizedValue : `${previous}, ${normalizedValue}`); + } + return new Map([...effective].sort(([left], [right]) => Number(left > right) - Number(left < right))); + } + + private update(name: string, value: string, operation: 'append' | 'replace'): void { + const normalized = String(name).toLowerCase(); + const authentication = isAzureAuthenticationHeader(normalized); + const normalizedValue = authentication ? coerceAzureCredentialHeaderValue(value) : value; + const previousValue = Headers.prototype.get.call(this, normalized); + let safe = true; + + if (authentication) { + try { + assertAzureCredentialHeaderValue(normalizedValue); + } catch { + safe = false; + } + } + + if (safe) { + if (operation === 'append') { + Headers.prototype.append.call(this, normalized, normalizedValue); + } else { + Headers.prototype.set.call(this, normalized, normalizedValue); + } + } else if (operation === 'replace') { + Headers.prototype.delete.call(this, normalized); + } else { + Headers.prototype.has.call(this, normalized); + } + + const mutations = azureAuthenticationHeaderMutations.get(this); + if (!mutations) return; + const previous = mutations.get(normalized); + const kind = + operation === 'replace' || previous?.kind === 'delete' || previous?.kind === 'replace' + ? 'replace' + : 'append'; + const previousValues = operation === 'replace' || previous?.kind === 'delete' ? [] : previous?.values; + mutations.set(normalized, { + kind, + values: authentication ? [...(previousValues ?? []), normalizedValue] : [], + }); + azureAuthenticationMutationNativeValues + .get(this) + ?.set(normalized, Headers.prototype.get.call(this, normalized)); + const unchanged = + operation === 'replace' && + previousValue !== null && + previousValue === Headers.prototype.get.call(this, normalized) && + previous?.kind !== 'append' && + (!authentication || + (previous?.kind === 'replace' && + previous.values.length === 1 && + previous.values[0] === normalizedValue)); + if (!unchanged) { + invalidateAzureAuthenticationHeaderIterators(this); + } + } +} + +class DeferredAzureAuthenticationNulls extends Set { + private initialized = false; + private readonly inherited = new Set(); + + static { + const operations = [ + 'union', + 'intersection', + 'difference', + 'symmetricDifference', + 'isSubsetOf', + 'isSupersetOf', + 'isDisjointFrom', + ] as const; + for (const name of operations) { + const operation = Object.getOwnPropertyDescriptor(Set.prototype, name)?.value; + if (typeof operation !== 'function') { + continue; + } + Object.defineProperty(this.prototype, name, { + configurable: true, + value: this.wrapModernOperation(operation), + writable: true, + }); + } + } + + private static wrapModernOperation(operation: (...values: unknown[]) => unknown) { + return function (this: DeferredAzureAuthenticationNulls, other: unknown): unknown { + this.initialize(); + return Reflect.apply(operation, this, [other]); + }; + } + + seedInherited(name: string, present: boolean): void { + if (present) { + Set.prototype.add.call(this, name); + this.inherited.add(name); + } else { + Set.prototype.delete.call(this, name); + this.inherited.delete(name); + } + } + + private initialize(): void { + const carrier = azureAuthenticationNullCarriers.get(this); + if (!carrier) return; + const removed = new Set(); + for (const name of this.inherited) { + if (!Set.prototype.has.call(this, name)) { + this.inherited.delete(name); + removed.add(name); + if (azureAuthenticationHeaderMutations.get(carrier.values)?.get(name) === undefined) { + carrier.values.delete(name); + } + } + } + if (this.initialized) return; + this.initialized = true; + + for (const layer of snapshotAzureAuthenticationHeaders(carrier) ?? []) { + for (const [name, value] of layer) { + const normalized = name.toLowerCase(); + if (removed.has(normalized)) continue; + if (value === null) { + super.add(normalized); + this.inherited.add(normalized); + } else { + super.delete(normalized); + this.inherited.delete(normalized); + } + } + } + } + + override get size(): number { + this.initialize(); + return super.size; + } + + override has(value: string): boolean { + this.initialize(); + return super.has(value); + } + + override entries(): ReturnType['entries']> { + this.initialize(); + return super.entries(); + } + + override keys(): ReturnType['keys']> { + this.initialize(); + return super.keys(); + } + + override values(): ReturnType['values']> { + this.initialize(); + return super.values(); + } + + override [Symbol.iterator](): ReturnType[typeof Symbol.iterator]> { + this.initialize(); + return super[Symbol.iterator](); + } + + override forEach( + callback: (value: string, key: string, parent: Set) => void, + thisArg?: unknown, + ): void { + this.initialize(); + super.forEach(callback, thisArg); + } + + override add(value: string): this { + this.initialize(); + const size = super.size; + super.add(value); + const carrier = azureAuthenticationNullCarriers.get(this); + if (carrier && super.size !== size) { + invalidateAzureAuthenticationHeaderIterators(carrier.values); + } + return this; + } + + override delete(value: string): boolean { + this.initialize(); + const removed = super.delete(value); + const carrier = azureAuthenticationNullCarriers.get(this); + if (removed && this.inherited.delete(value)) { + carrier?.values.delete(value); + } + if (removed && carrier) { + invalidateAzureAuthenticationHeaderIterators(carrier.values); + } + return removed; + } + + override clear(): void { + this.initialize(); + for (const value of [...super.values()]) { + this.delete(value); + } + } +} + +/** + * Creates an authenticated Azure header carrier without first appending a raw + * credential to native Headers, where rejected values appear in diagnostics. + */ +export const buildAzureAuthenticationHeaders = (...headers: AzureAuthenticationValues): NullableHeaders => { + const nulls = new DeferredAzureAuthenticationNulls(); + const carrier: NullableHeaders = { + [brand_privateNullableHeaders]: true, + values: new DeferredAzureAuthenticationHeaders(), + nulls, + }; + azureAuthenticationHeaders.set(carrier, headers); + azureAuthenticationHeaderCarriers.set(carrier.values, carrier); + azureAuthenticationNullCarriers.set(carrier.nulls, carrier); + const records = new WeakMap(); + azureAuthenticationHeaderRecordSnapshots.set(carrier, records); + + try { + for (const layer of headers) { + if (layer === undefined || layer === null) continue; + if (brand_privateNullableHeaders in layer) { + for (const name of ['api-key', 'authorization']) { + if (Set.prototype.has.call(layer.nulls, name)) { + nulls.seedInherited(name, true); + } else if (Headers.prototype.has.call(layer.values, name)) { + nulls.seedInherited(name, false); + } + } + continue; + } + if (layer instanceof Headers) { + for (const name of ['api-key', 'authorization']) { + if (Headers.prototype.has.call(layer, name)) { + nulls.seedInherited(name, false); + } + } + continue; + } + if (isReadonlyArray(layer)) { + for (const row of layer) { + const name = azureAuthenticationTupleName(row); + if (typeof name !== 'string' || !isAzureAuthenticationHeader(name)) continue; + const value = row[1]; + const values = isReadonlyArray(value) ? value : [value]; + for (const candidate of values) { + if (candidate !== undefined) { + nulls.seedInherited(name.toLowerCase(), candidate === null); + } + } + } + continue; + } + + const descriptors = Object.getOwnPropertyDescriptors(layer); + const keys = Object.keys(descriptors).filter((name) => descriptors[name]?.enumerable === true); + records.set(layer, { descriptors, keys }); + for (const name of keys) { + if (!isAzureAuthenticationHeader(name)) continue; + const descriptor = descriptors[name]; + if (descriptor === undefined || !('value' in descriptor)) continue; + const value: unknown = descriptor.value; + const values = isReadonlyArray(value) ? value : [value]; + for (const candidate of values) { + if (candidate !== undefined) { + nulls.seedInherited(name.toLowerCase(), candidate === null); + } + } + } + } + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + return carrier; +}; + +/** Exposes already-safe effective credentials through genuine native Headers intrinsics. */ +export const materializeAzureAuthenticationHeaders = (carrier: NullableHeaders): NullableHeaders => { + const effective = new Map(); + + for (const layer of snapshotAzureAuthenticationHeaders(carrier) ?? []) { + const seen = new Set(); + for (const [name, value] of layer) { + const normalized = name.toLowerCase(); + if (!isAzureAuthenticationHeader(normalized)) continue; + if (!seen.has(normalized)) { + effective.delete(normalized); + seen.add(normalized); + } + if (value === null) { + effective.delete(normalized); + continue; + } + if (typeof value !== 'string') { + effective.set(normalized, undefined); + continue; + } + const previous = effective.get(normalized); + if (effective.has(normalized) && previous === undefined) continue; + effective.set(normalized, [...(previous ?? []), value]); + } + } + + let materialized = azureAuthenticationMaterializedHeaders.get(carrier.values); + const unmaterialized = new Set(); + for (const [name, values] of effective) { + if (values === undefined) { + unmaterialized.add(name); + continue; + } + try { + for (const value of values) { + assertAzureCredentialHeaderValue(value); + } + } catch { + // Malformed or shadowed credentials remain deferred so protected hooks may replace them. + unmaterialized.add(name); + continue; + } + + Headers.prototype.delete.call(carrier.values, name); + for (const value of values) { + Headers.prototype.append.call(carrier.values, name, value); + } + materialized ??= new Set(); + materialized.add(name); + } + if (materialized !== undefined) { + azureAuthenticationMaterializedHeaders.set(carrier.values, materialized); + } + if (unmaterialized.size !== 0) { + azureAuthenticationUnmaterializedHeaders.set(carrier.values, unmaterialized); + } + return carrier; +}; + +/** Privately protects one synchronous Azure body pass and its authenticated final merge. */ +export const protectAzureRequestHeaders = ( + headers: HeadersLike, + owner?: object, +): AzureRequestHeaderProtection | undefined => { + if (headers === undefined || headers === null || typeof headers !== 'object') { + return undefined; + } + + let registrations = azureRequestHeaders.get(headers); + if (!registrations) { + registrations = { + references: 0, + markers: [], + registrations: new Set(), + }; + azureRequestHeaders.set(headers, registrations); + } + const activeRegistrations = registrations; + const carrier = buildAzureAuthenticationHeaders(headers); + const activeRegistration: AzureRequestHeaderRegistration = { + carrier, + headers, + owner, + record: azureAuthenticationHeaderRecordSnapshots.get(carrier)?.get(headers), + }; + azureRequestAuthenticationHeaders.set(carrier, activeRegistration); + activeRegistrations.references += 1; + activeRegistrations.registrations.add(activeRegistration); + const marker: AzureRequestHeaderMarker = { + active: true, + reserved: false, + registration: activeRegistration, + }; + activeRegistrations.markers.push(marker); + let released = false; + + const deactivate = (): void => { + if (!marker.active) return; + marker.active = false; + const position = activeRegistrations.markers.indexOf(marker); + if (position !== -1) { + activeRegistrations.markers.splice(position, 1); + } + }; + const release = (): void => { + if (released) return; + released = true; + deactivate(); + activeRegistrations.registrations.delete(activeRegistration); + activeRegistrations.references -= 1; + if (activeRegistrations.references === 0) { + azureRequestHeaders.delete(headers); + } + }; + + const bind = (carrier: NullableHeaders): NullableHeaders => { + const authentic = azureAuthenticationHeaders.has(carrier) + ? carrier + : azureAuthenticationHeaderCarriers.get(carrier.values); + if (!authentic) { + return carrier; + } + const isolated = { ...carrier }; + azureRequestAuthenticationHeaders.set(isolated, activeRegistration); + return isolated; + }; + + return { + bind, + deactivate, + release, + snapshot: () => { + if ( + overridesAzureAuthenticationHeader(headers, 'api-key', activeRegistration) || + overridesAzureAuthenticationHeader(headers, 'authorization', activeRegistration) + ) { + const effective = new Map(); + for (const layer of snapshotAzureAuthenticationHeaders( + activeRegistration.carrier, + activeRegistration, + ) ?? []) { + for (const [name, value] of layer) { + const normalized = name.toLowerCase(); + if (!isAzureAuthenticationHeader(normalized)) continue; + if (value === null) { + effective.delete(normalized); + continue; + } + const current = effective.get(normalized); + if (current === undefined) { + effective.set(normalized, [value]); + } else { + current.push(value); + } + } + } + for (const values of effective.values()) { + for (const value of values) { + if (typeof value !== 'string') { + coerceAzureCredentialHeaderValue(value); + } + } + } + } + }, + }; +}; + +/** Captures a request-local Azure header capability before asynchronous authentication starts. */ +export const captureAzureHeaders = ( + client: object, + options: FinalRequestOptions, +): AzureRequestHeaderSnapshot | undefined => { + const active = azureRequestHeaderSnapshots.get(options); + const context = active?.[active.length - 1]; + return context?.client === client ? context.snapshot : undefined; +}; + +/** Binds one synchronous request-build invocation without mutating its client or caller options. */ +export const withAzureRequestHeaderSnapshot = ( + client: object, + options: FinalRequestOptions, + authenticationOptions: FinalRequestOptions, + headers: () => HeadersLike, + protection: AzureRequestHeaderProtection | undefined, + build: () => Result, +): Result => { + let active = azureRequestHeaderSnapshots.get(options); + if (active === undefined) { + active = []; + azureRequestHeaderSnapshots.set(options, active); + } + const contexts = active; + const snapshot: AzureRequestHeaderSnapshot = { + authenticate: async (authentication, schemes) => { + protection?.snapshot(); + const carrier = await Reflect.apply(authentication, client, [authenticationOptions, schemes]); + return carrier === undefined || protection === undefined ? carrier : protection.bind(carrier); + }, + headers, + }; + contexts.push({ client, snapshot }); + try { + return build(); + } finally { + contexts.pop(); + if (contexts.length === 0) { + azureRequestHeaderSnapshots.delete(options); + } + } +}; + +const reserveAzureBodyMarker = (headers: HeadersLike): AzureRequestHeaderMarker | undefined => { + if (headers === undefined || headers === null || typeof headers !== 'object') return undefined; + const markers = azureRequestHeaders.get(headers)?.markers; + const marker = markers?.[markers.length - 1]; + if (marker === undefined || !marker.active || marker.reserved) return undefined; + marker.reserved = true; + return marker; +}; + +const matchesAzureRequestHeaders = ( + headers: HeadersLike, + registration: AzureRequestHeaderRegistration, +): boolean => { + if (headers === registration.headers) return true; + if (registration.owner === undefined || typeof headers !== 'object' || headers === null) return false; + const registrations = azureRequestHeaders.get(headers); + if (registrations === undefined) return false; + for (const candidate of registrations.registrations) { + if (candidate.owner === registration.owner) return true; + } + return false; +}; + +function* iterateHeaderRecord( + headers: Record, + record?: AzureAuthenticationRecordSnapshot, +): IterableIterator { + for (const name of record?.keys ?? Object.keys(headers)) { + let value: HeaderValue | readonly HeaderValue[]; + try { + value = headers[name]; + } catch (error) { + if (isAzureAuthenticationHeader(name)) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + throw error; + } + yield [name, value]; + } +} + +function* iterateHeaders( + headers: HeadersLike, + registration?: AzureRequestHeaderRegistration, + record?: AzureAuthenticationRecordSnapshot, +): IterableIterator { if (!headers) return; if (brand_privateNullableHeaders in headers) { const { values, nulls } = headers; - yield* values.entries(); + const nullNames = new Set([...nulls].map((name) => name.toLowerCase())); + const deferredValues = azureAuthenticationHeaderCarriers.has(values); + const keys = deferredValues ? Headers.prototype.keys.call(values) : values.keys(); + const visibleNames = new Set([...keys, ...nullNames].map((name) => name.toLowerCase())); + const mutations = azureAuthenticationHeaderMutations.get(values); + const materialized = azureAuthenticationMaterializedHeaders.get(values); + const nativeMutationValues = azureAuthenticationMutationNativeValues.get(values); + const carrier = azureAuthenticationHeaderCarriers.get(values) ?? headers; + const activeRegistration = registration ?? azureRequestAuthenticationHeaders.get(carrier); + const sources = azureAuthenticationHeaders.get(carrier); + const layers = snapshotAzureAuthenticationHeaders(carrier, activeRegistration); + if (layers !== undefined) { + for (let index = 0; index < layers.length; index += 1) { + const layer = layers[index]; + if (layer === undefined) continue; + const seen = new Set(); + const refreshed = new Set(); + for (const [name, snapshot] of layer) { + const normalized = name.toLowerCase(); + const mutation = mutations?.get(normalized); + if ( + nullNames.has(normalized) || + materialized?.has(normalized) || + mutation?.kind === 'delete' || + mutation?.kind === 'replace' || + (visibleNames.has(normalized) && mutation?.kind !== 'append') + ) { + continue; + } + if (!seen.has(normalized)) { + seen.add(normalized); + yield [name, null]; + } + if ( + !isAzureAuthenticationHeader(normalized) && + activeRegistration?.record !== undefined && + sources?.[index] === activeRegistration.headers + ) { + if (refreshed.has(name)) continue; + refreshed.add(name); + const current = ( + activeRegistration.headers as Record + )[name]; + if (current === undefined) continue; + yield [name, null]; + const currentValues = isReadonlyArray(current) ? current : [current]; + for (const value of currentValues) { + if (value !== undefined) yield [name, value]; + } + continue; + } + yield [name, snapshot]; + } + } + } + const emitted = new Set(); + const entries = deferredValues ? Headers.prototype.entries.call(values) : values.entries(); + for (const [name, value] of entries) { + const normalized = name.toLowerCase(); + const mutation = mutations?.get(normalized); + if (mutation === undefined && azureAuthenticationUnmaterializedHeaders.get(values)?.has(normalized)) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + if (mutation && isAzureAuthenticationHeader(normalized)) { + emitted.add(normalized); + if (materialized?.has(normalized)) { + yield [name, value]; + if (nativeMutationValues?.get(normalized) === value) { + for (const pending of mutation.values) { + try { + assertAzureCredentialHeaderValue(pending); + } catch { + yield [name, pending]; + } + } + } + } else { + for (const pending of mutation.values) { + yield [name, pending]; + } + } + } else { + yield [name, value]; + } + } + if (mutations) { + for (const [name, mutation] of mutations) { + if (!isAzureAuthenticationHeader(name) || emitted.has(name)) continue; + if ( + materialized?.has(name) && + nativeMutationValues?.get(name) !== Headers.prototype.get.call(values, name) + ) { + continue; + } + for (const pending of mutation.values) { + yield [name, pending]; + } + } + } for (const name of nulls) { yield [name, null]; } @@ -46,7 +1081,10 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator, + record ?? (registration?.headers === headers ? registration.record : undefined), + ); } for (let row of iter) { const name = row[0]; @@ -67,32 +1105,353 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator { +/** Validates only the final authentication values without native construction. */ +export const assertAzureAuthenticationHeaders = (headers: HeadersLike): void => { + for (const [name, value] of iterateHeaders(headers)) { + if (value !== null && isAzureAuthenticationHeader(name)) { + assertAzureCredentialHeaderValue(coerceAzureCredentialHeaderValue(value)); + } + } +}; + +const assertNoUnboundAzureRequestRegistration = (headers: HeadersLike[]): void => { + for (const source of headers) { + if (source !== null && typeof source === 'object' && azureRequestHeaders.has(source)) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + } +}; + +const overridesAzureAuthenticationRecordValue = ( + headers: object, + key: string, + record?: AzureAuthenticationRecordSnapshot, +): boolean => { + const descriptor = record?.descriptors[key] ?? Object.getOwnPropertyDescriptor(headers, key); + if (descriptor === undefined) { + return false; + } + if (!('value' in descriptor)) { + return true; + } + const value: unknown = descriptor.value; + return Array.isArray(value) ? value.some((candidate) => candidate !== undefined) : value !== undefined; +}; + +const azureAuthenticationTupleValueDescriptor = ( + value: object, + index: number, +): PropertyDescriptor | undefined => { + let owner: object | null = value; + for (let depth = 0; owner !== null && depth < 32; depth += 1) { + const descriptor = Object.getOwnPropertyDescriptor(owner, index); + if (descriptor !== undefined) return descriptor; + owner = Object.getPrototypeOf(owner) as object | null; + } + if (owner !== null) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + return undefined; +}; + +const overridesAzureAuthenticationHeader = ( + headers: HeadersLike, + name: string, + registration?: AzureRequestHeaderRegistration, + record?: AzureAuthenticationRecordSnapshot, +): boolean => { + if (headers === undefined || headers === null || typeof headers !== 'object') { + return false; + } + + if (brand_privateNullableHeaders in headers) { + const carrier = azureAuthenticationHeaders.has(headers as NullableHeaders) + ? (headers as NullableHeaders) + : azureAuthenticationHeaderCarriers.get((headers as NullableHeaders).values); + if (carrier !== undefined) { + const activeRegistration = registration ?? azureRequestAuthenticationHeaders.get(carrier); + const records = azureAuthenticationHeaderRecordSnapshots.get(carrier); + const mutations = azureAuthenticationHeaderMutations.get(carrier.values); + const mutation = mutations?.get(name); + const materialized = azureAuthenticationMaterializedHeaders.get(carrier.values)?.has(name); + if ( + materialized && + mutation !== undefined && + azureAuthenticationMutationNativeValues.get(carrier.values)?.get(name) !== + Headers.prototype.get.call(carrier.values, name) + ) { + return ( + Set.prototype.has.call((headers as NullableHeaders).nulls, name) || + Headers.prototype.has.call((headers as NullableHeaders).values, name) + ); + } + if (mutation?.kind === 'replace' || mutation?.kind === 'append') { + return true; + } + if (mutation?.kind === 'delete') { + return false; + } + if (materialized) { + return ( + Set.prototype.has.call((headers as NullableHeaders).nulls, name) || + Headers.prototype.has.call((headers as NullableHeaders).values, name) + ); + } + for (const layer of azureAuthenticationHeaders.get(carrier) ?? []) { + if ( + overridesAzureAuthenticationHeader( + layer, + name, + activeRegistration, + layer !== null && typeof layer === 'object' ? records?.get(layer) : undefined, + ) + ) { + return true; + } + } + } + return ( + Set.prototype.has.call((headers as NullableHeaders).nulls, name) || + Headers.prototype.has.call((headers as NullableHeaders).values, name) + ); + } + + if (headers instanceof Headers) { + return Headers.prototype.has.call(headers, name); + } + + if (isReadonlyArray(headers)) { + try { + return headers.some((entry) => { + const candidate = azureAuthenticationTupleName(entry); + if (typeof candidate !== 'string' || candidate.toLowerCase() !== name) { + return false; + } + + const descriptor = azureAuthenticationTupleValueDescriptor(entry, 1); + if (descriptor === undefined) { + return false; + } + if (!('value' in descriptor)) { + return true; + } + + const value: unknown = descriptor.value; + if (!isReadonlyArray(value)) { + return value !== undefined; + } + for (let index = 0; index < value.length; index += 1) { + const element = azureAuthenticationTupleValueDescriptor(value, index); + if (element !== undefined && (!('value' in element) || element.value !== undefined)) { + return true; + } + } + return false; + }); + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + } + + const snapshot = record ?? (registration?.headers === headers ? registration.record : undefined); + try { + for (const key of snapshot?.keys ?? Object.keys(headers)) { + if (key.toLowerCase() === name && overridesAzureAuthenticationRecordValue(headers, key, snapshot)) { + return true; + } + } + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + return false; +}; + +const hasRemainingAzureAuthenticationOverride = ( + headers: HeadersLike, + current: string, + name: string, + registration?: AzureRequestHeaderRegistration, + record?: AzureAuthenticationRecordSnapshot, +): boolean => { + if ( + headers === undefined || + headers === null || + typeof headers !== 'object' || + brand_privateNullableHeaders in headers || + headers instanceof Headers || + isReadonlyArray(headers) + ) { + return false; + } + + let found = false; + const snapshot = record ?? (registration?.headers === headers ? registration.record : undefined); + for (const key of snapshot?.keys ?? Object.keys(headers)) { + if (!found) { + found = key === current; + continue; + } + if (key.toLowerCase() === name && overridesAzureAuthenticationRecordValue(headers, key, snapshot)) { + return true; + } + } + return false; +}; + +const hasLaterAzureAuthenticationOverride = ( + sources: HeadersLike[], + index: number, + name: string, + registration?: AzureRequestHeaderRegistration, +): boolean => { + for (let candidate = index + 1; candidate < sources.length; candidate += 1) { + if (overridesAzureAuthenticationHeader(sources[candidate], name, registration)) { + return true; + } + } + return false; +}; + +const buildHeadersWithRegistration = ( + newHeaders: HeadersLike[], + bodyRegistration: AzureRequestHeaderRegistration | undefined, +): NullableHeaders => { + let requestRegistration = bodyRegistration; + let protectsAzureCredentials = bodyRegistration !== undefined; + if (!protectsAzureCredentials) { + for (const headers of newHeaders) { + if (typeof headers !== 'object' || headers === null) { + continue; + } + const carrier = azureAuthenticationHeaders.has(headers as NullableHeaders) + ? (headers as NullableHeaders) + : brand_privateNullableHeaders in headers + ? azureAuthenticationHeaderCarriers.get((headers as NullableHeaders).values) + : undefined; + if (!carrier) { + continue; + } + protectsAzureCredentials = true; + requestRegistration = + azureRequestAuthenticationHeaders.get(headers as NullableHeaders) ?? + azureRequestAuthenticationHeaders.get(carrier); + if (requestRegistration) { + break; + } + } + if (protectsAzureCredentials && requestRegistration === undefined) { + assertNoUnboundAzureRequestRegistration(newHeaders); + } + } const targetHeaders = new Headers(); const nullHeaders = new Set(); - for (const headers of newHeaders) { + const pendingAuthenticationHeaders = new Map(); + + for (let sourceIndex = 0; sourceIndex < newHeaders.length; sourceIndex += 1) { + const source = newHeaders[sourceIndex]; const seenHeaders = new Set(); - for (const [name, value] of iterateHeaders(headers)) { + const headers = + protectsAzureCredentials && + requestRegistration !== undefined && + matchesAzureRequestHeaders(source, requestRegistration) + ? requestRegistration.carrier + : source; + const unprovenAuthenticationHeaders = new Map(); + for (const [name, values] of pendingAuthenticationHeaders) { + const mutable = values.filter((value) => typeof value !== 'string'); + if (mutable.length > 0 && overridesAzureAuthenticationHeader(headers, name, requestRegistration)) { + unprovenAuthenticationHeaders.set(name, mutable); + } + } + if (requestRegistration !== undefined && headers === requestRegistration.carrier) { + snapshotAzureAuthenticationHeaders(headers, requestRegistration); + } + for (const [name, value] of iterateHeaders(headers, requestRegistration)) { if (!httpTokenHeaderName.test(name)) { throw new TypeError(`Header name must be a valid HTTP token ["${name}"]`); } const lowerName = name.toLowerCase(); + const deferAuthenticationHeader = protectsAzureCredentials && isAzureAuthenticationHeader(lowerName); if (!seenHeaders.has(lowerName)) { targetHeaders.delete(lowerName); + if (deferAuthenticationHeader) { + pendingAuthenticationHeaders.delete(lowerName); + } seenHeaders.add(lowerName); } if (value === null) { targetHeaders.delete(lowerName); + if (deferAuthenticationHeader) { + pendingAuthenticationHeaders.delete(lowerName); + } nullHeaders.add(lowerName); } else { - targetHeaders.append(lowerName, value); + if (deferAuthenticationHeader) { + let snapshot = value; + if (typeof value !== 'string') { + const shadowedHere = hasRemainingAzureAuthenticationOverride( + headers === requestRegistration?.carrier ? source : headers, + name, + lowerName, + requestRegistration, + ); + if (shadowedHere) { + const uncertain = unprovenAuthenticationHeaders.get(lowerName) ?? []; + uncertain.push(value); + unprovenAuthenticationHeaders.set(lowerName, uncertain); + } + if ( + !shadowedHere && + !hasLaterAzureAuthenticationOverride(newHeaders, sourceIndex, lowerName, requestRegistration) + ) { + snapshot = coerceAzureCredentialHeaderValue(value); + } + } + const pending = pendingAuthenticationHeaders.get(lowerName); + if (pending) { + pending.push(snapshot); + } else { + pendingAuthenticationHeaders.set(lowerName, [snapshot]); + } + } else { + targetHeaders.append(lowerName, value); + } nullHeaders.delete(lowerName); } } + for (const [name, uncertain] of unprovenAuthenticationHeaders) { + if (pendingAuthenticationHeaders.get(name)?.some((value) => uncertain.includes(value))) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + } + } + for (const [name, values] of pendingAuthenticationHeaders) { + const snapshots = values.map((value) => { + const snapshot = coerceAzureCredentialHeaderValue(value); + assertAzureCredentialHeaderValue(snapshot); + return snapshot; + }); + pendingAuthenticationHeaders.set(name, snapshots); + } + for (const [name, values] of pendingAuthenticationHeaders) { + for (const value of values) { + targetHeaders.append(name, value); + } } return { [brand_privateNullableHeaders]: true, values: targetHeaders, nulls: nullHeaders }; }; +export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { + const marker = newHeaders.length === 1 ? reserveAzureBodyMarker(newHeaders[0]) : undefined; + try { + return buildHeadersWithRegistration(newHeaders, marker?.registration); + } finally { + if (marker !== undefined) { + marker.reserved = false; + } + } +}; + export const isEmptyHeaders = (headers: HeadersLike) => { for (const _ of iterateHeaders(headers)) return false; return true; diff --git a/src/internal/utils/log.ts b/src/internal/utils/log.ts index 56689bb31..636edb38b 100644 --- a/src/internal/utils/log.ts +++ b/src/internal/utils/log.ts @@ -57,6 +57,7 @@ const noopLogger = { }; let cachedLoggers = /* @__PURE__ */ new WeakMap(); +const intrinsicHeadersEntries = Headers.prototype.entries; export function loggerFor(client: OpenAI): Logger { const logger = client.logger; @@ -140,7 +141,7 @@ export function redactURL(value: string): string { export const formatRequestDetails = (details: { options?: RequestOptions | undefined; - headers?: Headers | Record | undefined; + headers?: Headers | Record | [string, string][] | undefined; retryOfRequestLogID?: string | undefined; retryOf?: string | undefined; url?: string | undefined; @@ -151,8 +152,14 @@ export const formatRequestDetails = (details: { body?: unknown; }) => { if (details.options) { - details.options = { ...details.options }; - delete details.options['headers']; // redundant + leaks internals + const options = details.options; + details.options = Object.fromEntries( + Reflect.ownKeys(options).flatMap((key) => + key === 'headers' || !Object.prototype.propertyIsEnumerable.call(options, key) + ? [] + : [[key, Reflect.get(options, key)]], + ), + ); if (details.options.path) { const path = details.options.path; const redacted = new URL(redactURL(new URL(path, 'https://redacted.invalid').href)); @@ -174,11 +181,27 @@ export const formatRequestDetails = (details: { details.url = redactURL(details.url); } if (details.headers) { - details.headers = Object.fromEntries( - (details.headers instanceof Headers ? [...details.headers] : Object.entries(details.headers)).map( - ([name, value]) => [name, isSensitiveHeader(name) ? '***' : value], - ), - ); + const headers = details.headers; + try { + details.headers = Object.fromEntries( + headers instanceof Headers + ? Array.from(intrinsicHeadersEntries.call(headers), ([name, value]) => [ + name, + isSensitiveHeader(name) ? '***' : value, + ]) + : Array.isArray(headers) + ? headers.map((entry) => { + const name = entry[0]; + return [name, isSensitiveHeader(name) ? '***' : entry[1]]; + }) + : Object.keys(headers).map((name) => [ + name, + isSensitiveHeader(name) ? '***' : (Reflect.get(headers, name) as string), + ]), + ); + } catch { + details.headers = {}; + } } if ('retryOfRequestLogID' in details) { if (details.retryOfRequestLogID) { diff --git a/src/realtime/websocket.ts b/src/realtime/websocket.ts index 196a1180e..15e98ca8f 100644 --- a/src/realtime/websocket.ts +++ b/src/realtime/websocket.ts @@ -1,4 +1,5 @@ import type { AzureOpenAI } from '../index'; +import { safeAzureCredentialHeaderValue } from '../internal/azure'; import { assertBedrockWebSocketOrigin } from '../internal/bedrock'; import { OpenAI } from '../index'; import { OpenAIError } from '../error'; @@ -125,11 +126,12 @@ function createAzureWebSocket( throw new Error('Azure OpenAI Realtime requires an API key'); } + const credential = safeAzureCredentialHeaderValue(apiKey); redactAzureCredentials(url, isBearerToken); const socketURL = new URL(url); socketURL.searchParams.delete('api-key'); socketURL.searchParams.delete('Authorization'); - const headers = isBearerToken ? { Authorization: `Bearer ${apiKey}` } : { 'api-key': apiKey }; + const headers = isBearerToken ? { Authorization: `Bearer ${credential}` } : { 'api-key': credential }; // @ts-ignore return new WebSocket(socketURL.toString(), { protocols, headers }); @@ -180,10 +182,11 @@ export class OpenAIRealtimeWebSocket extends OpenAIRealtimeEmitter { ) { super(); const hasProvider = typeof (client as any)?._options?.apiKey === 'function'; + const apiKey = client?.apiKey; const dangerouslyAllowBrowser = props.dangerouslyAllowBrowser ?? (client as any)?._options?.dangerouslyAllowBrowser ?? - (client?.apiKey?.startsWith('ek_') ? true : null); + (typeof apiKey === 'string' && apiKey.startsWith('ek_') ? true : null); if (!dangerouslyAllowBrowser && isRunningInBrowserOrBrowserWorker()) { throw new OpenAIError( "It looks like you're running in a browser-like environment.\n\nThis is disabled by default, as it risks exposing your secret API credentials to attackers.\n\nYou can avoid this error by creating an ephemeral session token:\nhttps://platform.openai.com/docs/api-reference/realtime-sessions\n", diff --git a/src/realtime/ws.ts b/src/realtime/ws.ts index b18931756..53ff984e7 100644 --- a/src/realtime/ws.ts +++ b/src/realtime/ws.ts @@ -1,4 +1,5 @@ import * as WS from 'ws'; +import { safeAzureCredentialHeaderValue, safeAzureWebSocketHeaders } from '../internal/azure'; import { assertBedrockWebSocketOrigin } from '../internal/bedrock'; import { protectWebSocketOptionsFromCredentialRedirects } from '../internal/ws'; import type { AzureOpenAI } from '../index'; @@ -61,16 +62,21 @@ export class OpenAIRealtimeWS extends OpenAIRealtimeEmitter { } this.url = buildRealtimeURL(client, props); assertBedrockWebSocketOrigin(client, this.url); + const azure = isAzure(client); const headers = { ...props.options?.headers, - ...(isAzure(client) && !props.__resolvedApiKey ? {} : { Authorization: `Bearer ${client.apiKey}` }), + ...(azure && !props.__resolvedApiKey + ? {} + : { + Authorization: `Bearer ${azure ? safeAzureCredentialHeaderValue(client.apiKey) : client.apiKey}`, + }), }; this.socket = new WS.WebSocket( this.url, protectWebSocketOptionsFromCredentialRedirects({ ...props.options, - headers, + headers: azure ? safeAzureWebSocketHeaders(headers) : headers, }), ); diff --git a/tests/lib/azure-credential-header-accessors.test.ts b/tests/lib/azure-credential-header-accessors.test.ts new file mode 100644 index 000000000..292f15aa9 --- /dev/null +++ b/tests/lib/azure-credential-header-accessors.test.ts @@ -0,0 +1,840 @@ +import { once } from 'node:events'; +import { vi } from 'vitest'; + +import { AzureOpenAI } from 'openai'; +import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; +import { + buildAzureAuthenticationHeaders, + buildHeaders, + protectAzureRequestHeaders, +} from 'openai/internal/headers'; +import type { NullableHeaders } from 'openai/internal/headers'; +import type { FinalRequestOptions } from 'openai/internal/request-options'; + +const BASE_URL = 'https://azure-resource.example.com/openai'; +const API_VERSION = '2024-02-15-preview'; +const SAFE_ERROR = 'Azure OpenAI credential contains an invalid HTTP header value.'; +const PRIVATE_CREDENTIAL = 'private-azure-credential-85d3'; +const MISSING_AUTHENTICATION = 'Could not resolve authentication method.'; + +type Authentication = 'static-api-key' | 'rotating-entra-token'; + +class ObservedAzure extends AzureOpenAI { + readonly authenticationOptions: FinalRequestOptions[] = []; + readonly preparedOptions: FinalRequestOptions[] = []; + readonly requestOptions: FinalRequestOptions[] = []; + awaitAuthentication: (() => Promise) | undefined; + clearPreparedCredential = false; + preparedCredential: null | undefined; + + protected override async prepareOptions(options: FinalRequestOptions): Promise { + await super.prepareOptions(options); + this.preparedOptions.push(options); + if (this.clearPreparedCredential) { + Reflect.set(this, 'apiKey', this.preparedCredential); + } + } + + protected override async prepareRequest( + request: RequestInit, + context: { url: string; options: FinalRequestOptions }, + ): Promise { + this.requestOptions.push(context.options); + await super.prepareRequest(request, context); + } + + protected override async authHeaders( + options: FinalRequestOptions, + schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, + ): Promise { + this.authenticationOptions.push(options); + if (this.awaitAuthentication) { + await this.awaitAuthentication(); + } + return super.authHeaders(options, schemes); + } +} + +function createClient(authentication: Authentication = 'static-api-key') { + const provider = vi.fn(async () => 'safe-rotating-token'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => + globalThis.Response.json({ ok: true }), + ); + const client = new ObservedAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'safe-configured-token' } + : { azureADTokenProvider: provider }), + fetch, + maxRetries: 0, + }); + return { client, fetch, provider }; +} + +async function expectSanitizedFailure(operation: Promise): Promise { + let failure: unknown; + try { + await operation; + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(TypeError); + if (!(failure instanceof TypeError)) { + throw new Error('Expected a sanitized Azure credential error.'); + } + expect(failure.message).toBe(SAFE_ERROR); + expect((failure as TypeError & { cause?: unknown }).cause).toBeUndefined(); + expect(failure.stack).not.toContain(PRIVATE_CREDENTIAL); +} + +function withDeepHeaderPrototype( + options: FinalRequestOptions, + owner: object, + depth: number, +): FinalRequestOptions { + let prototype = owner; + for (let index = 0; index < depth; index += 1) { + prototype = Object.create(prototype) as object; + } + Object.setPrototypeOf(options, prototype); + return options; +} + +describe('Azure immutable request-header accessors', () => { + const requests = [ + { description: 'a bodyless GET', method: 'get', body: false }, + { description: 'a JSON POST', method: 'post', body: true }, + { description: 'an explicitly bodyless POST', method: 'post', body: undefined }, + ] as const; + + test.each( + (['static-api-key', 'rotating-entra-token'] as const).flatMap((authentication) => + requests.flatMap((request) => + ([true, false] as const).flatMap((enumerable) => + (['api-key', 'Authorization'] as const).map((header) => ({ + authentication, + enumerable, + header, + request, + })), + ), + ), + ), + )( + '$authentication reads an immutable enumerable=$enumerable $header accessor once for $request.description', + async ({ authentication, enumerable, header, request }) => { + const { client, fetch, provider } = createClient(authentication); + const options: FinalRequestOptions = { + method: request.method, + path: '/models', + ...(request.body === false ? {} : { body: request.body === true ? { safe: true } : undefined }), + }; + const headers = { [header]: 'safe-request-token', 'x-custom': 'preserved' }; + let reads = 0; + Object.defineProperty(options, 'headers', { + configurable: false, + enumerable, + get() { + reads += 1; + return headers; + }, + }); + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + + await client.request(options); + + expect(reads).toBe(1); + expect(client.authenticationOptions).toEqual([options]); + expect(client.preparedOptions).toEqual([options]); + expect(client.requestOptions).toEqual([options]); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(descriptor); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(header)).toBe('safe-request-token'); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('x-custom')).toBe('preserved'); + expect(fetch).toHaveBeenCalledTimes(1); + expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + }, + ); + + test.each(['get', 'post'] as const)( + 'never invokes a stateful immutable $method accessor a second time', + async (method) => { + const { client, fetch } = createClient(); + const options: FinalRequestOptions = { + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + }; + const first = { 'api-key': 'safe-first-token' }; + let reads = 0; + Object.defineProperty(options, 'headers', { + configurable: false, + enumerable: true, + get() { + reads += 1; + if (reads !== 1) { + throw Object.assign(new Error(PRIVATE_CREDENTIAL), { cause: new Error(PRIVATE_CREDENTIAL) }); + } + return first; + }, + }); + + await client.request(options); + + expect(reads).toBe(1); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('safe-first-token'); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['throws', 'returns an invalid credential'] as const)( + 'sanitizes an immutable accessor that %s after reading it once', + async (behavior) => { + const { client, fetch } = createClient(); + const options: FinalRequestOptions = { method: 'post', path: '/models', body: { safe: true } }; + let reads = 0; + Object.defineProperty(options, 'headers', { + configurable: false, + enumerable: true, + get() { + reads += 1; + if (behavior === 'throws') { + throw Object.assign(new Error(PRIVATE_CREDENTIAL), { cause: new Error(PRIVATE_CREDENTIAL) }); + } + return { Authorization: `${PRIVATE_CREDENTIAL}\nprivate-suffix` }; + }, + }); + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + + await expectSanitizedFailure(client.request(options)); + + expect(reads).toBe(1); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(descriptor); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each(['immutable own', 'nonextensible inherited'] as const)( + 'keeps public buildRequest hooks on the original options with %s accessors', + async (representation) => { + const { client } = createClient(); + const owner = representation === 'immutable own' ? {} : Object.create(null); + let reads = 0; + Object.defineProperty(owner, 'headers', { + configurable: false, + enumerable: true, + get() { + reads += 1; + return { 'api-key': 'safe-request-token' }; + }, + }); + const options = + representation === 'immutable own' + ? Object.assign(owner as FinalRequestOptions, { method: 'get' as const, path: '/models' }) + : Object.preventExtensions( + Object.assign(Object.create(owner) as FinalRequestOptions, { + method: 'get' as const, + path: '/models', + }), + ); + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + + const built = await client.buildRequest(options); + + expect(reads).toBe(1); + expect(client.authenticationOptions).toEqual([options]); + expect(built.req.headers.get('api-key')).toBe('safe-request-token'); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(descriptor); + }, + ); + + test('does not invoke mutation traps for a safe immutable proxy-backed accessor', async () => { + const { client, fetch } = createClient(); + const target: FinalRequestOptions = { method: 'get', path: '/models' }; + const read = vi.fn(() => ({ 'api-key': 'safe-proxy-token' })); + const mutate = vi.fn(() => { + throw new Error(PRIVATE_CREDENTIAL); + }); + Object.defineProperty(target, 'headers', { configurable: false, enumerable: true, get: read }); + const options = new Proxy(target, { defineProperty: mutate, deleteProperty: mutate }); + + await client.request(options); + + expect(read).toHaveBeenCalledTimes(1); + expect(mutate).not.toHaveBeenCalled(); + expect(client.authenticationOptions).toEqual([options]); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('safe-proxy-token'); + }); + + test('isolates simultaneous immutable snapshots while keeping protected-hook identity', async () => { + const { client } = createClient(); + const options: FinalRequestOptions = { method: 'post', path: '/models', body: { safe: true } }; + const records = [{ 'api-key': 'first-tenant-token' }, { 'api-key': 'second-tenant-token' }]; + let reads = 0; + Object.defineProperty(options, 'headers', { + configurable: false, + enumerable: true, + get() { + const result = records[reads]; + reads += 1; + return result; + }, + }); + const releases: (() => void)[] = []; + client.awaitAuthentication = async () => { + const gate = new AbortController(); + releases.push(() => gate.abort()); + await once(gate.signal, 'abort'); + }; + + const first = client.buildRequest(options); + const second = client.buildRequest(options); + expect(client.authenticationOptions).toEqual([options, options]); + expect(reads).toBe(2); + + releases[0]?.(); + const firstBuilt = await first; + releases[1]?.(); + const secondBuilt = await second; + + expect(firstBuilt.req.headers.get('api-key')).toBe('first-tenant-token'); + expect(secondBuilt.req.headers.get('api-key')).toBe('second-tenant-token'); + expect(reads).toBe(2); + }); + + test.each( + (['nonextensible', 'sealed', 'frozen'] as const).flatMap((immutability) => + (['static-api-key', 'rotating-entra-token'] as const) + .filter((authentication) => immutability !== 'frozen' || authentication === 'static-api-key') + .map((authentication) => ({ authentication, immutability })), + ), + )( + '$authentication keeps concurrent public requests tenant-local on a $immutability client', + async ({ authentication, immutability }) => { + const { client, fetch } = createClient(authentication); + const header = authentication === 'static-api-key' ? 'api-key' : 'Authorization'; + const records = [ + { [header]: 'first-tenant-token', 'x-tenant': 'first' }, + { [header]: 'second-tenant-token', 'x-tenant': 'second' }, + ]; + let reads = 0; + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { safe: true }, + get headers() { + const record = records[reads]; + reads += 1; + return record; + }, + }; + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + const releases: (() => void)[] = []; + client.awaitAuthentication = async () => { + const gate = new AbortController(); + releases.push(() => gate.abort()); + await once(gate.signal, 'abort'); + }; + if (immutability === 'frozen') { + Object.freeze(client); + } else if (immutability === 'sealed') { + Object.seal(client); + } else { + Object.preventExtensions(client); + } + + const first = client.request(options); + const second = client.request(options); + await vi.waitFor(() => expect(releases).toHaveLength(2), { interval: 1 }); + expect(client.authenticationOptions).toEqual([options, options]); + expect(reads).toBe(2); + + releases[0]?.(); + await first; + releases[1]?.(); + await second; + + const firstHeaders = new Headers(fetch.mock.calls[0]?.[1]?.headers); + const secondHeaders = new Headers(fetch.mock.calls[1]?.[1]?.headers); + expect(firstHeaders.get(header)).toBe('first-tenant-token'); + expect(firstHeaders.get('x-tenant')).toBe('first'); + expect(secondHeaders.get(header)).toBe('second-tenant-token'); + expect(secondHeaders.get('x-tenant')).toBe('second'); + expect(Object.getOwnPropertyDescriptor(client, 'authHeaders')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(descriptor); + expect(fetch).toHaveBeenCalledTimes(2); + }, + ); + + test.each( + (['api-key', 'Authorization'] as const).flatMap((header) => + (['object', 'proxy'] as const).map((representation) => ({ header, representation })), + ), + )( + 'snapshots an effective $representation $header before a later caller getter mutates it', + async ({ header, representation }) => { + let effectiveCredential = 'first-tenant-token'; + let coercions = 0; + const source = { + toString(): string { + coercions += 1; + return effectiveCredential; + }, + }; + const credential = representation === 'proxy' ? new Proxy(source, {}) : source; + const defaults: Record = {}; + Object.defineProperty(defaults, header, { enumerable: true, value: credential }); + const requestHeaders: Record = {}; + Object.defineProperty(requestHeaders, 'x-request-metadata', { + enumerable: true, + get(): string { + effectiveCredential = 'second-tenant-token'; + return 'preserved'; + }, + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => + globalThis.Response.json({ ok: true }), + ); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-configured-token', + defaultHeaders: defaults, + fetch, + maxRetries: 0, + }); + + await client.request({ method: 'get', path: '/models', headers: requestHeaders }); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get(header)).toBe('first-tenant-token'); + expect(sent.get('x-request-metadata')).toBe('preserved'); + expect(coercions).toBe(1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'snapshots an effective $header before a later getter in the same source mutates it', + async (header) => { + let effectiveCredential = 'first-tenant-token'; + let coercions = 0; + const defaults: Record = {}; + Object.defineProperty(defaults, header, { + enumerable: true, + value: { + toString(): string { + coercions += 1; + return effectiveCredential; + }, + }, + }); + Object.defineProperty(defaults, 'x-default-metadata', { + enumerable: true, + get(): string { + effectiveCredential = 'second-tenant-token'; + return 'preserved'; + }, + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => + globalThis.Response.json({ ok: true }), + ); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-configured-token', + defaultHeaders: defaults, + fetch, + maxRetries: 0, + }); + + await client.request({ method: 'get', path: '/models' }); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get(header)).toBe('first-tenant-token'); + expect(sent.get('x-default-metadata')).toBe('preserved'); + expect(coercions).toBe(1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'never coerces a shadowed object-backed $header while a later getter replaces it', + async (header) => { + let coercions = 0; + const shadowed = { + toString(): string { + coercions += 1; + throw new Error(PRIVATE_CREDENTIAL); + }, + }; + const defaults: Record = {}; + Object.defineProperty(defaults, header, { enumerable: true, value: shadowed }); + const requestHeaders: Record = {}; + Object.defineProperty(requestHeaders, header, { + enumerable: true, + get(): string { + return 'request-tenant-token'; + }, + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => + globalThis.Response.json({ ok: true }), + ); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-configured-token', + defaultHeaders: defaults, + fetch, + maxRetries: 0, + }); + + await client.request({ method: 'get', path: '/models', headers: requestHeaders }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(header)).toBe('request-tenant-token'); + expect(coercions).toBe(0); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'never coerces an ineffective $header before a later case-insensitive same-source override', + async (header) => { + let coercions = 0; + const defaults: Record = {}; + Object.defineProperty(defaults, header, { + enumerable: true, + value: { + toString(): string { + coercions += 1; + throw new Error(PRIVATE_CREDENTIAL); + }, + }, + }); + Object.defineProperty(defaults, header.toUpperCase(), { + enumerable: true, + get(): string { + return 'effective-default-token'; + }, + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => + globalThis.Response.json({ ok: true }), + ); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-configured-token', + defaultHeaders: defaults, + fetch, + maxRetries: 0, + }); + + await client.request({ method: 'get', path: '/models' }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(header)).toBe('effective-default-token'); + expect(coercions).toBe(0); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'fails closed when a later $header getter mutates a credential without replacing it', + async (header) => { + let effectiveCredential = 'first-tenant-token'; + const defaults: Record = {}; + Object.defineProperty(defaults, header, { + enumerable: true, + value: { toString: () => effectiveCredential }, + }); + const requestHeaders: Record = {}; + Object.defineProperty(requestHeaders, header, { + enumerable: true, + get(): undefined { + effectiveCredential = 'second-tenant-token'; + return undefined; + }, + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => + globalThis.Response.json({ ok: true }), + ); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-configured-token', + defaultHeaders: defaults, + fetch, + maxRetries: 0, + }); + + await expectSanitizedFailure( + client.request({ method: 'get', path: '/models', headers: requestHeaders }), + ); + + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test('rejects an unbound authentication carrier instead of guessing its request registration', () => { + const headers = { 'api-key': 'tenant-token' }; + const protection = protectAzureRequestHeaders(headers); + try { + expect(() => buildHeaders([buildAzureAuthenticationHeaders(), headers])).toThrow(SAFE_ERROR); + } finally { + protection?.release(); + } + }); + + test('refreshes rotating bearer credentials across repeated immutable-header requests', async () => { + const { client, fetch, provider } = createClient('rotating-entra-token'); + provider.mockImplementation(async () => `rotating-token-${provider.mock.calls.length}`); + const options: FinalRequestOptions = { method: 'get', path: '/models' }; + const read = vi.fn(() => ({ 'x-custom': 'preserved' })); + Object.defineProperty(options, 'headers', { configurable: false, enumerable: true, get: read }); + + await client.request(options); + await client.request(options); + + expect(provider).toHaveBeenCalledTimes(2); + expect(read).toHaveBeenCalledTimes(2); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('authorization')).toBe( + 'Bearer rotating-token-1', + ); + expect(new Headers(fetch.mock.calls[1]?.[1]?.headers).get('authorization')).toBe( + 'Bearer rotating-token-2', + ); + }); + + test('preserves unrelated body-serialization errors after copying immutable headers', async () => { + const { client, fetch } = createClient(); + const failure = new Error('unrelated custom body serialization failed'); + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { + toJSON() { + throw failure; + }, + }, + }; + const read = vi.fn(() => ({ 'api-key': 'safe-request-token' })); + Object.defineProperty(options, 'headers', { configurable: false, enumerable: true, get: read }); + + await expect(client.request(options)).rejects.toBe(failure); + + expect(read).toHaveBeenCalledTimes(1); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe('Azure deep request-header prototype discovery', () => { + test.each([40, 128] as const)( + 'snapshots a safe inherited accessor at prototype depth %s exactly once', + async (depth) => { + const { client } = createClient(); + let reads = 0; + const owner = Object.create(null) as object; + Object.defineProperty(owner, 'headers', { + configurable: true, + enumerable: true, + get() { + reads += 1; + if (reads !== 1) { + throw new Error(PRIVATE_CREDENTIAL); + } + return { 'api-key': 'safe-inherited-token' }; + }, + }); + const options = withDeepHeaderPrototype({ method: 'get', path: '/models' }, owner, depth); + + const built = await client.buildRequest(options); + + expect(reads).toBe(1); + expect(built.req.headers.get('api-key')).toBe('safe-inherited-token'); + expect(client.authenticationOptions).toEqual([options]); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toBeUndefined(); + }, + ); + + test('fails closed before reading an accessor beyond the bounded prototype walk', async () => { + const { client, fetch } = createClient(); + const owner = Object.create(null) as object; + const read = vi.fn(() => { + throw new Error(PRIVATE_CREDENTIAL); + }); + Object.defineProperty(owner, 'headers', { configurable: true, get: read }); + const options = withDeepHeaderPrototype({ method: 'get', path: '/models' }, owner, 1024); + + await expectSanitizedFailure(client.request(options)); + + expect(read).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + test.each(['descriptor', 'prototype'] as const)( + 'sanitizes a deep inherited proxy %s trap without invoking the credential accessor', + async (operation) => { + const { client, fetch } = createClient(); + const read = vi.fn(() => ({ 'api-key': 'safe-inherited-token' })); + const owner = Object.create(null) as object; + Object.defineProperty(owner, 'headers', { configurable: true, get: read }); + const hostile = new Proxy(Object.create(owner) as object, { + getOwnPropertyDescriptor(target, property) { + if (operation === 'descriptor' && property === 'headers') { + throw Object.assign(new Error(PRIVATE_CREDENTIAL), { cause: new Error(PRIVATE_CREDENTIAL) }); + } + return Reflect.getOwnPropertyDescriptor(target, property); + }, + getPrototypeOf(target) { + if (operation === 'prototype') { + throw Object.assign(new Error(PRIVATE_CREDENTIAL), { cause: new Error(PRIVATE_CREDENTIAL) }); + } + return Reflect.getPrototypeOf(target); + }, + }); + const options = withDeepHeaderPrototype({ method: 'get', path: '/models' }, hostile, 48); + + await expectSanitizedFailure(client.request(options)); + + expect(read).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test('fails closed on a cyclic proxy prototype without reading hostile headers', async () => { + const { client, fetch } = createClient(); + const target = Object.create(null) as object; + let reads = 0; + const cycle: object = new Proxy(target, { + get(value, property, receiver) { + if (property === 'headers') { + reads += 1; + throw new Error(PRIVATE_CREDENTIAL); + } + return Reflect.get(value, property, receiver); + }, + getPrototypeOf() { + return cycle; + }, + }); + const options = Object.assign(Object.create(cycle) as FinalRequestOptions, { + method: 'get' as const, + path: '/models', + }); + + await expectSanitizedFailure(client.request(options)); + + expect(reads).toBe(0); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('bounds proxy-generated infinite prototype chains without invoking hostile header getters', async () => { + const { client, fetch } = createClient(); + let traversals = 0; + let reads = 0; + const handler: ProxyHandler = { + get(target, property, receiver) { + if (property === 'headers') { + reads += 1; + throw new Error(PRIVATE_CREDENTIAL); + } + return Reflect.get(target, property, receiver); + }, + getPrototypeOf() { + traversals += 1; + return new Proxy(Object.create(null) as object, handler); + }, + }; + const root = new Proxy(Object.create(null) as object, handler); + const options = Object.assign(Object.create(root) as FinalRequestOptions, { + method: 'get' as const, + path: '/models', + }); + + await expectSanitizedFailure(client.request(options)); + + expect(reads).toBe(0); + expect(traversals).toBeLessThan(300); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe('Azure bearer credential absence', () => { + test.each([null, undefined] as const)( + 'uses normal missing-auth validation for a %s rotating credential in public buildRequest', + async (credential) => { + const { client, fetch, provider } = createClient('rotating-entra-token'); + Reflect.set(client, 'apiKey', credential); + + await expect(client.buildRequest({ method: 'get', path: '/models' })).rejects.toThrow( + MISSING_AUTHENTICATION, + ); + + expect(fetch).not.toHaveBeenCalled(); + expect(provider).not.toHaveBeenCalled(); + }, + ); + + test.each([null, undefined] as const)( + 'never dispatches a Bearer %s credential cleared by prepareOptions', + async (credential) => { + const { client, fetch, provider } = createClient('rotating-entra-token'); + client.clearPreparedCredential = true; + client.preparedCredential = credential; + + await expect(client.request({ method: 'get', path: '/models' })).rejects.toThrow( + MISSING_AUTHENTICATION, + ); + + expect(provider).toHaveBeenCalledTimes(1); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each([null, undefined] as const)( + 'preserves valid request authentication overrides for an absent %s rotating credential', + async (credential) => { + const { client } = createClient('rotating-entra-token'); + Reflect.set(client, 'apiKey', credential); + + const built = await client.buildRequest({ + method: 'get', + path: '/models', + headers: { Authorization: 'Bearer safe-request-token' }, + }); + + expect(built.req.headers.get('authorization')).toBe('Bearer safe-request-token'); + }, + ); + + test.each([null, undefined] as const)( + 'retains explicit authorization omission for an absent %s rotating credential', + async (credential) => { + const { client } = createClient('rotating-entra-token'); + Reflect.set(client, 'apiKey', credential); + + const built = await client.buildRequest({ + method: 'get', + path: '/models', + headers: { Authorization: null }, + }); + + expect(built.req.headers.has('authorization')).toBe(false); + expect(built.req.headers.has('api-key')).toBe(false); + }, + ); + + test('keeps explicit static null omission distinct from an undefined static credential', async () => { + const omitted = createClient(); + omitted.client.apiKey = null; + await omitted.client.request({ method: 'get', path: '/models' }); + expect(new Headers(omitted.fetch.mock.calls[0]?.[1]?.headers).has('api-key')).toBe(false); + + const missing = createClient(); + Reflect.set(missing.client, 'apiKey', undefined); + await expect(missing.client.buildRequest({ method: 'get', path: '/models' })).rejects.toThrow( + MISSING_AUTHENTICATION, + ); + expect(missing.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts new file mode 100644 index 000000000..a111ee5bf --- /dev/null +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -0,0 +1,5198 @@ +import { once } from 'node:events'; +import { createRequire } from 'node:module'; +import { runInNewContext } from 'node:vm'; +import { vi } from 'vitest'; + +import { APIConnectionError, AzureOpenAI, OpenAIError } from 'openai'; +import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; +import { buildAzureAuthenticationHeaders, buildHeaders } from 'openai/internal/headers'; +import type { NullableHeaders } from 'openai/internal/headers'; +import type { FinalRequestOptions } from 'openai/internal/request-options'; + +type Authentication = 'static-api-key' | 'rotating-entra-token'; +type PublicRoute = 'generic-request' | 'models-list' | 'chat-completion'; +type Fetch = (url: RequestInfo, init?: RequestInit) => Promise; +type CarrierAuthenticationScheme = 'auth' | 'bearer' | 'admin'; + +class ProtectedHookAzure extends AzureOpenAI { + injectedHeaders: Record | Headers | [string, string][] | undefined; + bearerCalls = 0; + adminCalls = 0; + fetchFailures = 0; + mutation: 'auth' | 'auth-null' | 'bearer' | 'admin' | undefined; + mutationScheme: CarrierAuthenticationScheme = 'auth'; + mutateCarrier: ((headers: Headers) => void) | undefined; + inspectAuthenticationCarrier: ((carrier: NullableHeaders) => void) | undefined; + cloneAuthenticationCarrier: 'spread' | 'assign' | undefined; + reusedAuthenticationCarrier: NullableHeaders | undefined; + observeAuthenticationOptions: ((options: FinalRequestOptions) => Promise) | undefined; + observePreparedOptions: ((options: FinalRequestOptions) => void) | undefined; + observeProtectedHookOptions: + | ((hook: 'auth' | 'bearer' | 'admin' | 'request', options: FinalRequestOptions) => void) + | undefined; + + protected override async prepareOptions(options: FinalRequestOptions): Promise { + await super.prepareOptions(options); + this.observePreparedOptions?.(options); + } + + protected override async prepareRequest( + request: RequestInit, + context?: { url: string; options: FinalRequestOptions }, + ): Promise { + if (context) { + this.observeProtectedHookOptions?.('request', context.options); + } + if (this.injectedHeaders) { + request.headers = this.injectedHeaders; + } + } + + protected override fetchWithAuth( + url: RequestInfo, + init: RequestInit, + timeout: number, + controller: AbortController, + schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, + ): Promise { + return super + .fetchWithAuth(url, init, timeout, controller, schemes) + .catch(this.recordFetchFailure.bind(this)); + } + + private recordFetchFailure(error: unknown): never { + this.fetchFailures += 1; + throw error; + } + + invokeProtectedFetch(headers: Record | Headers): Promise { + return this.fetchWithAuth( + 'https://azure-resource.example.com/openai/models', + { headers }, + 1000, + new AbortController(), + ); + } + + inspectDeferredDefaultHeaders(defaults: Record, inspect: (headers: Headers) => void): void { + const carrier = buildAzureAuthenticationHeaders(defaults); + this._options.defaultHeaders = carrier; + inspect(carrier.values); + } + + protected override async authHeaders( + options: FinalRequestOptions, + schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, + ): Promise { + this.observeProtectedHookOptions?.('auth', options); + if (this.observeAuthenticationOptions) { + await this.observeAuthenticationOptions(options); + } + const carrier = this.reusedAuthenticationCarrier ?? (await super.authHeaders(options, schemes)); + if (this.mutation === 'auth') { + carrier?.values.set('API-KEY', 'mutated-static-token'); + } else if (this.mutation === 'auth-null') { + carrier?.nulls.add('api-key'); + } + if (carrier && this.mutationScheme === 'auth') { + this.mutateCarrier?.(carrier.values); + } + if (carrier) { + this.inspectAuthenticationCarrier?.(carrier); + } + if (!carrier || !this.cloneAuthenticationCarrier) { + return carrier; + } + if (this.cloneAuthenticationCarrier === 'spread') { + return { ...carrier }; + } + const copied = {}; + return Object.assign(copied, carrier); + } + + protected override async bearerAuth(options: FinalRequestOptions): Promise { + this.observeProtectedHookOptions?.('bearer', options); + this.bearerCalls += 1; + if (this.mutation === 'bearer' || (this.mutationScheme === 'bearer' && this.mutateCarrier)) { + const carrier = await super.bearerAuth(options); + if (!carrier) { + throw new Error('Expected a deferred bearer authentication carrier.'); + } + if (this.mutation === 'bearer') { + carrier.values.set('AUTHORIZATION', 'Bearer mutated-bearer-token'); + } else { + this.mutateCarrier?.(carrier.values); + } + return carrier; + } + return buildHeaders([{ Authorization: 'Bearer custom-bearer-token' }]); + } + + protected override async adminAPIKeyAuth(options: FinalRequestOptions): Promise { + this.observeProtectedHookOptions?.('admin', options); + this.adminCalls += 1; + if (this.mutation === 'admin' || (this.mutationScheme === 'admin' && this.mutateCarrier)) { + const carrier = await super.adminAPIKeyAuth(options); + if (!carrier) { + throw new Error('Expected a deferred admin authentication carrier.'); + } + if (this.mutation === 'admin') { + carrier.values.set('authorization', 'Bearer mutated-admin-token'); + } else { + this.mutateCarrier?.(carrier.values); + } + return carrier; + } + return buildHeaders([{ Authorization: 'Bearer custom-admin-token' }]); + } +} + +const testRequire = createRequire(`${process.cwd()}/package.json`); +const foreignRequire = createRequire(testRequire.resolve('vitest/package.json')); +const { Headers: ForeignHeaders } = foreignRequire('undici') as { Headers: typeof Headers }; +const createForeignHeaders = (values: [string, string][]): Headers => + runInNewContext('new ForeignHeaders(values)', { ForeignHeaders, values }) as Headers; +const BASE_URL = 'https://azure-resource.example.com/openai'; +const API_VERSION = '2024-02-15-preview'; +const PRIVATE_CREDENTIAL = 'azure-private-credential-75da'; +const PRIVATE_SUFFIX = 'private-patient-record-21f8'; +const SAFE_ERROR = 'Azure OpenAI credential contains an invalid HTTP header value.'; + +const authenticationModes: readonly Authentication[] = ['static-api-key', 'rotating-entra-token']; +const publicRoutes: readonly PublicRoute[] = ['generic-request', 'models-list', 'chat-completion']; +const malformedCredentials = [ + ...Array.from({ length: 0x20 }, (_, code) => code) + .filter((code) => code !== 0x09) + .map((code) => ({ + format: `forbidden control byte U+${code.toString(16).padStart(4, '0').toUpperCase()}`, + character: String.fromCodePoint(code), + })), + { format: 'DEL U+007F', character: String.fromCodePoint(0x7f) }, + { format: 'non-ByteString Unicode', character: '\u{1F680}' }, + { format: 'unpaired Unicode surrogate', character: String.fromCodePoint(0xd8_00) }, + { format: 'carriage-return line-feed', character: '\r\n' }, +] as const; + +const malformedCases = authenticationModes.flatMap((authentication) => + publicRoutes.flatMap((route) => + malformedCredentials.map(({ format, character }) => ({ authentication, route, format, character })), + ), +); + +const validCredentials = [ + { format: 'plain', credential: 'valid-azure-credential-9c54' }, + { format: 'horizontal-tab', credential: 'valid\tazure-credential' }, + { format: 'space', credential: 'valid azure-credential' }, + { format: 'lowest obs-text', credential: `valid${String.fromCodePoint(0x80)}azure-credential` }, + { format: 'highest obs-text', credential: `valid${String.fromCodePoint(0xff)}azure-credential` }, +] as const; + +function createLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +type TestLogger = ReturnType; + +function createClient({ + authentication, + credential, + fetch, + tokenProvider = async () => credential, + logger, + redirect, +}: { + authentication: Authentication; + credential: string; + fetch: Fetch; + tokenProvider?: () => Promise; + logger?: TestLogger; + redirect?: RequestInit['redirect']; +}): AzureOpenAI { + return new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + deployment: 'test-deployment', + maxRetries: 0, + logLevel: 'debug', + fetch, + ...(logger ? { logger } : {}), + ...(redirect ? { fetchOptions: { redirect } } : {}), + ...(authentication === 'static-api-key' + ? { apiKey: credential } + : { azureADTokenProvider: tokenProvider }), + }); +} + +function invokePublicRoute(client: AzureOpenAI, route: PublicRoute): Promise { + switch (route) { + case 'generic-request': { + return client.request({ method: 'get', path: '/models' }); + } + case 'models-list': { + return client.models.list(); + } + case 'chat-completion': { + return client.chat.completions.create({ + model: 'test-deployment', + messages: [{ role: 'user', content: 'hello' }], + }); + } + default: { + throw new Error('Unknown Azure public request route.'); + } + } +} + +async function expectPrivateCredentialFailure( + operation: () => Promise, + credential: string, +): Promise { + let failure: unknown; + try { + await operation(); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(TypeError); + if (!(failure instanceof TypeError)) { + throw new Error('Invalid Azure credentials must preserve their native TypeError class.'); + } + + expect(failure.message).toBe(SAFE_ERROR); + expect((failure as TypeError & { cause?: unknown }).cause).toBeUndefined(); + for (const diagnostic of [failure.message, failure.stack ?? '']) { + expect(diagnostic).not.toContain(credential); + expect(diagnostic).not.toContain(PRIVATE_CREDENTIAL); + expect(diagnostic).not.toContain(PRIVATE_SUFFIX); + } + return failure; +} + +async function expectPrivateTransportCredentialFailure( + operation: () => Promise, + credential: string, +): Promise { + let failure: unknown; + try { + await operation(); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(APIConnectionError); + if (!(failure instanceof APIConnectionError)) { + throw new Error('Protected Azure transport failures must retain their connection wrapper.'); + } + const { cause } = failure as APIConnectionError & { cause?: unknown }; + expect(cause).toBeInstanceOf(TypeError); + if (!(cause instanceof TypeError)) { + throw new Error('Invalid Azure transport credentials require a sanitized TypeError cause.'); + } + expect(cause.message).toBe(SAFE_ERROR); + expect((cause as TypeError & { cause?: unknown }).cause).toBeUndefined(); + for (const diagnostic of [failure.message, failure.stack ?? '', cause.message, cause.stack ?? '']) { + expect(diagnostic).not.toContain(credential); + expect(diagnostic).not.toContain(PRIVATE_CREDENTIAL); + expect(diagnostic).not.toContain(PRIVATE_SUFFIX); + } +} + +function expectPrivateLogs(logger: TestLogger, credential: string): void { + const calls = [ + ...logger.debug.mock.calls, + ...logger.info.mock.calls, + ...logger.warn.mock.calls, + ...logger.error.mock.calls, + ]; + for (const argumentsList of calls) { + const serialized = JSON.stringify(argumentsList); + expect(serialized).not.toContain(credential); + expect(serialized).not.toContain(PRIVATE_CREDENTIAL); + expect(serialized).not.toContain(PRIVATE_SUFFIX); + } +} + +describe('Azure credential header diagnostic privacy', () => { + beforeEach(() => { + vi.stubEnv('AZURE_OPENAI_API_KEY', ''); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + test.each(malformedCases)( + '$authentication $route rejects $format before exposing or sending the credential', + async ({ authentication, route, character }) => { + const credential = `${PRIVATE_CREDENTIAL}${character}${PRIVATE_SUFFIX}`; + const logger = createLogger(); + const fetch = vi.fn(async () => Response.json({ data: [] })); + const tokenProvider = vi.fn(async () => credential); + const client = createClient({ authentication, credential, fetch, tokenProvider, logger }); + + await expectPrivateCredentialFailure(() => invokePublicRoute(client, route), credential); + + expect(fetch).not.toHaveBeenCalled(); + expect(tokenProvider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + expectPrivateLogs(logger, credential); + }, + ); + + test.each(authenticationModes)( + 'keeps the real default logger free of a malformed %s credential', + async (authentication) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const spies = [ + vi.spyOn(console, 'debug'), + vi.spyOn(console, 'info'), + vi.spyOn(console, 'warn'), + vi.spyOn(console, 'error'), + ]; + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = createClient({ authentication, credential, fetch }); + + await expectPrivateCredentialFailure( + () => client.request({ method: 'get', path: '/models' }), + credential, + ); + + expect(fetch).not.toHaveBeenCalled(); + for (const spy of spies) { + for (const argumentsList of spy.mock.calls) { + expect(JSON.stringify(argumentsList)).not.toContain(PRIVATE_CREDENTIAL); + expect(JSON.stringify(argumentsList)).not.toContain(PRIVATE_SUFFIX); + } + } + }, + ); + + test.each(authenticationModes)( + 'preserves unrelated invalid caller-header diagnostics for %s authentication', + async (authentication) => { + const callerValue = 'caller-header\nunrelated-invalid-value'; + const credential = 'valid-azure-credential'; + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = createClient({ authentication, credential, fetch, logger: createLogger() }); + + let failure: unknown; + try { + await client.request({ + method: 'get', + path: '/models', + headers: { 'x-caller': callerValue }, + }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(TypeError); + expect((failure as Error).message).toContain(callerValue); + expect((failure as Error).message).not.toBe(SAFE_ERROR); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each([ + ['SDK provider error', new OpenAIError('The real credential provider failed.')], + ['generic provider error', new Error('The real credential provider failed.')], + ] as const)( + 'preserves %s and the existing provider failure contract', + async (_description, originalFailure) => { + const tokenProvider = vi.fn(async (): Promise => { + throw originalFailure; + }); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = createClient({ + authentication: 'rotating-entra-token', + credential: 'unused-valid-credential', + tokenProvider, + fetch, + logger: createLogger(), + }); + + let failure: unknown; + try { + await client.request({ method: 'get', path: '/models' }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(OpenAIError); + if (originalFailure instanceof OpenAIError) { + expect(failure).toBe(originalFailure); + } else { + expect(failure).not.toBe(originalFailure); + expect((failure as Error & { cause?: unknown }).cause).toBe(originalFailure); + } + expect(tokenProvider).toHaveBeenCalledTimes(1); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each( + authenticationModes.flatMap((authentication) => + validCredentials.map(({ format, credential }) => ({ authentication, format, credential })), + ), + )( + 'preserves valid $format $authentication credentials and redirect behavior', + async ({ authentication, credential }) => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => + Response.json({ data: [], object: 'list' }), + ); + const tokenProvider = vi.fn(async () => credential); + const client = createClient({ + authentication, + credential, + fetch, + tokenProvider, + logger: createLogger(), + redirect: 'follow', + }); + + await client.request({ method: 'get', path: '/models' }); + + const [, request] = fetch.mock.calls[0] ?? []; + const headers = new Headers(request?.headers); + if (authentication === 'static-api-key') { + expect(headers.get('api-key')).toBe(credential); + expect(headers.has('authorization')).toBe(false); + expect(request?.redirect).toBe('manual'); + } else { + expect(headers.get('authorization')).toBe(`Bearer ${credential}`); + expect(headers.has('api-key')).toBe(false); + expect(request?.redirect).toBe('follow'); + } + expect(tokenProvider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(authenticationModes)( + 'does not validate or resolve a %s credential when bearer authentication is disabled', + async (authentication) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const fetch = vi.fn(async () => Response.json({ ok: true })); + const tokenProvider = vi.fn(async () => credential); + const client = createClient({ + authentication, + credential, + fetch, + tokenProvider, + logger: createLogger(), + }); + + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: false, adminAPIKeyAuth: false }, + headers: { authorization: null, 'api-key': null }, + }); + + expect(tokenProvider).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test('validates credentials loaded from the Azure environment', async () => { + const credential = `${PRIVATE_CREDENTIAL}\r${PRIVATE_SUFFIX}`; + vi.stubEnv('AZURE_OPENAI_API_KEY', credential); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + fetch, + maxRetries: 0, + logger: createLogger(), + }); + + await expectPrivateCredentialFailure( + () => client.request({ method: 'get', path: '/models' }), + credential, + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('rejects malformed static credentials through direct public request building', async () => { + const credential = `${PRIVATE_CREDENTIAL}\u0001${PRIVATE_SUFFIX}`; + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = createClient({ + authentication: 'static-api-key', + credential, + fetch, + logger: createLogger(), + }); + + await expectPrivateCredentialFailure( + () => client.buildRequest({ method: 'get', path: '/models' }), + credential, + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + test.each( + authenticationModes.flatMap((authentication) => + (['default', 'request'] as const).flatMap((source) => + (['api-key', 'Authorization'] as const).map((header) => ({ authentication, source, header })), + ), + ), + )( + '$authentication rejects the effective $source $header override without exposing it', + async ({ authentication, source, header }) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const fetch = vi.fn(async () => Response.json({ ok: true })); + const tokenProvider = vi.fn(async () => 'valid-entra-token'); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'valid-azure-key' } + : { azureADTokenProvider: tokenProvider }), + ...(source === 'default' ? { defaultHeaders: { [header]: credential } } : {}), + fetch, + }); + await expectPrivateCredentialFailure( + () => + client.request({ + method: 'get', + path: '/models', + ...(source === 'request' ? { headers: { [header]: credential } } : {}), + }), + credential, + ); + expect(fetch).not.toHaveBeenCalled(); + expect(tokenProvider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + }, + ); + + const bodyCredentialCases = authenticationModes.flatMap((authentication) => + (['api-key', 'Authorization'] as const).flatMap((header) => + (['chat completion', 'form body', 'undefined body'] as const).map((body) => ({ + authentication, + header, + body, + })), + ), + ); + + test.each(bodyCredentialCases)( + '$authentication protects request-level $header during $body preprocessing', + async ({ authentication, header, body }) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const provider = vi.fn(async () => 'safe-provider-token'); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + deployment: 'test-deployment', + ...(authentication === 'static-api-key' + ? { apiKey: 'safe-configured-token' } + : { azureADTokenProvider: provider }), + fetch, + maxRetries: 0, + }); + const headers = { [header]: credential }; + const operation = () => { + if (body === 'chat completion') { + return client.chat.completions.create( + { model: 'test-deployment', messages: [{ role: 'user', content: 'hello' }] }, + { headers }, + ); + } + if (body === 'form body') { + const form = new FormData(); + form.append('safe', 'payload'); + return client.request({ method: 'post', path: '/models', body: form, headers }); + } + return client.request({ method: 'post', path: '/models', body: undefined, headers }); + }; + + await expectPrivateCredentialFailure(operation, credential); + expect(fetch).not.toHaveBeenCalled(); + expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + }, + ); + + test.each( + authenticationModes.flatMap((authentication) => + (['changes value', 'throws on reuse'] as const).flatMap((behavior) => + (['replaceable', 'fixed'] as const).map((representation) => ({ + authentication, + behavior, + representation, + })), + ), + ), + )( + '$authentication reads a $behavior $representation request body getter only once', + async ({ authentication, behavior, representation }) => { + const first = { payload: 'first body representation' }; + const options: FinalRequestOptions = { method: 'post', path: '/models' }; + let reads = 0; + Object.defineProperty(options, 'body', { + configurable: representation === 'replaceable', + enumerable: true, + get() { + reads += 1; + if (reads === 1) { + return first; + } + if (behavior === 'throws on reuse') { + throw new Error('A one-shot request body cannot be read again.'); + } + return { payload: 'incorrect second representation' }; + }, + }); + const provider = vi.fn(async () => 'safe-provider-token'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'safe-configured-token' } + : { azureADTokenProvider: provider }), + fetch, + maxRetries: 0, + }); + + await client._callApiKey(); + const built = await client.buildRequest(options); + + expect(reads).toBe(1); + expect(built.req.body).toBe(JSON.stringify(first)); + expect(built.req.headers.get(authentication === 'static-api-key' ? 'api-key' : 'authorization')).toBe( + authentication === 'static-api-key' ? 'safe-configured-token' : 'Bearer safe-provider-token', + ); + expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each(authenticationModes)( + '%s dispatches the first representation from a stateful request body getter', + async (authentication) => { + const first = { payload: 'first body representation' }; + const options: FinalRequestOptions = { method: 'post', path: '/models' }; + let reads = 0; + Object.defineProperty(options, 'body', { + configurable: true, + enumerable: true, + get() { + reads += 1; + return reads === 1 ? first : { payload: 'later diagnostic representation' }; + }, + }); + const provider = vi.fn(async () => 'safe-provider-token'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'safe-configured-token' } + : { azureADTokenProvider: provider }), + fetch, + maxRetries: 0, + }); + + await client.request(options); + + expect(fetch.mock.calls[0]?.[1]?.body).toBe(JSON.stringify(first)); + expect(reads).toBe(2); + expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test('preserves custom body serialization errors after protected options have been copied', async () => { + const expected = new Error('custom request body serialization failed'); + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + headers: { 'x-safe': 'preserved' }, + }; + Object.defineProperty(options, 'body', { + configurable: true, + enumerable: true, + get() { + return { + toJSON() { + throw expected; + }, + }; + }, + }); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + maxRetries: 0, + }); + + await expect(client.buildRequest(options)).rejects.toBe(expected); + }); + + test('preserves custom authentication errors after copying a falsy protected body', async () => { + const expected = new Error('custom authentication failed'); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + maxRetries: 0, + }); + client.observeAuthenticationOptions = async () => { + throw expected; + }; + + await expect( + client.request({ + method: 'post', + path: '/models', + headers: { 'x-safe': 'preserved' }, + body: false, + }), + ).rejects.toBe(expected); + }); + + test.each(['inherited', 'non-enumerable'] as const)( + 'does not consume a request body getter omitted by an %s options copy', + async (representation) => { + const options: FinalRequestOptions = { method: 'post', path: '/models' }; + const owner = representation === 'inherited' ? Object.create(Object.getPrototypeOf(options)) : options; + if (representation === 'inherited') { + Object.setPrototypeOf(options, owner); + } + const read = vi.fn(() => { + throw new Error('An omitted request body getter must not be consumed.'); + }); + Object.defineProperty(owner, 'body', { + configurable: true, + enumerable: representation === 'inherited', + get: read, + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-configured-token', + fetch, + maxRetries: 0, + }); + + await client.request(options); + + expect(read).not.toHaveBeenCalled(); + expect(fetch.mock.calls[0]?.[1]?.body).toBeUndefined(); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('safe-configured-token'); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['data headers', 'headers accessor first', 'headers accessor last'] as const)( + 'sanitizes a throwing body getter with %s without consuming it twice', + async (representation) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const headers = { 'api-key': 'safe-request-token' }; + const options: FinalRequestOptions = { method: 'post', path: '/models' }; + const installHeaders = (): void => { + Object.defineProperty(options, 'headers', { + configurable: true, + enumerable: true, + get: () => headers, + }); + }; + if (representation === 'data headers') { + options.headers = headers; + } else if (representation === 'headers accessor first') { + installHeaders(); + } + const read = vi.fn(() => { + throw Object.assign(new Error(credential), { cause: new Error(credential) }); + }); + Object.defineProperty(options, 'body', { + configurable: true, + enumerable: true, + get: read, + }); + if (representation === 'headers accessor last') { + installHeaders(); + } + const descriptor = Object.getOwnPropertyDescriptor(options, 'body'); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + + await expectPrivateCredentialFailure(() => client.request(options), credential); + + expect(read).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertyDescriptor(options, 'body')).toEqual(descriptor); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each(['installation', 'restoration'] as const)( + 'sanitizes a body-accessor snapshot proxy %s trap', + async (phase) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const target: FinalRequestOptions = { + method: 'post', + path: '/models', + headers: { 'api-key': 'safe-request-token' }, + }; + Object.defineProperty(target, 'body', { + configurable: true, + enumerable: true, + get: () => ({ safe: true }), + }); + const descriptor = Object.getOwnPropertyDescriptor(target, 'body'); + let writes = 0; + const options = new Proxy(target, { + defineProperty(value, property, next) { + if (property !== 'body') { + return Reflect.defineProperty(value, property, next); + } + writes += 1; + if ((phase === 'installation' && writes === 1) || (phase === 'restoration' && writes === 2)) { + if (phase === 'restoration') { + Reflect.defineProperty(value, property, next); + } + throw Object.assign(new Error(credential), { cause: new Error(credential) }); + } + return Reflect.defineProperty(value, property, next); + }, + }); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + + await expectPrivateCredentialFailure(() => client.request(options), credential); + + expect(Object.getOwnPropertyDescriptor(target, 'body')).toEqual(descriptor); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + const statefulBodyCases = [ + { description: 'false', initial: false }, + { description: 'null', initial: null }, + { description: 'zero', initial: 0 }, + { description: 'an empty string', initial: '' }, + { description: 'undefined', initial: undefined }, + ] as const; + + test.each( + statefulBodyCases.flatMap(({ description, initial }) => + (['api-key', 'Authorization'] as const).flatMap((name) => + (['own', 'inherited'] as const).map((representation) => ({ + description, + initial, + name, + representation, + })), + ), + ), + )( + 'protects $name when an $representation body accessor changes from $description', + async ({ initial, name, representation }) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const headers = { [name]: credential }; + const options: FinalRequestOptions = { method: 'post', path: '/models', headers }; + const owner: object = + representation === 'own' ? options : Object.create(Object.getPrototypeOf(options)); + if (representation === 'inherited') { + Object.setPrototypeOf(options, owner); + } + let reads = 0; + Object.defineProperty(owner, 'body', { + configurable: true, + enumerable: true, + get() { + reads += 1; + return reads === 1 ? initial : { safe: true }; + }, + }); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + + await expectPrivateCredentialFailure(() => client.request(options), credential); + + expect(reads).toBe(representation === 'own' ? 1 : 0); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each( + statefulBodyCases + .filter(({ initial }) => initial !== undefined) + .flatMap(({ description, initial }) => + authenticationModes.flatMap((authentication) => + (['api-key', 'Authorization'] as const).flatMap((name) => + (['nearby', 'deep'] as const).map((depth) => ({ + authentication, + depth, + description, + initial, + name, + })), + ), + ), + ), + )( + '$authentication protects $name when a $depth inherited body accessor materializes a body after $description', + async ({ authentication, depth, initial, name }) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + headers: { [name]: credential }, + }; + const owner = Object.create(Object.getPrototypeOf(options)) as object; + let prototype = owner; + const ancestorDepth = depth === 'deep' ? 40 : 1; + for (let index = 0; index < ancestorDepth; index += 1) { + prototype = Object.create(prototype) as object; + } + Object.setPrototypeOf(options, prototype); + + let reads = 0; + Object.defineProperty(owner, 'body', { + configurable: true, + enumerable: true, + get() { + reads += 1; + Object.defineProperty(options, 'body', { + configurable: true, + enumerable: true, + get() { + reads += 1; + return { safe: true }; + }, + }); + return initial; + }, + }); + + const provider = vi.fn(async () => 'safe-provider-token'); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'safe-configured-token' } + : { azureADTokenProvider: provider }), + fetch, + maxRetries: 0, + }); + + await expectPrivateCredentialFailure(() => client.request(options), credential); + + expect(reads).toBe(0); + expect(fetch).not.toHaveBeenCalled(); + expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + }, + ); + + test.each( + (['descriptor', 'prototype'] as const).flatMap((operation) => + (['api-key', 'Authorization'] as const).map((name) => ({ operation, name })), + ), + )( + 'sanitizes $name credentials thrown by inherited body $operation inspection', + async ({ operation, name }) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const fail = vi.fn(() => { + throw Object.assign(new Error(credential), { cause: new Error(credential) }); + }); + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + headers: { [name]: credential }, + }; + const target = Object.create(Object.getPrototypeOf(options)) as object; + const hostile = new Proxy(target, { + getOwnPropertyDescriptor(value, property) { + if (operation === 'descriptor' && property === 'body') { + return fail(); + } + return Reflect.getOwnPropertyDescriptor(value, property); + }, + getPrototypeOf(value) { + if (operation === 'prototype') { + return fail(); + } + return Reflect.getPrototypeOf(value); + }, + }); + Object.setPrototypeOf(options, hostile); + + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-configured-token', + fetch, + maxRetries: 0, + }); + + await expectPrivateCredentialFailure(() => client.request(options), credential); + + expect(fail).toHaveBeenCalledTimes(1); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each( + (['descriptor', 'value', 'membership'] as const).flatMap((operation) => + (['/models', '/chat/completions'] as const).flatMap((route) => + (['api-key', 'Authorization'] as const).map((header) => ({ operation, route, header })), + ), + ), + )( + 'sanitizes $header credentials thrown by own body $operation proxy inspection on $route', + async ({ operation, route, header }) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const fail = vi.fn(() => { + throw Object.assign(new Error(credential), { cause: new Error(credential) }); + }); + const target: FinalRequestOptions = { + method: 'post', + path: route, + body: operation === 'membership' ? undefined : { model: 'safe-model', safe: true }, + headers: { [header]: 'safe-request-token' }, + }; + const options = new Proxy(target, { + getOwnPropertyDescriptor(value, property) { + return property === 'body' && operation === 'descriptor' + ? fail() + : Reflect.getOwnPropertyDescriptor(value, property); + }, + get(value, property, receiver) { + return property === 'body' && operation === 'value' + ? fail() + : Reflect.get(value, property, receiver); + }, + has(value, property) { + return property === 'body' && operation === 'membership' ? fail() : Reflect.has(value, property); + }, + }); + const logger = createLogger(); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + logger, + logLevel: 'debug', + maxRetries: 0, + }); + + await expectPrivateCredentialFailure(() => client.request(options), credential); + + expect(fail).toHaveBeenCalledTimes(1); + expect(fetch).not.toHaveBeenCalled(); + expectPrivateLogs(logger, credential); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'protects $name when a body accessor replaces itself with a truthy value', + async (name) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + headers: { [name]: credential }, + }; + let reads = 0; + Object.defineProperty(options, 'body', { + configurable: true, + enumerable: true, + get() { + reads += 1; + Object.defineProperty(options, 'body', { + configurable: true, + enumerable: true, + value: { safe: true }, + }); + return false; + }, + }); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + + await expectPrivateCredentialFailure(() => client.request(options), credential); + + expect(reads).toBe(1); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'snapshots the effective %s override once across body preprocessing and final authentication', + async (name) => { + const malformed = `${PRIVATE_CREDENTIAL}\r${PRIVATE_SUFFIX}`; + let reads = 0; + const headers: Record = {}; + Object.defineProperty(headers, name, { + enumerable: true, + get() { + reads += 1; + return reads === 1 ? 'safe-final-token' : malformed; + }, + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-configured-token', + fetch, + maxRetries: 0, + }); + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { safe: 'payload' }, + headers, + }; + + await client.request(options); + + expect(reads).toBe(1); + expect(options.headers).toBe(headers); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(name)).toBe('safe-final-token'); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each( + (['api-key', 'Authorization'] as const).flatMap((name) => [ + { name, body: { safe: 'payload' }, description: 'a JSON body' }, + { name, body: undefined, description: 'an explicitly undefined body' }, + ]), + )( + 'snapshots accessor-backed $name request headers once before preprocessing $description', + async ({ name, body }) => { + const malformed = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const firstHeaders = { [name]: 'safe-first-token', 'x-custom': 'preserved' }; + const unsafeHeaders = { [name]: malformed }; + let reads = 0; + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body, + get headers() { + reads += 1; + return reads === 1 ? firstHeaders : unsafeHeaders; + }, + }; + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + const state = new WeakMap(); + const marker = { source: 'accessor request options' }; + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.observePreparedOptions = (prepared) => state.set(prepared, marker); + client.observeProtectedHookOptions = (_hook, received) => { + expect(received).toBe(options); + expect(state.get(received)).toBe(marker); + }; + + await client.request(options); + + expect(reads).toBe(1); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(descriptor); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(name)).toBe('safe-first-token'); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('x-custom')).toBe('preserved'); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each( + (['own', 'inherited'] as const).flatMap((representation) => + (['installation', 'restoration'] as const).flatMap((phase) => + (['throws', 'forwards then throws'] as const).flatMap((behavior) => + (['api-key', 'Authorization'] as const).map((header) => ({ + representation, + phase, + behavior, + header, + })), + ), + ), + ), + )( + 'sanitizes $header when an $representation snapshot $phase trap $behavior', + async ({ representation, phase, behavior, header }) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const suppliedHeaders = { [header]: 'safe-request-token', 'x-custom': 'preserved' }; + let reads = 0; + const target: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { safe: true }, + get headers() { + reads += 1; + return suppliedHeaders; + }, + }; + if (representation === 'inherited') { + const prototype = Object.create(Object.getPrototypeOf(target)) as object; + const descriptor = Object.getOwnPropertyDescriptor(target, 'headers'); + if (descriptor === undefined) { + throw new Error('Expected the request headers accessor to be configurable.'); + } + Object.defineProperty(prototype, 'headers', descriptor); + Reflect.deleteProperty(target, 'headers'); + Object.setPrototypeOf(target, prototype); + } + const original = Object.getOwnPropertyDescriptor(target, 'headers'); + const fail = vi.fn(() => { + throw Object.assign(new Error(credential), { cause: new Error(credential) }); + }); + let definitions = 0; + let failed = false; + const options = new Proxy(target, { + defineProperty(value, property, descriptor) { + if (property === 'headers') { + definitions += 1; + const shouldFail = phase === 'installation' || (representation === 'own' && definitions === 2); + if (shouldFail && !failed) { + failed = true; + if (behavior === 'forwards then throws') { + Reflect.defineProperty(value, property, descriptor); + } + return fail(); + } + } + return Reflect.defineProperty(value, property, descriptor); + }, + deleteProperty(value, property) { + if ( + property === 'headers' && + representation === 'inherited' && + phase === 'restoration' && + !failed + ) { + failed = true; + if (behavior === 'forwards then throws') { + Reflect.deleteProperty(value, property); + } + return fail(); + } + return Reflect.deleteProperty(value, property); + }, + }); + const logger = createLogger(); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + logger, + logLevel: 'debug', + maxRetries: 0, + }); + + await expectPrivateCredentialFailure(() => client.request(options), credential); + + expect(fail).toHaveBeenCalledTimes(1); + expect(fetch).not.toHaveBeenCalled(); + expectPrivateLogs(logger, credential); + expect(Object.getOwnPropertyDescriptor(target, 'headers')).toEqual(original); + expect(options.headers).toBe(suppliedHeaders); + + await client.request(options); + + expect(reads).toBe(3); + expect(Object.getOwnPropertyDescriptor(target, 'headers')).toEqual(original); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(header)).toBe('safe-request-token'); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('x-custom')).toBe('preserved'); + expect(fetch).toHaveBeenCalledTimes(1); + expectPrivateLogs(logger, credential); + }, + ); + + test.each([ + 'descriptor lookup', + 'prototype lookup', + 'extensibility inspection', + 'descriptor restoration', + ] as const)('sanitizes an inherited header snapshot proxy during %s', async (operation) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + let reads = 0; + const prototype = { + get headers() { + reads += 1; + return { 'api-key': 'safe-request-token' }; + }, + }; + const target = Object.assign(Object.create(prototype) as FinalRequestOptions, { + method: 'post' as const, + path: '/models', + body: { safe: true }, + }); + const fail = vi.fn(() => { + throw Object.assign(new Error(credential), { cause: new Error(credential) }); + }); + let descriptors = 0; + let failed = false; + const options = new Proxy(target, { + getOwnPropertyDescriptor(value, property) { + if (property === 'headers') { + descriptors += 1; + if ( + !failed && + (operation === 'descriptor lookup' || + (operation === 'descriptor restoration' && descriptors === 4)) + ) { + failed = true; + return fail(); + } + } + return Reflect.getOwnPropertyDescriptor(value, property); + }, + getPrototypeOf(value) { + if (operation === 'prototype lookup' && !failed) { + failed = true; + return fail(); + } + return Reflect.getPrototypeOf(value); + }, + isExtensible(value) { + if (operation === 'extensibility inspection' && !failed) { + failed = true; + return fail(); + } + return Reflect.isExtensible(value); + }, + }); + const logger = createLogger(); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + logger, + logLevel: 'debug', + maxRetries: 0, + }); + + await expectPrivateCredentialFailure(() => client.request(options), credential); + + expect(fail).toHaveBeenCalledTimes(1); + expect(fetch).not.toHaveBeenCalled(); + expectPrivateLogs(logger, credential); + + await client.request(options); + + expect(reads).toBe(operation === 'descriptor restoration' ? 2 : 1); + expect(Object.getOwnPropertyDescriptor(target, 'headers')).toBeUndefined(); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('safe-request-token'); + expect(fetch).toHaveBeenCalledTimes(1); + expectPrivateLogs(logger, credential); + }); + + test.each( + (['own', 'inherited'] as const).flatMap((representation) => + (['descriptor', 'value', 'enumeration'] as const).map((operation) => ({ representation, operation })), + ), + )( + 'sanitizes an $representation header snapshot proxy during request-option $operation copying', + async ({ representation, operation }) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const source = { + get headers() { + return { 'api-key': 'safe-request-token' }; + }, + }; + const target = Object.assign( + representation === 'own' ? source : (Object.create(source) as FinalRequestOptions), + { method: 'post' as const, path: '/models', body: { safe: true } }, + ); + const original = Object.getOwnPropertyDescriptor(target, 'headers'); + const fail = vi.fn(() => { + throw Object.assign(new Error(credential), { cause: new Error(credential) }); + }); + let installed = false; + let failed = false; + const options = new Proxy(target, { + defineProperty(value, property, descriptor) { + const defined = Reflect.defineProperty(value, property, descriptor); + if (property === 'headers') { + installed = true; + } + return defined; + }, + getOwnPropertyDescriptor(value, property) { + if (installed && !failed && property === 'headers' && operation === 'descriptor') { + failed = true; + return fail(); + } + return Reflect.getOwnPropertyDescriptor(value, property); + }, + get(value, property, receiver) { + if (installed && !failed && property === 'headers' && operation === 'value') { + failed = true; + return fail(); + } + return Reflect.get(value, property, receiver); + }, + ownKeys(value) { + if (installed && !failed && operation === 'enumeration') { + failed = true; + return fail(); + } + return Reflect.ownKeys(value); + }, + }); + const logger = createLogger(); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + logger, + logLevel: 'debug', + maxRetries: 0, + }); + + await expectPrivateCredentialFailure(() => client.request(options), credential); + + expect(fail).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertyDescriptor(target, 'headers')).toEqual(original); + expect(fetch).not.toHaveBeenCalled(); + expectPrivateLogs(logger, credential); + }, + ); + + test('preserves unrelated caller errors after accessor-backed request options have been copied', async () => { + const failure = new Error('custom request body serialization failed'); + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { + toJSON() { + throw failure; + }, + }, + get headers() { + return { 'api-key': 'safe-request-token' }; + }, + }; + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + + await expect(client.request(options)).rejects.toBe(failure); + + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(descriptor); + expect(fetch).not.toHaveBeenCalled(); + }); + + test.each(['api-key', 'Authorization'] as const)( + 'restores accessor-backed request headers after rejecting a malformed %s snapshot', + async (name) => { + const malformed = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + let reads = 0; + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { safe: 'payload' }, + get headers() { + reads += 1; + return { [name]: reads === 1 ? malformed : 'safe-second-token' }; + }, + }; + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + + await expectPrivateCredentialFailure(() => client.request(options), malformed); + + expect(reads).toBe(1); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(descriptor); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test('sanitizes an unsafe nonconfigurable request headers accessor after reading it once', async () => { + const malformed = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + let reads = 0; + const options: FinalRequestOptions = { method: 'post', path: '/models', body: { safe: true } }; + Object.defineProperty(options, 'headers', { + enumerable: true, + get() { + reads += 1; + return { 'api-key': malformed }; + }, + }); + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + + await expectPrivateCredentialFailure(() => client.request(options), malformed); + + expect(reads).toBe(1); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(descriptor); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('preserves protected-hook mutations of accessor-backed request headers', async () => { + let reads = 0; + let writes = 0; + let headers = { 'api-key': 'first-token' }; + const replacement = { 'api-key': 'hook-replacement-token' }; + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { safe: true }, + get headers() { + reads += 1; + return headers; + }, + set headers(value) { + writes += 1; + if (!value || Array.isArray(value) || value instanceof Headers) { + throw new Error('Expected replacement request header record.'); + } + headers = value as { 'api-key': string }; + }, + }; + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.observeProtectedHookOptions = (hook, received) => { + if (hook === 'auth') { + received.headers = replacement; + } + }; + + await client.request(options); + + expect(reads).toBe(2); + expect(writes).toBe(1); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(descriptor); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('hook-replacement-token'); + }); + + test.each( + (['own', 'inherited'] as const).flatMap((representation) => + (['api-key', 'Authorization'] as const).map((name) => ({ representation, name })), + ), + )( + 'dispatches the effective $representation $name request-header accessor setter value', + async ({ representation, name }) => { + let reads = 0; + let writes = 0; + let effective: Record = { [name]: 'initial-token' }; + const owner = { + get headers() { + reads += 1; + if (reads > 2) { + throw new Error(PRIVATE_CREDENTIAL); + } + return effective; + }, + set headers(value: Record) { + writes += 1; + effective = { [name]: String(value[name]).toLowerCase(), 'x-setter': 'normalized' }; + }, + }; + const options: FinalRequestOptions = Object.assign( + representation === 'own' ? owner : Object.create(owner), + { method: 'post' as const, path: '/models', body: { safe: true } }, + ); + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.observeProtectedHookOptions = (hook, received) => { + if (hook === 'auth') { + received.headers = { [name]: 'CASE-SENSITIVE-TOKEN' }; + } + }; + + await client.request(options); + + expect(reads).toBe(2); + expect(writes).toBe(1); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(descriptor); + const dispatched = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(dispatched.get(name)).toBe('case-sensitive-token'); + expect(dispatched.get('x-setter')).toBe('normalized'); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['throws', 'returns an invalid credential'] as const)( + 'sanitizes a request-header accessor that %s after its setter runs', + async (behavior) => { + const malformed = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + let reads = 0; + let assigned = false; + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { safe: true }, + get headers() { + reads += 1; + if (assigned && behavior === 'throws') { + throw Object.assign(new Error(malformed), { cause: new Error(malformed) }); + } + return { 'api-key': assigned ? malformed : 'safe-initial-token' }; + }, + set headers(_value) { + assigned = true; + }, + }; + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + const logger = createLogger(); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + logger, + logLevel: 'debug', + maxRetries: 0, + }); + client.observeProtectedHookOptions = (hook, received) => { + if (hook === 'auth') { + received.headers = { 'api-key': 'safe-supplied-token' }; + } + }; + + await expectPrivateCredentialFailure(() => client.request(options), malformed); + + expect(reads).toBe(2); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(descriptor); + expect(fetch).not.toHaveBeenCalled(); + expectPrivateLogs(logger, malformed); + }, + ); + + test.each([ + ['the same Azure client', false], + ['different Azure clients', true], + ] as const)( + 'isolates rotating request-header accessors when %s share request options', + async (_description, differentClients) => { + const firstClient = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-first-client-token', + maxRetries: 0, + }); + const secondClient = differentClients + ? new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-second-client-token', + maxRetries: 0, + }) + : firstClient; + const snapshots = [ + { 'api-key': 'tenant-a-token', 'x-custom': 'preserved' }, + { 'api-key': 'tenant-b-token', 'x-custom': 'preserved' }, + ]; + let reads = 0; + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { shared: true }, + get headers() { + const snapshot = snapshots[reads]; + reads += 1; + return snapshot; + }, + }; + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + const observed: FinalRequestOptions[] = []; + const releases = new Set(); + const pauseAuthentication = async (received: FinalRequestOptions) => { + const index = observed.length; + observed.push(received); + await vi.waitFor(() => expect(releases.has(index)).toBe(true), { interval: 1 }); + }; + firstClient.observeAuthenticationOptions = pauseAuthentication; + secondClient.observeAuthenticationOptions = pauseAuthentication; + + const first = firstClient.buildRequest(options); + const second = secondClient.buildRequest(options); + expect(observed).toEqual([options, options]); + expect(reads).toBe(2); + + releases.add(0); + const firstBuilt = await first; + releases.add(1); + const secondBuilt = await second; + + expect(firstBuilt.req.headers.get('api-key')).toBe('tenant-a-token'); + expect(secondBuilt.req.headers.get('api-key')).toBe('tenant-b-token'); + expect(firstBuilt.req.headers.get('x-custom')).toBe('preserved'); + expect(secondBuilt.req.headers.get('x-custom')).toBe('preserved'); + expect(reads).toBe(2); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(descriptor); + }, + ); + + test('keeps shared request options unchanged across overlapping private authentication waits', async () => { + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + }); + let credential = 'first-token'; + let reads = 0; + const rawHeaders = { 'api-key': 'first-token', 'x-custom': 'preserved' }; + Object.defineProperty(rawHeaders, 'api-key', { + enumerable: true, + get() { + reads += 1; + return credential; + }, + set(value: string) { + credential = value; + }, + }); + const body = { safe: 'payload' }; + const metadata = { source: 'shared request options' }; + const { signal } = new AbortController(); + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body, + headers: rawHeaders, + __metadata: metadata, + signal, + }; + const observed: FinalRequestOptions[] = []; + const releases = new Set(); + client.observeAuthenticationOptions = async (received) => { + const index = observed.length; + observed.push(received); + await vi.waitFor(() => expect(releases.has(index)).toBe(true), { interval: 1 }); + }; + + const first = client.buildRequest(options); + const duringFirst = options.headers; + const second = client.buildRequest(options); + const duringSecond = options.headers; + expect(observed).toHaveLength(2); + releases.add(0); + const firstBuilt = await first; + const whileSecondWaits = options.headers; + releases.add(1); + const secondBuilt = await second; + + expect(duringFirst).toBe(rawHeaders); + expect(duringSecond).toBe(rawHeaders); + expect(whileSecondWaits).toBe(rawHeaders); + expect(options.headers).toBe(rawHeaders); + expect(observed).toHaveLength(2); + expect(reads).toBe(2); + expect(observed[0]).toBe(options); + expect(observed[1]).toBe(options); + expect(observed.every((received) => received.__metadata === metadata)).toBe(true); + expect(observed.every((received) => received.body === body && received.signal === signal)).toBe(true); + expect(firstBuilt.req.headers.get('api-key')).toBe('first-token'); + expect(secondBuilt.req.headers.get('api-key')).toBe('first-token'); + + client.observeAuthenticationOptions = undefined; + rawHeaders['api-key'] = 'updated-token'; + const reused = await client.buildRequest(options); + expect(reused.req.headers.get('api-key')).toBe('updated-token'); + expect(reads).toBe(3); + expect(options.headers).toBe(rawHeaders); + }); + + test.each([ + ['static authentication', 'static-api-key', false] as const, + ['rotating bearer authentication', 'rotating-entra-token', false] as const, + ['rotating administrator authentication', 'rotating-entra-token', true] as const, + ])( + 'preserves prepareOptions WeakMap identity through every protected %s hook', + async (_description, authentication, admin) => { + const provider = vi.fn(async () => 'configured-provider-token'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'configured-static-token' } + : { azureADTokenProvider: provider, adminAPIKey: 'configured-admin-token' }), + fetch, + maxRetries: 0, + }); + const state = new WeakMap(); + const marker = { secret: 'protected per-request state' }; + const observed: string[] = []; + let prepared: FinalRequestOptions | undefined; + client.observePreparedOptions = (options) => { + prepared = options; + state.set(options, marker); + }; + client.observeProtectedHookOptions = (hook, options) => { + observed.push(hook); + expect(options).toBe(prepared); + expect(state.get(options)).toBe(marker); + }; + const headers = { 'x-custom': 'preserved' }; + + await client.request({ + method: 'post', + path: '/models', + body: { safe: 'payload' }, + headers, + __security: { bearerAuth: true, adminAPIKeyAuth: admin }, + }); + + const expectedHooks = ['auth']; + if (authentication === 'rotating-entra-token') { + expectedHooks.push('bearer'); + } + if (admin) { + expectedHooks.push('admin'); + } + expect(observed).toEqual([...expectedHooks, 'request']); + expect(prepared?.headers).toBe(headers); + expect(fetch).toHaveBeenCalledTimes(1); + expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + }, + ); + + test('keeps the Azure body marker reserved across a reentrant query-header merge', async () => { + const malformed = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const headers = { 'api-key': malformed }; + let nestedFailure: unknown; + let queryReads = 0; + const query = { + get tenant() { + queryReads += 1; + try { + buildHeaders([headers]); + } catch (error) { + nestedFailure = error; + } + return 'safe-tenant'; + }, + }; + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + + await expectPrivateCredentialFailure( + () => client.request({ method: 'post', path: '/models', body: { safe: true }, headers, query }), + malformed, + ); + + expect(queryReads).toBe(1); + expect(nestedFailure).toBeInstanceOf(TypeError); + expect((nestedFailure as Error).message).toBe(SAFE_ERROR); + expect((nestedFailure as Error).message).not.toContain(PRIVATE_CREDENTIAL); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('never leaks an Azure body marker into reentrant non-Azure processing of the same raw object', async () => { + const malformed = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + let reads = 0; + let nestedFailure: unknown; + const headers: Record = {}; + Object.defineProperty(headers, 'api-key', { + enumerable: true, + get() { + reads += 1; + if (reads === 1) { + try { + buildHeaders([headers]); + } catch (error) { + nestedFailure = error; + } + return 'safe-outer-token'; + } + return malformed; + }, + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + + await client.request({ method: 'post', path: '/models', body: { safe: true }, headers }); + + expect(nestedFailure).toBeInstanceOf(TypeError); + expect((nestedFailure as Error).message).not.toBe(SAFE_ERROR); + expect((nestedFailure as Error).message).toContain(PRIVATE_CREDENTIAL); + expect(reads).toBe(2); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('safe-outer-token'); + }); + + test('isolates a nested Azure request started while snapshotting an outer credential getter', async () => { + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + maxRetries: 0, + }); + let reads = 0; + let nested: ReturnType | undefined; + const headers: Record = {}; + Object.defineProperty(headers, 'api-key', { + enumerable: true, + get() { + reads += 1; + if (reads === 1) { + nested = client.buildRequest({ + method: 'post', + path: '/models', + body: { nested: true }, + headers: { 'api-key': 'nested-token' }, + }); + return 'tenant-a-token'; + } + return 'tenant-b-token'; + }, + }); + + const outer = client.buildRequest({ + method: 'post', + path: '/models', + body: { outer: true }, + headers, + }); + if (!nested) { + throw new Error('Expected the outer credential getter to start the nested request.'); + } + const [outerBuilt, nestedBuilt] = await Promise.all([outer, nested]); + + expect(outerBuilt.req.headers.get('api-key')).toBe('tenant-a-token'); + expect(nestedBuilt.req.headers.get('api-key')).toBe('nested-token'); + expect(reads).toBe(1); + expect(Object.getOwnPropertyDescriptor(client, 'authHeaders')).toBeUndefined(); + }); + + test('isolates concurrent body snapshots for distinct mutable raw header objects', async () => { + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + }); + const firstHeaders = { 'api-key': 'first-token' }; + const secondHeaders = { 'api-key': 'second-token' }; + const firstOptions: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { first: true }, + headers: firstHeaders, + }; + const secondOptions: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { second: true }, + headers: secondHeaders, + }; + const observed: FinalRequestOptions[] = []; + let released = false; + client.observeAuthenticationOptions = async (received) => { + observed.push(received); + await vi.waitFor(() => expect(released).toBe(true), { interval: 1 }); + }; + + const first = client.buildRequest(firstOptions); + const second = client.buildRequest(secondOptions); + expect(observed).toHaveLength(2); + expect(firstOptions.headers).toBe(firstHeaders); + expect(secondOptions.headers).toBe(secondHeaders); + released = true; + const [firstBuilt, secondBuilt] = await Promise.all([first, second]); + + expect(firstBuilt.req.headers.get('api-key')).toBe('first-token'); + expect(secondBuilt.req.headers.get('api-key')).toBe('second-token'); + expect(firstOptions.headers).toBe(firstHeaders); + expect(secondOptions.headers).toBe(secondHeaders); + }); + + test.each([ + ['the same Azure client', false], + ['different Azure clients', true], + ] as const)( + 'isolates overlapping tenant credentials in one mutable header record across %s', + async (_description, differentClients) => { + const fetch = vi.fn(async () => Response.json({ ok: true })); + const firstClient = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-first-client-token', + fetch, + maxRetries: 0, + }); + const secondClient = differentClients + ? new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-second-client-token', + fetch, + maxRetries: 0, + }) + : firstClient; + const sharedHeaders = { 'api-key': 'tenant-a-token', 'x-custom': 'preserved' }; + const firstOptions: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { tenant: 'a' }, + headers: sharedHeaders, + }; + const secondOptions: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { tenant: 'b' }, + headers: sharedHeaders, + }; + const observed = new Set(); + const releases = new Set(); + const pauseAuthentication = async (options: FinalRequestOptions) => { + observed.add(options); + await vi.waitFor(() => expect(releases.has(options)).toBe(true), { interval: 1 }); + }; + firstClient.observeAuthenticationOptions = pauseAuthentication; + secondClient.observeAuthenticationOptions = pauseAuthentication; + + const first = firstClient.buildRequest(firstOptions); + sharedHeaders['api-key'] = 'tenant-b-token'; + const second = secondClient.buildRequest(secondOptions); + expect(observed.size).toBe(2); + + releases.add(secondOptions); + const secondBuilt = await second; + releases.add(firstOptions); + const firstBuilt = await first; + + expect(firstBuilt.req.headers.get('api-key')).toBe('tenant-a-token'); + expect(secondBuilt.req.headers.get('api-key')).toBe('tenant-b-token'); + expect(firstBuilt.req.headers.get('x-custom')).toBe('preserved'); + expect(secondBuilt.req.headers.get('x-custom')).toBe('preserved'); + expect(firstOptions.headers).toBe(sharedHeaders); + expect(secondOptions.headers).toBe(sharedHeaders); + expect(sharedHeaders['api-key']).toBe('tenant-b-token'); + }, + ); + + test.each(['genuine', 'spread clone', 'assigned clone'] as const)( + 'isolates overlapping requests when a protected hook reuses the same %s authentication carrier', + async (representation) => { + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + maxRetries: 0, + }); + const genuine = buildAzureAuthenticationHeaders([['api-key', 'configured-cached-token']]); + if (representation === 'spread clone') { + client.reusedAuthenticationCarrier = { ...genuine }; + } else if (representation === 'assigned clone') { + const copied = {}; + client.reusedAuthenticationCarrier = Object.assign(copied, genuine); + } else { + client.reusedAuthenticationCarrier = genuine; + } + const headers = { 'api-key': 'tenant-a-token' }; + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { shared: true }, + headers, + }; + + const first = client.buildRequest(options); + headers['api-key'] = 'tenant-b-token'; + const second = client.buildRequest(options); + const [firstBuilt, secondBuilt] = await Promise.all([first, second]); + + expect(firstBuilt.req.headers.get('api-key')).toBe('tenant-a-token'); + expect(secondBuilt.req.headers.get('api-key')).toBe('tenant-b-token'); + expect(options.headers).toBe(headers); + }, + ); + + test.each([ + ['the same Azure client', false], + ['different Azure clients', true], + ] as const)( + 'isolates mutated tenant credentials when %s concurrently reuse the same request options', + async (_description, differentClients) => { + const firstClient = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-first-client-token', + maxRetries: 0, + }); + const secondClient = differentClients + ? new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-second-client-token', + maxRetries: 0, + }) + : firstClient; + const headers = { 'api-key': 'tenant-a-token' }; + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { shared: true }, + headers, + }; + const observed: FinalRequestOptions[] = []; + const releases = new Set(); + const pauseAuthentication = async (received: FinalRequestOptions) => { + const index = observed.length; + observed.push(received); + await vi.waitFor(() => expect(releases.has(index)).toBe(true), { interval: 1 }); + }; + firstClient.observeAuthenticationOptions = pauseAuthentication; + secondClient.observeAuthenticationOptions = pauseAuthentication; + + const first = firstClient.buildRequest(options); + headers['api-key'] = 'tenant-b-token'; + const second = secondClient.buildRequest(options); + expect(observed).toEqual([options, options]); + + releases.add(1); + const secondBuilt = await second; + releases.add(0); + const firstBuilt = await first; + + expect(firstBuilt.req.headers.get('api-key')).toBe('tenant-a-token'); + expect(secondBuilt.req.headers.get('api-key')).toBe('tenant-b-token'); + expect(options.headers).toBe(headers); + expect(headers['api-key']).toBe('tenant-b-token'); + }, + ); + + test.each([ + ['a configurable read-only own hook', true, false, false], + ['a nonconfigurable writable own hook', false, true, false], + ['a nonextensible configurable own hook', true, false, true], + ['a nonextensible nonconfigurable writable own hook', false, true, true], + ] as const)( + 'restores the exact authentication descriptor for %s', + async (_description, configurable, writable, preventExtensions) => { + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + maxRetries: 0, + }); + Object.defineProperty(client, 'authHeaders', { + configurable, + enumerable: true, + value: Object.getOwnPropertyDescriptor(ProtectedHookAzure.prototype, 'authHeaders')?.value, + writable, + }); + if (preventExtensions) { + Object.preventExtensions(client); + } + const descriptor = Object.getOwnPropertyDescriptor(client, 'authHeaders'); + + const built = await client.buildRequest({ + method: 'post', + path: '/models', + body: { safe: true }, + headers: { 'api-key': 'request-token' }, + }); + + expect(built.req.headers.get('api-key')).toBe('request-token'); + expect(Object.getOwnPropertyDescriptor(client, 'authHeaders')).toEqual(descriptor); + }, + ); + + test.each(['nonextensible inherited hook', 'nonconfigurable read-only own hook'] as const)( + 'fails closed before reading credentials when authentication protection cannot replace a %s', + async (representation) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + maxRetries: 0, + }); + if (representation === 'nonextensible inherited hook') { + Object.preventExtensions(client); + } else { + Object.defineProperty(client, 'authHeaders', { + configurable: false, + value: Object.getOwnPropertyDescriptor(ProtectedHookAzure.prototype, 'authHeaders')?.value, + writable: false, + }); + } + const descriptor = Object.getOwnPropertyDescriptor(client, 'authHeaders'); + + await expectPrivateCredentialFailure( + () => + client.buildRequest({ + method: 'post', + path: '/models', + body: { safe: true }, + headers: { 'api-key': credential }, + }), + credential, + ); + + expect(Object.getOwnPropertyDescriptor(client, 'authHeaders')).toEqual(descriptor); + }, + ); + + test('preserves an intentional protected-hook authentication method replacement', async () => { + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + maxRetries: 0, + }); + const replacement = Object.getOwnPropertyDescriptor(ProtectedHookAzure.prototype, 'authHeaders')?.value; + client.observeProtectedHookOptions = (hook) => { + if (hook === 'auth') { + expect(Reflect.get(client, 'authHeaders')).toBe(replacement); + expect(Object.getOwnPropertyDescriptor(client, 'authHeaders')).toBeUndefined(); + Object.defineProperty(client, 'authHeaders', { + configurable: true, + enumerable: true, + value: replacement, + writable: false, + }); + } + }; + + const built = await client.buildRequest({ + method: 'post', + path: '/models', + body: { safe: true }, + headers: { 'api-key': 'request-token' }, + }); + + expect(built.req.headers.get('api-key')).toBe('request-token'); + expect(Object.getOwnPropertyDescriptor(client, 'authHeaders')).toEqual({ + configurable: true, + enumerable: true, + value: replacement, + writable: false, + }); + }); + + test('releases failed private body snapshots before the same caller headers are reused', async () => { + const malformed = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const headers = { 'api-key': malformed }; + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + }); + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { safe: true }, + headers, + }; + + await expectPrivateCredentialFailure(() => client.buildRequest(options), malformed); + expect(options.headers).toBe(headers); + headers['api-key'] = 'safe-reused-token'; + const reused = await client.buildRequest(options); + expect(reused.req.headers.get('api-key')).toBe('safe-reused-token'); + expect(options.headers).toBe(headers); + }); + + test.each( + authenticationModes.flatMap((authentication) => + (['valid', 'null'] as const).map((override) => ({ authentication, override })), + ), + )( + '$authentication accepts a malformed configured credential replaced by a $override override', + async ({ authentication, override }) => { + const configured = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const tokenProvider = vi.fn(async () => configured); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: configured } + : { azureADTokenProvider: tokenProvider }), + fetch, + }); + const name = authentication === 'static-api-key' ? 'API-KEY' : 'AUTHORIZATION'; + const replacement = override === 'null' ? null : 'safe-replacement'; + await client.request({ method: 'get', path: '/models', headers: { [name]: replacement } }); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(name)).toBe(replacement); + expect(fetch).toHaveBeenCalledTimes(1); + expect(tokenProvider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + }, + ); + + test.each(authenticationModes)( + '%s preserves case-insensitive last-write authentication header precedence', + async (authentication) => { + const configured = 'safe-configured-credential'; + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: configured } + : { azureADTokenProvider: async () => configured }), + fetch, + }); + const name = authentication === 'static-api-key' ? 'api-key' : 'authorization'; + await client.request({ + method: 'get', + path: '/models', + headers: { + [name.toUpperCase()]: `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`, + [name]: 'safe-final-credential', + }, + }); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(name)).toBe('safe-final-credential'); + }, + ); + + test.each(['valid', 'null'] as const)( + 'does not append an invalid default credential superseded by a %s request override', + async (override) => { + const unsafe = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-configured-key', + defaultHeaders: { 'API-KEY': unsafe }, + fetch, + }); + const replacement = override === 'null' ? null : 'safe-final-key'; + await client.request({ + method: 'get', + path: '/models', + headers: { 'api-key': replacement }, + }); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe(replacement); + }, + ); + + test.each([ + { + name: 'duplicate tuple values', + headers: [ + ['Authorization', 'safe-first'], + ['authorization', `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`], + ], + }, + { + name: 'multiple object values', + headers: { + Authorization: ['safe-first', `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`], + }, + }, + ])('rejects unsafe retained $name without invoking native header diagnostics', async ({ headers }) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = createClient({ + authentication: 'static-api-key', + credential: 'safe-key', + fetch, + }); + await expectPrivateCredentialFailure( + () => client.request({ method: 'get', path: '/models', headers }), + credential, + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('preserves a valid Headers default and a case-insensitive request replacement', async () => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-configured-key', + defaultHeaders: new Headers({ 'API-KEY': 'safe-default-key' }), + fetch, + }); + await client.request({ + method: 'get', + path: '/models', + headers: { 'api-KEY': 'safe-final-key' }, + }); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('safe-final-key'); + }); + + test.each(authenticationModes)( + '%s preserves the existing explicitly enabled admin authentication precedence', + async (authentication) => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const provider = vi.fn(async () => 'safe-provider-token'); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'safe-static-key' } + : { azureADTokenProvider: provider }), + adminAPIKey: 'safe-admin-key', + fetch, + }); + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: true, adminAPIKeyAuth: true }, + }); + const headers = new Headers(fetch.mock.calls[0]?.[1]?.headers); + if (authentication === 'static-api-key') { + expect(headers.get('api-key')).toBe('safe-static-key'); + expect(headers.has('authorization')).toBe(false); + } else { + expect(headers.get('authorization')).toBe('Bearer safe-admin-key'); + expect(headers.has('api-key')).toBe(false); + } + expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + }, + ); + + test('sanitizes explicit credential overrides without resolving a disabled provider', async () => { + const unsafe = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const provider = vi.fn(async () => 'unused-provider-token'); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + azureADTokenProvider: provider, + fetch, + }); + await expectPrivateCredentialFailure( + () => + client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: false, adminAPIKeyAuth: false }, + headers: { AUTHORIZATION: unsafe }, + }), + unsafe, + ); + expect(provider).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('continues refreshing valid Entra credentials for each public request', async () => { + const tokenProvider = vi + .fn<() => Promise>() + .mockResolvedValueOnce('valid-entra-token-one') + .mockResolvedValueOnce('valid-entra-token-two'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = createClient({ + authentication: 'rotating-entra-token', + credential: 'unused-valid-credential', + tokenProvider, + fetch, + logger: createLogger(), + }); + + await client.request({ method: 'get', path: '/models' }); + await client.request({ method: 'get', path: '/models' }); + + const firstRequest = fetch.mock.calls[0]?.[1]; + const secondRequest = fetch.mock.calls[1]?.[1]; + expect(new Headers(firstRequest?.headers).get('authorization')).toBe('Bearer valid-entra-token-one'); + expect(new Headers(secondRequest?.headers).get('authorization')).toBe('Bearer valid-entra-token-two'); + expect(tokenProvider).toHaveBeenCalledTimes(2); + }); + + test.each( + authenticationModes.flatMap((authentication) => + (['api-key', 'Authorization'] as const).flatMap((header) => + (['ambient', 'default'] as const).map((source) => ({ authentication, header, source })), + ), + ), + )( + '$authentication protects $source $header while preprocessing ambient headers', + async ({ authentication, header, source }) => { + const credential = `${PRIVATE_CREDENTIAL}\r${PRIVATE_SUFFIX}`; + vi.stubEnv( + 'OPENAI_CUSTOM_HEADERS', + source === 'ambient' ? `${header}: ${credential}` : 'X-Ambient: safe', + ); + const fetch = vi.fn(async () => Response.json({ ok: true })); + await expectPrivateCredentialFailure( + async () => + new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'safe-key' } + : { azureADTokenProvider: async () => 'safe-token' }), + ...(source === 'default' ? { defaultHeaders: { [header]: credential } } : {}), + fetch, + }), + credential, + ); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each(['valid', 'null'] as const)( + 'applies the %s default before an unsafe ambient credential', + async (override) => { + const credential = `${PRIVATE_CREDENTIAL}\r${PRIVATE_SUFFIX}`; + vi.stubEnv('OPENAI_CUSTOM_HEADERS', `API-KEY: ${credential}\nX-Ambient: preserved`); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const value = override === 'null' ? null : 'safe-default-key'; + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-key', + defaultHeaders: { 'api-key': value }, + fetch, + }); + await client.request({ method: 'get', path: '/models' }); + const headers = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(headers.get('api-key')).toBe(value); + expect(headers.get('x-ambient')).toBe('preserved'); + }, + ); + + test('preserves an explicitly null static Azure api-key', async () => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ baseURL: BASE_URL, apiVersion: API_VERSION, apiKey: 'safe-key', fetch }); + client.apiKey = null; + await client.request({ method: 'get', path: '/models' }); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).has('api-key')).toBe(false); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test.each( + authenticationModes.flatMap((authentication) => + (['api-key', 'Authorization'] as const).map((header) => ({ authentication, header })), + ), + )('$authentication sanitizes $header injected by prepareRequest', async ({ authentication, header }) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'safe-key' } + : { azureADTokenProvider: async () => 'safe-token' }), + fetch, + maxRetries: 0, + }); + client.injectedHeaders = { [header]: credential }; + await expectPrivateTransportCredentialFailure( + () => client.request({ method: 'get', path: '/models' }), + credential, + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + test.each(['off', 'debug'] as const)( + 'ignores hostile prepareRequest Headers iterators before %s request logging', + async (logLevel) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const iterate = vi.fn(() => { + throw new Error(credential); + }); + const prototype = Object.create(Headers.prototype) as object; + Object.defineProperty(prototype, Symbol.iterator, { + configurable: true, + value: iterate, + }); + const injected = Object.setPrototypeOf( + new Headers({ 'api-key': 'safe-hook-token', 'x-custom': 'preserved' }), + prototype, + ); + const logger = createLogger(); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + logger, + logLevel, + maxRetries: 0, + }); + client.injectedHeaders = injected; + + await client.request({ method: 'get', path: '/models' }); + + expect(iterate).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('safe-hook-token'); + expectPrivateLogs(logger, credential); + if (logLevel === 'debug') { + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('sending request'), + expect.objectContaining({ + headers: expect.objectContaining({ 'api-key': '***', 'x-custom': 'preserved' }), + }), + ); + } + }, + ); + + test.each(['off', 'debug'] as const)( + 'sanitizes matched throwing prepareRequest Headers iterators during %s request logging', + async (logLevel) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const iterate = vi.fn(() => { + throw Object.assign(new Error(credential), { cause: new Error(credential) }); + }); + const prototype = Object.create(Headers.prototype) as object; + Object.defineProperties(prototype, { + entries: { configurable: true, value: iterate }, + [Symbol.iterator]: { configurable: true, value: iterate }, + }); + const injected = Object.setPrototypeOf(new Headers({ 'api-key': 'safe-hook-token' }), prototype); + const logger = createLogger(); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + logger, + logLevel, + maxRetries: 0, + }); + client.injectedHeaders = injected; + + await expectPrivateTransportCredentialFailure( + () => client.request({ method: 'get', path: '/models' }), + credential, + ); + + expect(iterate).toHaveBeenCalledTimes(1); + expect(client.fetchFailures).toBe(1); + expect(fetch).not.toHaveBeenCalled(); + expectPrivateLogs(logger, credential); + }, + ); + + test.each( + (['off', 'debug'] as const).flatMap((logLevel) => + (['api-key', 'Authorization'] as const).map((header) => ({ logLevel, header })), + ), + )( + 'sanitizes a throwing prepareRequest $header accessor before $logLevel request logging', + async ({ logLevel, header }) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const readCredential = vi.fn(() => { + throw Object.assign(new Error(credential), { cause: new Error(credential) }); + }); + const injected: Record = {}; + Object.defineProperty(injected, header, { enumerable: true, get: readCredential }); + const logger = createLogger(); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + logger, + logLevel, + maxRetries: 0, + }); + client.injectedHeaders = injected; + + await expectPrivateTransportCredentialFailure( + () => client.request({ method: 'get', path: '/models' }), + credential, + ); + + expect(readCredential).toHaveBeenCalledTimes(1); + expect(client.fetchFailures).toBe(1); + expect(fetch).not.toHaveBeenCalled(); + expectPrivateLogs(logger, credential); + if (logLevel === 'debug') { + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('sending request'), + expect.objectContaining({ headers: expect.objectContaining({ [header]: '***' }) }), + ); + } + }, + ); + + test.each( + (['off', 'debug'] as const).flatMap((logLevel) => + (['api-key', 'Authorization'] as const).flatMap((header) => + ([false, true] as const).map((malformed) => ({ logLevel, header, malformed })), + ), + ), + )( + 'redacts prepareRequest tuple-array $header headers before $logLevel logging (malformed: $malformed)', + async ({ logLevel, header, malformed }) => { + const credential = `${PRIVATE_CREDENTIAL}${malformed ? '\n' : '-'}${PRIVATE_SUFFIX}`; + const logger = createLogger(); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + logger, + logLevel, + maxRetries: 0, + }); + client.injectedHeaders = [ + [header, credential], + ['x-visible', 'preserved'], + ]; + + if (malformed) { + await expectPrivateTransportCredentialFailure( + () => client.request({ method: 'get', path: '/models' }), + credential, + ); + expect(fetch).not.toHaveBeenCalled(); + } else { + await client.request({ method: 'get', path: '/models' }); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(header)).toBe(credential); + expect(fetch).toHaveBeenCalledTimes(1); + } + + expectPrivateLogs(logger, credential); + if (logLevel === 'debug') { + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('sending request'), + expect.objectContaining({ + headers: expect.objectContaining({ [header]: '***', 'x-visible': 'preserved' }), + }), + ); + } + }, + ); + + test.each(['bearer', 'admin'] as const)( + 'preserves the protected %s authentication override', + async (scheme) => { + const provider = vi.fn(async () => 'safe-provider-token'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + azureADTokenProvider: provider, + adminAPIKey: 'default-admin-token', + fetch, + }); + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: true, adminAPIKeyAuth: scheme === 'admin' }, + }); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('authorization')).toBe( + scheme === 'admin' ? 'Bearer custom-admin-token' : 'Bearer custom-bearer-token', + ); + expect(client.bearerCalls).toBe(1); + expect(client.adminCalls).toBe(scheme === 'admin' ? 1 : 0); + expect(provider).toHaveBeenCalledTimes(1); + }, + ); + + test('keeps only the effective case-variant post-hook credential', async () => { + const credential = `${PRIVATE_CREDENTIAL}\r${PRIVATE_SUFFIX}`; + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-key', + fetch, + maxRetries: 0, + }); + client.injectedHeaders = { AUTHORIZATION: credential, authorization: 'safe-final' }; + await client.request({ method: 'get', path: '/models' }); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('authorization')).toBe('safe-final'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('preserves safe protected-hook header object identity and redirects', async () => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-key', + fetch, + }); + const injected = { 'API-KEY': 'safe\tupdated-key', 'X-Custom': 'preserved' }; + client.injectedHeaders = injected; + await client.request({ method: 'get', path: '/models' }); + const request = fetch.mock.calls[0]?.[1]; + expect(request?.headers).toBeInstanceOf(Headers); + expect(request?.headers).not.toBe(injected); + expect(new Headers(request?.headers).get('api-key')).toBe('safe\tupdated-key'); + expect(new Headers(request?.headers).get('x-custom')).toBe('preserved'); + expect(request?.redirect).toBe('manual'); + }); + + test.each([ + ['static API key', 'static-api-key', 'api-key', false] as const, + ['rotating bearer token', 'rotating-entra-token', 'authorization', false] as const, + ['rotating admin token', 'rotating-entra-token', 'authorization', true] as const, + ])( + 'preserves an intrinsic post-hook Headers identity and transport metadata for %s', + async (_description, authentication, name, admin) => { + const credential = name === 'api-key' ? 'hook-static-token' : 'Bearer hook-rotating-token'; + const injected = new Headers({ [name]: credential, 'x-custom': 'preserved' }); + const metadata = new WeakMap(); + const marker = { source: 'protected request hook' }; + metadata.set(injected, marker); + + let transportMetadata: { source: string } | undefined; + const fetch = vi.fn(async (_url: RequestInfo, init?: RequestInit) => { + if (init?.headers instanceof Headers) { + transportMetadata = metadata.get(init.headers); + } + return Response.json({ ok: true }); + }); + const provider = vi.fn(async () => 'configured-provider-token'); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'configured-static-token' } + : { azureADTokenProvider: provider, adminAPIKey: 'configured-admin-token' }), + fetch, + maxRetries: 0, + }); + client.injectedHeaders = injected; + + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: true, adminAPIKeyAuth: admin }, + }); + + const request = fetch.mock.calls[0]?.[1]; + expect(request?.headers).toBe(injected); + expect(transportMetadata).toBe(marker); + expect(injected.get(name)).toBe(credential); + expect(injected.get('x-custom')).toBe('preserved'); + expect(request?.redirect).toBe(name === 'api-key' ? 'manual' : undefined); + expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each([ + ['static API key', 'static-api-key', 'api-key', false] as const, + ['rotating bearer token', 'rotating-entra-token', 'authorization', false] as const, + ['rotating admin token', 'rotating-entra-token', 'authorization', true] as const, + ])( + 'preserves an unmodified inherited Headers subclass identity and metadata for %s', + async (_description, authentication, name, admin) => { + const trackedPrototype = Object.create(Headers.prototype) as object; + const nestedPrototype = Object.create(trackedPrototype) as object; + const credential = name === 'api-key' ? 'subclass-static-token' : 'Bearer subclass-rotating-token'; + const injected = Object.setPrototypeOf( + new Headers({ [name]: credential, 'x-custom': 'preserved' }), + nestedPrototype, + ); + const metadata = new WeakMap(); + const marker = { source: 'trusted transport metadata' }; + metadata.set(injected, marker); + + let transportMetadata: { source: string } | undefined; + const fetch = vi.fn(async (_url: RequestInfo, init?: RequestInit) => { + if (init?.headers instanceof Headers) { + transportMetadata = metadata.get(init.headers); + } + return Response.json({ ok: true }); + }); + const provider = vi.fn(async () => 'configured-provider-token'); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'configured-static-token' } + : { azureADTokenProvider: provider, adminAPIKey: 'configured-admin-token' }), + fetch, + maxRetries: 0, + }); + client.injectedHeaders = injected; + + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: true, adminAPIKeyAuth: admin }, + }); + + const request = fetch.mock.calls[0]?.[1]; + expect(request?.headers).toBe(injected); + expect(transportMetadata).toBe(marker); + expect(injected.get(name)).toBe(credential); + expect(injected.get('x-custom')).toBe('preserved'); + expect(request?.redirect).toBe(name === 'api-key' ? 'manual' : undefined); + expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each( + (['get', 'has', 'entries'] as const).flatMap((operation) => + (['prototype accessor', 'ancestor accessor', 'instance accessor'] as const).map((override) => ({ + operation, + override, + })), + ), + )( + 'does not preserve or invoke an overridden Headers subclass $override ($operation)', + async ({ operation, override }) => { + let accessorReads = 0; + const trackedPrototype = Object.create(Headers.prototype) as object; + const nestedPrototype = Object.create(trackedPrototype) as object; + const injected = Object.setPrototypeOf( + new Headers({ 'api-key': 'safe-subclass-token', 'x-custom': 'preserved' }), + nestedPrototype, + ); + let target: object; + if (override === 'instance accessor') { + target = injected; + } else if (override === 'ancestor accessor') { + target = trackedPrototype; + } else { + target = nestedPrototype; + } + Object.defineProperty(target, operation, { + configurable: true, + get() { + accessorReads += 1; + if (operation === 'entries') { + throw new Error(`${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`); + } + return Headers.prototype[operation]; + }, + }); + + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.injectedHeaders = injected; + + await client.request({ method: 'get', path: '/models' }); + + const sent = fetch.mock.calls[0]?.[1]?.headers; + expect(sent).toBeInstanceOf(Headers); + expect(sent).not.toBe(injected); + expect(new Headers(sent).get('api-key')).toBe('safe-subclass-token'); + expect(new Headers(sent).get('x-custom')).toBe('preserved'); + expect(accessorReads).toBe(0); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each([ + ['static API key', 'static-api-key', 'api-key', false] as const, + ['rotating bearer token', 'rotating-entra-token', 'authorization', false] as const, + ['rotating admin token', 'rotating-entra-token', 'authorization', true] as const, + ])( + 'safely snapshots actual cross-realm undici Headers for %s', + async (_description, authentication, name, admin) => { + const credential = name === 'api-key' ? 'realm-static-token' : 'Bearer realm-rotating-token'; + const firstCookie = 'session=first; Expires=Wed, 21 Oct 2015 07:28:00 GMT'; + const secondCookie = 'preference=second; Path=/'; + const injected = createForeignHeaders([ + [name, credential], + ['x-custom', 'preserved'], + ['set-cookie', firstCookie], + ['set-cookie', secondCookie], + ]); + expect(injected).not.toBeInstanceOf(Headers); + const provider = vi.fn(async () => 'configured-provider-token'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'configured-static-token' } + : { azureADTokenProvider: provider, adminAPIKey: 'configured-admin-token' }), + fetch, + maxRetries: 0, + }); + client.injectedHeaders = injected; + + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: true, adminAPIKeyAuth: admin }, + }); + + const request = fetch.mock.calls[0]?.[1]; + expect(request?.headers).toBeInstanceOf(Headers); + expect(request?.headers).not.toBe(injected); + const sent = request?.headers as Headers; + expect(sent.get(name)).toBe(credential); + expect(sent.get('x-custom')).toBe('preserved'); + expect(sent.getSetCookie()).toEqual([firstCookie, secondCookie]); + expect(request?.redirect).toBe(name === 'api-key' ? 'manual' : undefined); + expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + }, + ); + + test.each([ + ['static API key', 'static-api-key', 'api-key', false] as const, + ['rotating bearer token', 'rotating-entra-token', 'authorization', false] as const, + ['rotating admin token', 'rotating-entra-token', 'authorization', true] as const, + ])( + 'safely snapshots inherited cross-realm undici Headers subclasses for %s', + async (_description, authentication, name, admin) => { + const credential = name === 'api-key' ? 'subclass-static-token' : 'Bearer subclass-rotating-token'; + const values: [string, string][] = [ + [name, credential], + ['x-custom', 'preserved'], + ]; + const injected = runInNewContext( + 'class Ancestor extends ForeignHeaders {} class Subclass extends Ancestor {} new Subclass(values)', + { ForeignHeaders, values }, + ) as Headers; + expect(injected).not.toBeInstanceOf(Headers); + expect( + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(injected), Symbol.toStringTag), + ).toBeUndefined(); + + const provider = vi.fn(async () => 'configured-provider-token'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'configured-static-token' } + : { azureADTokenProvider: provider, adminAPIKey: 'configured-admin-token' }), + fetch, + maxRetries: 0, + }); + client.injectedHeaders = injected; + + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: true, adminAPIKeyAuth: admin }, + }); + + const request = fetch.mock.calls[0]?.[1]; + expect(request?.headers).toBeInstanceOf(Headers); + expect(request?.headers).not.toBe(injected); + expect(new Headers(request?.headers).get(name)).toBe(credential); + expect(new Headers(request?.headers).get('x-custom')).toBe('preserved'); + expect(request?.redirect).toBe(name === 'api-key' ? 'manual' : undefined); + expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + }, + ); + + test.each( + (['get', 'has', 'entries', 'iterator'] as const).flatMap((operation) => + (['instance accessor', 'subclass accessor', 'ancestor accessor', 'subclass method'] as const).map( + (override) => ({ operation, override }), + ), + ), + )( + 'rejects an overridden cross-realm Headers subclass $override ($operation) without invoking it', + async ({ operation, override }) => { + const values: [string, string][] = [ + ['api-key', 'safe-subclass-token'], + ['x-custom', 'preserved'], + ]; + const injected = runInNewContext( + 'class Ancestor extends ForeignHeaders {} class Subclass extends Ancestor {} new Subclass(values)', + { ForeignHeaders, values }, + ) as Headers; + const subclass = Object.getPrototypeOf(injected) as object; + const ancestor = Object.getPrototypeOf(subclass) as object; + let target: object = subclass; + if (override === 'instance accessor') { + target = injected; + } else if (override === 'ancestor accessor') { + target = ancestor; + } + const key = operation === 'iterator' ? Symbol.iterator : operation; + let operationReads = 0; + const maliciousOperation = () => { + operationReads += 1; + throw new Error(`${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`); + }; + Object.defineProperty(target, key, { + configurable: true, + ...(override === 'subclass method' ? { value: maliciousOperation } : { get: maliciousOperation }), + }); + + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + + await expectPrivateCredentialFailure( + () => client.invokeProtectedFetch(injected), + `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`, + ); + expect(operationReads).toBe(0); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each([false, true] as const)( + 'snapshots cross-realm credential iteration once (malformed first: %s)', + async (malformedFirst) => { + const malformed = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const safe = 'safe-realm credential\tvalue\u00FF'; + const injected = createForeignHeaders([['api-key', 'placeholder']]); + const originalPrototype = Object.getPrototypeOf(injected) as object; + const prototype = Object.create(null) as object; + for (const name of Reflect.ownKeys(originalPrototype)) { + const descriptor = Object.getOwnPropertyDescriptor(originalPrototype, name); + if (descriptor) { + Object.defineProperty(prototype, name, descriptor); + } + } + let reads = 0; + Object.defineProperty(prototype, Symbol.iterator, { + configurable: true, + value() { + reads += 1; + const credential = malformedFirst || reads !== 1 ? malformed : safe; + return [ + ['api-key', credential], + ['x-custom', 'preserved'], + ][Symbol.iterator](); + }, + }); + Object.setPrototypeOf(injected, prototype); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + + if (malformedFirst) { + await expectPrivateCredentialFailure(() => client.invokeProtectedFetch(injected), malformed); + expect(fetch).not.toHaveBeenCalled(); + } else { + await client.invokeProtectedFetch(injected); + const request = fetch.mock.calls[0]?.[1]; + expect(request?.headers).not.toBe(injected); + expect(new Headers(request?.headers).get('api-key')).toBe(safe); + expect(new Headers(request?.headers).get('x-custom')).toBe('preserved'); + } + expect(reads).toBe(1); + }, + ); + + test('rejects a spoofed cross-realm Headers iterator accessor without invoking it', async () => { + let getterReads = 0; + const prototype = Object.create(null) as object; + Object.defineProperties(prototype, { + [Symbol.toStringTag]: { value: 'Headers' }, + [Symbol.iterator]: { + get() { + getterReads += 1; + throw new Error(`${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`); + }, + }, + entries: { value: () => [][Symbol.iterator]() }, + get: { value: () => null }, + has: { value: () => false }, + }); + const injected = Object.create(prototype) as Headers; + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + + await expectPrivateCredentialFailure( + () => client.invokeProtectedFetch(injected), + `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`, + ); + expect(getterReads).toBe(0); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('bounds a cross-realm Headers iterator before materializing untrusted entries', async () => { + const injected = createForeignHeaders([['api-key', 'safe-token']]); + const prototype = Object.create(Object.getPrototypeOf(injected)) as object; + Object.defineProperties(prototype, { + [Symbol.toStringTag]: { value: 'Headers' }, + [Symbol.iterator]: { + value: () => + Array.from({ length: 1025 }, (_, index) => [`x-header-${index}`, 'safe'])[Symbol.iterator](), + }, + entries: { value: Object.getPrototypeOf(injected).entries }, + get: { value: Object.getPrototypeOf(injected).get }, + has: { value: Object.getPrototypeOf(injected).has }, + }); + Object.setPrototypeOf(injected, prototype); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-key', + fetch, + }); + await expect(client.invokeProtectedFetch(injected)).rejects.toThrow(SAFE_ERROR); + expect(fetch).not.toHaveBeenCalled(); + }); + + test.each( + (['own', 'subclass', 'ancestor'] as const).flatMap((representation) => + (['overlong', 'nonterminating', 'credential-bearing cleanup'] as const).map((behavior) => ({ + representation, + behavior, + })), + ), + )( + 'bounds a $behavior matched same-realm Headers $representation iterator before dispatch', + async ({ representation, behavior }) => { + const prototype = Object.create(Headers.prototype) as object; + const subclass = Object.create(prototype) as object; + const injected = Object.setPrototypeOf(new Headers({ 'api-key': 'placeholder' }), subclass); + let owner: object = prototype; + if (representation === 'own') { + owner = injected; + } else if (representation === 'subclass') { + owner = subclass; + } + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + let yielded = 0; + let closed = false; + function iterate(): IterableIterator<[string, string]> { + let index = 0; + return { + [Symbol.iterator]() { + return this; + }, + next(): IteratorResult<[string, string]> { + if (behavior !== 'nonterminating' && index >= 2048) { + closed = true; + return { done: true, value: undefined }; + } + const current = index; + index += 1; + yielded += 1; + const value: [string, string] = + current === 0 ? ['api-key', 'safe-request-token'] : [`x-custom-${current}`, 'preserved']; + return { done: false, value }; + }, + return(): IteratorResult<[string, string]> { + closed = true; + if (behavior === 'credential-bearing cleanup') { + throw Object.assign(new Error(credential), { cause: new Error(credential) }); + } + return { done: true, value: undefined }; + }, + }; + } + Object.defineProperties(owner, { + entries: { configurable: true, value: iterate }, + [Symbol.iterator]: { configurable: true, value: iterate }, + }); + const logger = createLogger(); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + logger, + logLevel: 'debug', + maxRetries: 0, + }); + client.injectedHeaders = injected; + + await expectPrivateTransportCredentialFailure( + () => client.request({ method: 'get', path: '/models' }), + credential, + ); + + expect(yielded).toBe(1025); + expect(closed).toBe(true); + expect(fetch).not.toHaveBeenCalled(); + expectPrivateLogs(logger, credential); + }, + ); + + test('preserves exactly 1,024 custom same-realm Headers entries and virtual credentials', async () => { + let reads = 0; + function* iterate(): IterableIterator<[string, string]> { + reads += 1; + yield ['api-key', 'safe-virtual-token']; + for (let index = 1; index < 1024; index += 1) { + yield [`x-custom-${index}`, `value-${index}`]; + } + } + const prototype = Object.create(Headers.prototype) as object; + Object.defineProperties(prototype, { + entries: { configurable: true, value: iterate }, + [Symbol.iterator]: { configurable: true, value: iterate }, + }); + const injected = Object.setPrototypeOf(new Headers({ 'api-key': 'placeholder' }), prototype); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.injectedHeaders = injected; + + await client.request({ method: 'get', path: '/models' }); + + const headers = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(headers.get('api-key')).toBe('safe-virtual-token'); + expect(headers.get('x-custom-1023')).toBe('value-1023'); + expect(reads).toBe(1); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test.each(['own', 'subclass', 'ancestor'] as const)( + 'preserves more than 1,024 intrinsic entries through a matched same-realm Headers %s iterator', + async (representation) => { + const entries: [string, string][] = [['api-key', 'safe-intrinsic-token']]; + for (let index = 0; index < 1200; index += 1) { + entries.push([`x-intrinsic-${index}`, `value-${index}`]); + } + const prototype = Object.create(Headers.prototype) as object; + const subclass = Object.create(prototype) as object; + const injected = Object.setPrototypeOf(new Headers(entries), subclass); + let owner: object = prototype; + if (representation === 'own') { + owner = injected; + } else if (representation === 'subclass') { + owner = subclass; + } + const iterate = vi.fn(function iterate(this: Headers) { + return Headers.prototype.entries.call(this); + }); + Object.defineProperties(owner, { + entries: { configurable: true, value: iterate }, + [Symbol.iterator]: { configurable: true, value: iterate }, + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.injectedHeaders = injected; + + await client.request({ method: 'get', path: '/models' }); + + const headers = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(headers.get('api-key')).toBe('safe-intrinsic-token'); + expect(headers.get('x-intrinsic-1199')).toBe('value-1199'); + expect(iterate).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test('preserves more than 1,024 intrinsic entries while ignoring a hostile iterator alias', async () => { + const entries: [string, string][] = [['api-key', 'safe-intrinsic-token']]; + for (let index = 0; index < 1200; index += 1) { + entries.push([`x-intrinsic-${index}`, `value-${index}`]); + } + const injected = new Headers(entries); + const iterate = vi.fn(() => { + throw new Error(`${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`); + }); + Object.defineProperty(injected, Symbol.iterator, { configurable: true, value: iterate }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.injectedHeaders = injected; + + await client.request({ method: 'get', path: '/models' }); + + const headers = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(headers.get('api-key')).toBe('safe-intrinsic-token'); + expect(headers.get('x-intrinsic-1199')).toBe('value-1199'); + expect(iterate).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test.each(['not a tuple', 'throwing tuple element', 'non-string tuple element'] as const)( + 'sanitizes a hostile matched same-realm Headers iterator row: %s', + async (representation) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const readCredential = vi.fn(() => { + throw Object.assign(new Error(credential), { cause: new Error(credential) }); + }); + let closed = false; + function* iterate(): IterableIterator { + try { + if (representation === 'not a tuple') { + yield { 0: 'api-key', 1: 'safe-token' }; + } else if (representation === 'throwing tuple element') { + yield new Proxy(['api-key', 'safe-token'], { + get(target, property, receiver) { + return property === '1' ? readCredential() : Reflect.get(target, property, receiver); + }, + }); + } else { + yield ['api-key', { toString: readCredential }]; + } + } finally { + closed = true; + } + } + const prototype = Object.create(Headers.prototype) as object; + Object.defineProperties(prototype, { + entries: { configurable: true, value: iterate }, + [Symbol.iterator]: { configurable: true, value: iterate }, + }); + const injected = Object.setPrototypeOf(new Headers({ 'api-key': 'placeholder' }), prototype); + const logger = createLogger(); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + logger, + logLevel: 'debug', + maxRetries: 0, + }); + client.injectedHeaders = injected; + + await expectPrivateTransportCredentialFailure( + () => client.request({ method: 'get', path: '/models' }), + credential, + ); + + expect(closed).toBe(true); + expect(readCredential).toHaveBeenCalledTimes(representation === 'throwing tuple element' ? 1 : 0); + expect(fetch).not.toHaveBeenCalled(); + expectPrivateLogs(logger, credential); + }, + ); + + test.each(['subclass override', 'own override'] as const)( + 'materializes a mutable post-hook Headers %s exactly once before dispatch', + async (override) => { + const malformed = `${PRIVATE_CREDENTIAL}\r${PRIVATE_SUFFIX}`; + let reads = 0; + const nextEntries = () => { + reads += 1; + return new Map([ + ['api-key', reads === 1 ? 'safe-first-token' : malformed], + ['x-custom', 'preserved'], + ]).entries(); + }; + + const injected = new Headers({ 'api-key': 'placeholder' }); + const operationOwner = + override === 'subclass override' + ? Object.getPrototypeOf(Object.setPrototypeOf(injected, Object.create(Headers.prototype))) + : injected; + Object.defineProperty(operationOwner, 'entries', { + configurable: true, + value: nextEntries, + }); + Object.defineProperty(operationOwner, Symbol.iterator, { + configurable: true, + value: nextEntries, + }); + + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-static-token', + fetch, + maxRetries: 0, + }); + + await client.invokeProtectedFetch(injected); + + const validationReads = reads; + const request = fetch.mock.calls[0]?.[1]; + expect(request?.headers).toBeInstanceOf(Headers); + expect(request?.headers).not.toBe(injected); + expect(new Headers(request?.headers).get('api-key')).toBe('safe-first-token'); + expect(new Headers(request?.headers).get('x-custom')).toBe('preserved'); + expect(request?.redirect).toBe('manual'); + expect(validationReads).toBe(1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['Headers', 'tuple'] as const)( + 'preserves safe ambient precedence with %s Azure default headers', + async (kind) => { + vi.stubEnv('OPENAI_CUSTOM_HEADERS', 'X-Ambient: original\nX-Override: ambient'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const defaults = + kind === 'Headers' ? new Headers({ 'x-override': 'default' }) : [['x-override', 'default']]; + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-key', + defaultHeaders: defaults, + fetch, + }); + await client.request({ method: 'get', path: '/models' }); + const headers = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(headers.get('x-ambient')).toBe('original'); + expect(headers.get('x-override')).toBe('default'); + }, + ); + + test.each(['getter', 'proxy'] as const)( + 'snapshots a mutable %s credential once across validation, redirect, and dispatch', + async (kind) => { + const credential = `${PRIVATE_CREDENTIAL}\r${PRIVATE_SUFFIX}`; + let reads = 0; + const readValue = () => { + reads += 1; + return reads === 1 ? 'safe-first-token' : credential; + }; + const getterHeaders: Record = {}; + Object.defineProperty(getterHeaders, 'api-key', { + enumerable: true, + get: readValue, + }); + const headers = + kind === 'getter' + ? getterHeaders + : new Proxy( + { 'api-key': 'placeholder' }, + { + get(target, property, receiver) { + return property === 'api-key' ? readValue() : Reflect.get(target, property, receiver); + }, + }, + ); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-key', + fetch, + maxRetries: 0, + }); + await client.invokeProtectedFetch(headers); + expect(reads).toBe(1); + const request = fetch.mock.calls[0]?.[1]; + expect(request?.headers).toBeInstanceOf(Headers); + expect(new Headers(request?.headers).get('api-key')).toBe('safe-first-token'); + expect(request?.redirect).toBe('manual'); + expect(client.fetchFailures).toBe(0); + }, + ); + + test('keeps protected Azure credential failures asynchronous and catchable', async () => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-key', + fetch, + maxRetries: 0, + }); + const failure = client.invokeProtectedFetch({ authorization: credential }); + expect(failure).toBeInstanceOf(Promise); + await expect(failure).rejects.toThrow(SAFE_ERROR); + await expect(failure.catch((error: unknown) => error)).resolves.not.toHaveProperty('cause'); + expect(client.fetchFailures).toBe(1); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('exposes deferred Azure authentication tombstones through a genuine observable Set', async () => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-static-token', + fetch, + maxRetries: 0, + }); + client.apiKey = null; + client.inspectAuthenticationCarrier = (carrier) => { + expect(carrier.nulls).toBeInstanceOf(Set); + expect(carrier.nulls.has('api-key')).toBe(true); + expect(carrier.nulls.size).toBe(1); + expect([...carrier.nulls]).toEqual(['api-key']); + expect([...carrier.nulls.keys()]).toEqual(['api-key']); + expect([...carrier.nulls.values()]).toEqual(['api-key']); + expect([...carrier.nulls.entries()]).toEqual([['api-key', 'api-key']]); + const observed: string[] = []; + const visitNulls = carrier.nulls.forEach.bind(carrier.nulls); + visitNulls((value, key, parent) => { + observed.push(value, key); + expect(parent).toBe(carrier.nulls); + }); + expect(observed).toEqual(['api-key', 'api-key']); + expect(carrier.values.has('api-key')).toBe(false); + }; + + await client.request({ method: 'get', path: '/models' }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).has('api-key')).toBe(false); + }); + + test.each(['delete', 'clear'] as const)( + 'restores missing-authentication validation when an inherited Azure tombstone is removed with %s', + async (operation) => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-static-token', + fetch, + maxRetries: 0, + }); + client.apiKey = null; + client.inspectAuthenticationCarrier = (carrier) => { + expect(carrier.nulls.has('api-key')).toBe(true); + if (operation === 'delete') { + expect(carrier.nulls.delete('api-key')).toBe(true); + } else { + carrier.nulls.clear(); + } + expect(carrier.nulls.size).toBe(0); + expect(carrier.values.has('api-key')).toBe(false); + }; + + await expect(client.request({ method: 'get', path: '/models' })).rejects.toThrow( + 'Could not resolve authentication method.', + ); + + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each(['delete', 'clear'] as const)( + 'restores a deferred static Azure credential when a caller-added tombstone is removed with %s', + async (operation) => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-static-token', + fetch, + maxRetries: 0, + }); + client.inspectAuthenticationCarrier = (carrier) => { + expect(carrier.nulls.has('api-key')).toBe(false); + expect(carrier.nulls.add('api-key')).toBe(carrier.nulls); + expect(carrier.nulls.has('api-key')).toBe(true); + if (operation === 'delete') { + expect(carrier.nulls.delete('api-key')).toBe(true); + } else { + carrier.nulls.clear(); + } + expect(carrier.nulls.size).toBe(0); + expect(carrier.values.get('api-key')).toBe('configured-static-token'); + }; + + await client.request({ method: 'get', path: '/models' }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('configured-static-token'); + }, + ); + + test.each(['auth', 'auth-null', 'bearer', 'admin'] as const)( + 'preserves a subclass mutation of the super %s authentication carrier', + async (mutation) => { + const malformed = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const provider = vi.fn(async () => (mutation === 'bearer' ? malformed : 'safe-provider-token')); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const isStatic = mutation === 'auth' || mutation === 'auth-null'; + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(isStatic ? { apiKey: malformed } : { azureADTokenProvider: provider, adminAPIKey: malformed }), + fetch, + maxRetries: 0, + }); + client.mutation = mutation; + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: true, adminAPIKeyAuth: mutation === 'admin' }, + }); + const headers = new Headers(fetch.mock.calls[0]?.[1]?.headers); + if (mutation === 'auth') { + expect(headers.get('api-key')).toBe('mutated-static-token'); + } else if (mutation === 'auth-null') { + expect(headers.has('api-key')).toBe(false); + } else { + expect(headers.get('authorization')).toBe(`Bearer mutated-${mutation}-token`); + } + expect(provider).toHaveBeenCalledTimes(isStatic ? 0 : 1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each([ + { + name: 'deletes a configured static key before setting bearer authentication', + scheme: 'auth', + configured: 'valid', + mutate: (headers: Headers) => { + headers.delete('API-KEY'); + headers.set('Authorization', 'Bearer replacement-token'); + }, + apiKey: null, + authorization: 'Bearer replacement-token', + }, + { + name: 'deletes a malformed static key without ever validating it', + scheme: 'auth', + configured: 'malformed', + mutate: (headers: Headers) => { + headers.delete('aPi-KeY'); + headers.set('AUTHORIZATION', 'Bearer replacement-token'); + }, + apiKey: null, + authorization: 'Bearer replacement-token', + }, + { + name: 'appends to the deferred configured static credential', + scheme: 'auth', + configured: 'valid', + mutate: (headers: Headers) => { + headers.append('API-KEY', 'appended-token'); + headers.append('api-key', 'second-token'); + }, + apiKey: 'configured-token, appended-token, second-token', + authorization: null, + }, + { + name: 'does not revive a deleted malformed key when appending a replacement', + scheme: 'auth', + configured: 'malformed', + mutate: (headers: Headers) => { + headers.delete('API-KEY'); + headers.append('api-key', 'appended-token'); + }, + apiKey: 'appended-token', + authorization: null, + }, + { + name: 'deletes an appended key before replacing its authentication scheme', + scheme: 'auth', + configured: 'valid', + mutate: (headers: Headers) => { + headers.append('api-key', 'discarded-token'); + headers.delete('API-KEY'); + headers.set('Authorization', 'Bearer replacement-token'); + }, + apiKey: null, + authorization: 'Bearer replacement-token', + }, + { + name: 'replaces an invalid intermediate protected-hook value safely', + scheme: 'auth', + configured: 'valid', + mutate: (headers: Headers) => { + headers.set('api-key', [PRIVATE_CREDENTIAL, PRIVATE_SUFFIX].join('\n')); + headers.set('API-KEY', 'safe-final-token'); + }, + apiKey: 'safe-final-token', + authorization: null, + }, + { + name: 'deletes an invalid appended protected-hook value safely', + scheme: 'auth', + configured: 'valid', + mutate: (headers: Headers) => { + headers.append('API-KEY', [PRIVATE_CREDENTIAL, PRIVATE_SUFFIX].join('\r')); + headers.delete('api-key'); + headers.set('authorization', 'Bearer safe-final-token'); + }, + apiKey: null, + authorization: 'Bearer safe-final-token', + }, + { + name: 'deletes a malformed rotating bearer credential', + scheme: 'bearer', + configured: 'malformed', + mutate: (headers: Headers) => { + headers.delete('AUTHORIZATION'); + headers.set('api-key', 'safe-bearer-replacement'); + }, + apiKey: 'safe-bearer-replacement', + authorization: null, + }, + { + name: 'appends to the deferred rotating bearer credential', + scheme: 'bearer', + configured: 'valid', + mutate: (headers: Headers) => { + headers.append('authorization', 'Bearer appended-token'); + }, + apiKey: null, + authorization: 'Bearer configured-token, Bearer appended-token', + }, + { + name: 'deletes a malformed administrator credential without losing bearer auth', + scheme: 'admin', + configured: 'malformed', + mutate: (headers: Headers) => { + headers.delete('Authorization'); + headers.set('API-KEY', 'safe-admin-replacement'); + }, + apiKey: 'safe-admin-replacement', + authorization: 'Bearer custom-bearer-token', + }, + ] as const)('preserves subclass carrier mutation: $name', async (scenario) => { + const credential = + scenario.configured === 'malformed' + ? [PRIVATE_CREDENTIAL, PRIVATE_SUFFIX].join('\n') + : 'configured-token'; + const provider = vi.fn(async () => (scenario.scheme === 'admin' ? 'safe-provider-token' : credential)); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(scenario.scheme === 'auth' + ? { apiKey: credential } + : { azureADTokenProvider: provider, adminAPIKey: credential }), + fetch, + maxRetries: 0, + }); + client.mutationScheme = scenario.scheme; + client.mutateCarrier = scenario.mutate; + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: true, adminAPIKeyAuth: scenario.scheme === 'admin' }, + }); + const headers = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(headers.get('api-key')).toBe(scenario.apiKey); + expect(headers.get('authorization')).toBe(scenario.authorization); + expect(provider).toHaveBeenCalledTimes(scenario.scheme === 'auth' ? 0 : 1); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test.each(['set', 'append'] as const)( + 'rejects an effective malformed protected-carrier %s without leaking it', + async (operation) => { + const credential = [PRIVATE_CREDENTIAL, PRIVATE_SUFFIX].join('\n'); + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.mutateCarrier = (headers) => { + headers[operation]('api-key', credential); + }; + await expectPrivateCredentialFailure( + () => client.request({ method: 'get', path: '/models' }), + credential, + ); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + const deferredHeaderReadScenarios = (['auth', 'bearer', 'admin'] as const).flatMap((scheme) => + (['get', 'has', 'entries', 'keys', 'values', 'iterator', 'forEach'] as const).map((method) => ({ + scheme, + method, + })), + ); + + test.each(deferredHeaderReadScenarios)( + 'preserves deferred $scheme authentication through Headers.$method', + async ({ scheme, method }) => { + const configured = 'configured-token'; + const expectedName = scheme === 'auth' ? 'api-key' : 'authorization'; + const expectedValue = scheme === 'auth' ? configured : `Bearer ${configured}`; + const provider = vi.fn(async () => configured); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(scheme === 'auth' + ? { apiKey: configured } + : { azureADTokenProvider: provider, adminAPIKey: configured }), + fetch, + maxRetries: 0, + }); + client.mutationScheme = scheme; + client.mutateCarrier = (headers) => { + expect(headers).toBeInstanceOf(Headers); + let observed: string | null = null; + switch (method) { + case 'get': { + observed = headers.get(expectedName.toUpperCase()); + break; + } + case 'has': { + observed = headers.has(expectedName.toUpperCase()) ? expectedValue : null; + break; + } + case 'entries': { + observed = [...headers.entries()].find(([name]) => name === expectedName)?.[1] ?? null; + break; + } + case 'keys': { + observed = [...headers.keys()].includes(expectedName) ? expectedValue : null; + break; + } + case 'values': { + observed = [...headers.values()].find((value) => value === expectedValue) ?? null; + break; + } + case 'iterator': { + observed = [...headers].find(([name]) => name === expectedName)?.[1] ?? null; + break; + } + case 'forEach': { + const callbackContext = { trusted: true }; + const iterate = headers.forEach; + iterate.call( + headers, + function collectHeader( + this: typeof callbackContext, + value: string, + name: string, + owner: Headers, + ) { + expect(this).toBe(callbackContext); + expect(owner).toBe(headers); + if (name === expectedName) { + observed = value; + } + }, + callbackContext, + ); + break; + } + default: { + throw new Error('Unknown deferred header reader.'); + } + } + expect(observed).toBe(expectedValue); + }; + + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: true, adminAPIKeyAuth: scheme === 'admin' }, + }); + expect(provider).toHaveBeenCalledTimes(scheme === 'auth' ? 0 : 1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + const liveDeferredHeaderCases = (['auth', 'bearer', 'admin'] as const).flatMap((scheme) => + (['entries', 'keys', 'values', 'iterator', 'forEach'] as const).map((method) => ({ scheme, method })), + ); + + test.each(liveDeferredHeaderCases)( + 'keeps deferred $scheme Headers.$method live through sorted and authentication mutations', + async ({ scheme, method }) => { + const configured = 'configured-token'; + const authenticationName = scheme === 'auth' ? 'api-key' : 'authorization'; + const finalAuthentication = scheme === 'auth' ? 'live-static-token' : `Bearer live-${scheme}-token`; + const provider = vi.fn(async () => configured); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(scheme === 'auth' + ? { apiKey: configured } + : { azureADTokenProvider: provider, adminAPIKey: configured }), + fetch, + maxRetries: 0, + }); + client.mutationScheme = scheme; + const observed: string[] = []; + client.mutateCarrier = (headers) => { + headers.set('x-a', 'first'); + headers.set('x-c', 'last'); + const namesByValue = new Map([ + ['first', 'x-a'], + ['middle', 'x-b'], + ['last', 'x-c'], + ]); + const visit = (name: string, owner: Headers): void => { + observed.push(name); + if (name === 'x-a') { + owner.set('x-b', 'middle'); + } + if (name === 'x-b') { + owner.set(authenticationName, finalAuthentication); + } + }; + + switch (method) { + case 'entries': { + for (const [name] of headers.entries()) { + visit(name, headers); + } + break; + } + case 'keys': { + for (const name of headers.keys()) { + visit(name, headers); + } + break; + } + case 'values': { + for (const value of headers.values()) { + const name = namesByValue.get(value) ?? authenticationName; + visit(name, headers); + } + break; + } + case 'iterator': { + for (const [name] of headers) { + visit(name, headers); + } + break; + } + case 'forEach': { + const iterate = headers.forEach; + iterate.call(headers, (_value, name, owner) => visit(name, owner)); + break; + } + default: { + throw new Error('Unknown deferred live header iterator.'); + } + } + }; + + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: true, adminAPIKeyAuth: scheme === 'admin' }, + }); + + expect(observed).toEqual([authenticationName, 'x-a', 'x-b', 'x-c']); + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get(authenticationName)).toBe(finalAuthentication); + expect(sent.get('x-b')).toBe('middle'); + expect(provider).toHaveBeenCalledTimes(scheme === 'auth' ? 0 : 1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each([ + { + name: 'sorted insertion before the cursor', + mutate: (headers: Headers) => headers.set('x-0', 'inserted earlier'), + }, + { + name: 'deletion of the current header', + mutate: (headers: Headers) => headers.delete('x-a'), + }, + { + name: 'deletion of a pending header', + mutate: (headers: Headers) => headers.delete('x-c'), + }, + { + name: 'replacement of a pending value', + mutate: (headers: Headers) => headers.set('x-c', 'replaced'), + }, + ])('matches native Headers iterator position after $name', async ({ mutate }) => { + const run = (headers: Headers): readonly [string, string][] => { + headers.set('x-a', 'first'); + headers.set('x-c', 'middle'); + headers.set('x-d', 'last'); + const iterator = headers.entries(); + iterator.next(); + iterator.next(); + mutate(headers); + return [...iterator]; + }; + const expected = run(new Headers([['api-key', 'configured-token']])); + let observed: readonly [string, string][] = []; + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.mutateCarrier = (headers) => { + observed = run(headers); + }; + + await client.request({ method: 'get', path: '/models' }); + + expect(observed).toEqual(expected); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('resumes an exhausted deferred Headers iterator after a later insertion', async () => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.mutateCarrier = (headers) => { + const iterator = headers.entries(); + expect(iterator.next()).toEqual({ value: ['api-key', 'configured-token'], done: false }); + expect(iterator.next()).toEqual({ value: undefined, done: true }); + headers.set('x-later', 'resumed'); + expect(iterator.next()).toEqual({ value: ['x-later', 'resumed'], done: false }); + expect(Object.prototype.toString.call(iterator)).toBe('[object Headers Iterator]'); + expect(iterator[Symbol.iterator]()).toBe(iterator); + }; + + await client.request({ method: 'get', path: '/models' }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('x-later')).toBe('resumed'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test.each(['entries', 'keys', 'values'] as const)( + 'preserves the native deferred Headers.%s iterator receiver and prototype protocol', + async (method) => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.mutateCarrier = (headers) => { + const iterator = headers[method](); + expect(Object.getOwnPropertyDescriptor(iterator, 'next')).toBeUndefined(); + expect(() => Reflect.apply(iterator.next, {}, [])).toThrow(TypeError); + let value: string | [string, string] = 'configured-token'; + if (method === 'entries') { + value = ['api-key', 'configured-token']; + } else if (method === 'keys') { + value = 'api-key'; + } + expect(Reflect.apply(Object.getPrototypeOf(iterator).next, iterator, [])).toEqual({ + value, + done: false, + }); + expect(iterator.next()).toEqual({ value: undefined, done: true }); + }; + + await client.request({ method: 'get', path: '/models' }); + + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each([ + { + name: 'native set insertion', + mutate: (headers: Headers) => Headers.prototype.set.call(headers, 'x-b', 'inserted'), + }, + { + name: 'native append update', + mutate: (headers: Headers) => Headers.prototype.append.call(headers, 'x-a', 'appended'), + }, + { + name: 'native deletion', + mutate: (headers: Headers) => Headers.prototype.delete.call(headers, 'x-a'), + }, + ])('keeps a deferred iterator live across $name', async ({ mutate }) => { + const run = (headers: Headers): readonly [string, string][] => { + headers.set('x-a', 'first'); + headers.set('x-c', 'last'); + const iterator = headers.entries(); + expect(iterator.next().value).toEqual(['api-key', 'configured-token']); + mutate(headers); + return [...iterator]; + }; + const expected = run(new Headers([['api-key', 'configured-token']])); + let observed: readonly [string, string][] = []; + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.mutateCarrier = (headers) => { + observed = run(headers); + }; + + await client.request({ method: 'get', path: '/models' }); + + expect(observed).toEqual(expected); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test.each(['add', 'delete'] as const)( + 'keeps an active deferred Headers iterator coherent when authentication tombstones %s', + async (operation) => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.inspectAuthenticationCarrier = (carrier) => { + carrier.values.set('x-a', 'first'); + carrier.values.set('x-b', 'middle'); + carrier.values.set('x-c', 'last'); + if (operation === 'delete') { + carrier.nulls.add('x-b'); + } + const iterator = carrier.values.entries(); + expect(iterator.next().value).toEqual(['api-key', 'configured-token']); + expect(iterator.next().value).toEqual(['x-a', 'first']); + if (operation === 'add') { + carrier.nulls.add('x-b'); + } else { + carrier.nulls.delete('x-b'); + } + expect([...iterator]).toEqual( + operation === 'add' + ? [['x-c', 'last']] + : [ + ['x-b', 'middle'], + ['x-c', 'last'], + ], + ); + }; + + await client.request({ method: 'get', path: '/models' }); + + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['add', 'delete', 'clear', 'replace'] as const)( + 'keeps a deferred iterator coherent across intrinsic authentication tombstone %s', + async (operation) => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.inspectAuthenticationCarrier = (carrier) => { + carrier.values.set('x-a', 'first'); + carrier.values.set('x-b', 'middle'); + carrier.values.set('x-c', 'last'); + if (operation !== 'add') { + carrier.nulls.add('x-b'); + } + const iterator = carrier.values.entries(); + iterator.next(); + iterator.next(); + if (operation === 'add') { + Set.prototype.add.call(carrier.nulls, 'x-b'); + } else if (operation === 'delete') { + Set.prototype.delete.call(carrier.nulls, 'x-b'); + } else if (operation === 'replace') { + Set.prototype.delete.call(carrier.nulls, 'x-b'); + Set.prototype.add.call(carrier.nulls, 'x-c'); + } else { + Set.prototype.clear.call(carrier.nulls); + } + const expected: [string, string][] = []; + if (operation !== 'add') { + expected.push(['x-b', 'middle']); + } + if (operation !== 'replace') { + expected.push(['x-c', 'last']); + } + expect([...iterator]).toEqual(expected); + }; + + await client.request({ method: 'get', path: '/models' }); + + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test('observes an equally sized intrinsic authentication-tombstone swap farther ahead', async () => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.inspectAuthenticationCarrier = (carrier) => { + for (const name of ['x-a', 'x-b', 'x-c', 'x-d', 'x-z']) { + carrier.values.set(name, name); + } + carrier.nulls.add('x-b'); + const iterator = carrier.values.entries(); + iterator.next(); + iterator.next(); + Set.prototype.delete.call(carrier.nulls, 'x-b'); + Set.prototype.add.call(carrier.nulls, 'x-d'); + expect([...iterator]).toEqual([ + ['x-b', 'x-b'], + ['x-c', 'x-c'], + ['x-z', 'x-z'], + ]); + }; + + await client.request({ method: 'get', path: '/models' }); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('never reads hostile own Set.size accessors while iterating deferred authentication', async () => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const fail = vi.fn(() => { + throw Object.assign(new Error(credential), { cause: new Error(credential) }); + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.inspectAuthenticationCarrier = (carrier) => { + Object.defineProperty(carrier.nulls, 'size', { configurable: true, get: fail }); + expect([...carrier.values]).toEqual([['api-key', 'configured-token']]); + }; + + await client.request({ method: 'get', path: '/models' }); + + expect(fail).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test.each(['delete absent', 'replace unchanged'] as const)( + 'does not repeatedly rebuild a live iterator during %s mutations', + async (operation) => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.inspectAuthenticationCarrier = (carrier) => { + for (let index = 0; index < 12; index += 1) { + carrier.values.set(`x-${index}`, String(index)); + } + const inspectNulls = vi.spyOn(carrier.nulls, Symbol.iterator); + const iterate = carrier.values.forEach; + iterate.call(carrier.values, (value, name, headers) => { + if (operation === 'delete absent') { + headers.delete('x-never-present'); + } else { + headers.set(name, value); + } + }); + expect(inspectNulls.mock.calls.length).toBeLessThanOrEqual(4); + }; + + await client.request({ method: 'get', path: '/models' }); + + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'sanitizes a malformed $name mutation reached through live deferred iteration', + async (name) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.mutateCarrier = (headers) => { + const iterate = headers.forEach; + iterate.call(headers, (_value, current, owner) => { + if (current === 'api-key') { + owner.set('x-next', 'visited'); + } + if (current === 'x-next') { + owner.set(name, credential); + } + }); + }; + + await expectPrivateCredentialFailure( + () => client.request({ method: 'get', path: '/models' }), + credential, + ); + + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + const deferredBoundaryScenarios = (['auth', 'bearer', 'admin'] as const).flatMap((scheme) => + [ + { boundary: 'ASCII edge whitespace', credential: ' \tvisible \t ' }, + { boundary: 'internal SP and HTAB', credential: 'in ter\tnal' }, + { boundary: 'valid obs-text', credential: '\u00A0visible\u00A0' }, + ].map(({ boundary, credential }) => ({ scheme, boundary, credential })), + ); + + test.each(deferredBoundaryScenarios)( + 'normalizes deferred $scheme $boundary exactly like native Headers', + async ({ scheme, credential }) => { + const expectedName = scheme === 'auth' ? 'api-key' : 'authorization'; + const raw = scheme === 'auth' ? credential : `Bearer ${credential}`; + const expected = new Headers([[expectedName, raw]]).get(expectedName); + const provider = vi.fn(async () => credential); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(scheme === 'auth' + ? { apiKey: credential } + : { azureADTokenProvider: provider, adminAPIKey: credential }), + fetch, + maxRetries: 0, + }); + client.mutationScheme = scheme; + client.mutateCarrier = (headers) => { + expect(headers.get(expectedName.toUpperCase())).toBe(expected); + expect(headers.has(expectedName.toUpperCase())).toBe(true); + expect([...headers.entries()].find(([name]) => name === expectedName)?.[1]).toBe(expected); + expect([...headers.keys()]).toContain(expectedName); + expect([...headers.values()]).toContain(expected); + expect([...headers].find(([name]) => name === expectedName)?.[1]).toBe(expected); + const observed: string[] = []; + const iterate = headers.forEach; + iterate.call(headers, (value, name) => { + if (name === expectedName) { + observed.push(value); + } + }); + expect(observed).toEqual([expected]); + }; + + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: true, adminAPIKeyAuth: scheme === 'admin' }, + }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(expectedName)).toBe(expected); + expect(provider).toHaveBeenCalledTimes(scheme === 'auth' ? 0 : 1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test('normalizes every deferred authentication value before combining duplicates', async () => { + const first = ' \tfirst \t '; + const second = '\t second \t'; + const native = new Headers([['api-key', first]]); + native.append('api-key', second); + const expected = native.get('api-key'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: first, + fetch, + maxRetries: 0, + }); + client.mutateCarrier = (headers) => { + headers.append('API-KEY', second); + expect(headers.get('api-key')).toBe(expected); + expect([...headers.entries()]).toContainEqual(['api-key', expected]); + }; + + await client.request({ method: 'get', path: '/models' }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe(expected); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('keeps deferred Headers reads coherent across malformed shadows and visible mutations', async () => { + const malformed = [PRIVATE_CREDENTIAL, PRIVATE_SUFFIX].join('\n'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: malformed, + fetch, + maxRetries: 0, + }); + client.mutateCarrier = (headers) => { + expect(headers.get('API-KEY')).toBe(malformed); + expect([...headers.entries()]).toEqual([['api-key', malformed]]); + + headers.set('API-KEY', 'safe-shadow'); + headers.append('api-key', 'safe-suffix'); + expect(headers.get('api-key')).toBe('safe-shadow, safe-suffix'); + + headers.set('Z-Extra', 'last'); + headers.set('A-Extra', 'first'); + expect([...headers.keys()]).toEqual(['a-extra', 'api-key', 'z-extra']); + + headers.delete('aPi-KeY'); + expect(headers.has('API-KEY')).toBe(false); + + headers.set('api-key', malformed); + expect(headers.get('API-KEY')).toBe(malformed); + headers.set('API-KEY', 'safe-final'); + expect([...headers]).toEqual([ + ['a-extra', 'first'], + ['api-key', 'safe-final'], + ['z-extra', 'last'], + ]); + }; + + await client.request({ method: 'get', path: '/models' }); + const headers = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(headers.get('api-key')).toBe('safe-final'); + expect(headers.get('a-extra')).toBe('first'); + expect(headers.get('z-extra')).toBe('last'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('snapshots a deferred Azure default-header getter once across reads and final dispatch', async () => { + const malformed = [PRIVATE_CREDENTIAL, PRIVATE_SUFFIX].join('\n'); + let reads = 0; + const defaults: Record = {}; + Object.defineProperty(defaults, 'API-KEY', { + enumerable: true, + get() { + reads += 1; + return reads === 1 ? 'safe-snapshot-token' : malformed; + }, + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.inspectDeferredDefaultHeaders(defaults, (headers) => { + expect(headers.get('api-key')).toBe('safe-snapshot-token'); + expect(headers.has('API-KEY')).toBe(true); + expect([...headers.entries()]).toEqual([['api-key', 'safe-snapshot-token']]); + }); + expect(reads).toBe(1); + + await client.request({ method: 'get', path: '/models' }); + expect(reads).toBe(1); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('safe-snapshot-token'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test.each( + (['bearer', 'admin'] as const).flatMap((scheme) => + (['read', 'append', 'set', 'delete', 'null'] as const).map((operation) => ({ scheme, operation })), + ), + )( + 'preserves individual protected $scheme Set-Cookie values through deferred $operation', + async ({ scheme, operation }) => { + const first = 'session=first; Expires=Wed, 21 Oct 2015 07:28:00 GMT'; + const second = 'preference=second; Path=/'; + const provider = vi.fn(async () => 'safe-provider-token'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + azureADTokenProvider: provider, + adminAPIKey: 'safe-admin-token', + fetch, + maxRetries: 0, + }); + client.mutationScheme = scheme; + client.mutateCarrier = (headers) => { + headers.append('Set-Cookie', ` ${first} `); + headers.append('set-cookie', second); + }; + let expected = [first, second]; + client.inspectAuthenticationCarrier = (carrier) => { + const expectCookieIteration = (): void => { + const expectedEntries = expected.map((value) => ['set-cookie', value]); + expect([...carrier.values.entries()].filter(([name]) => name === 'set-cookie')).toEqual( + expectedEntries, + ); + expect([...carrier.values.keys()].filter((name) => name === 'set-cookie')).toEqual( + expected.map(() => 'set-cookie'), + ); + expect( + [...carrier.values.values()].filter( + (value) => + value.startsWith('session=') || value.startsWith('preference=') || value.includes('=value'), + ), + ).toEqual(expected); + expect([...carrier.values].filter(([name]) => name === 'set-cookie')).toEqual(expectedEntries); + const visited: [string, string][] = []; + const visitCookies = carrier.values.forEach; + visitCookies.call(carrier.values, (value, name, parent) => { + if (name === 'set-cookie') { + expect(parent).toBe(carrier.values); + visited.push([name, value]); + } + }); + expect(visited).toEqual(expectedEntries); + expect(carrier.values.get('set-cookie')).toBe(expected.length === 0 ? null : expected.join(', ')); + }; + + expect(carrier.values.getSetCookie()).toEqual(expected); + expectCookieIteration(); + expect(Object.getOwnPropertyDescriptor(carrier.values, 'getSetCookie')).toBeUndefined(); + expect( + typeof Object.getOwnPropertyDescriptor(Object.getPrototypeOf(carrier.values), 'getSetCookie') + ?.value, + ).toBe('function'); + + if (operation === 'append') { + carrier.values.append('Set-Cookie', 'third=value'); + expected = [...expected, 'third=value']; + } else if (operation === 'set') { + carrier.values.set('set-cookie', 'replacement=value'); + expected = ['replacement=value']; + } else if (operation === 'delete') { + carrier.values.delete('SET-COOKIE'); + expected = []; + } else if (operation === 'null') { + carrier.nulls.add('set-cookie'); + expected = []; + } + + expect(carrier.values.getSetCookie()).toEqual(expected); + expectCookieIteration(); + const detached = carrier.values.getSetCookie; + expect(() => detached()).toThrow(TypeError); + }; + + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: true, adminAPIKeyAuth: scheme === 'admin' }, + }); + + const dispatched = fetch.mock.calls[0]?.[1]?.headers; + expect(dispatched).toBeInstanceOf(Headers); + if (dispatched instanceof Headers) { + expect(dispatched.getSetCookie()).toEqual(expected); + } + expect(provider).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['auth', 'bearer', 'admin'] as const)( + 'keeps deferred $scheme Headers operations on their native prototype', + async (scheme) => { + const configured = 'prototype-credential'; + const expectedName = scheme === 'auth' ? 'api-key' : 'authorization'; + const expected = scheme === 'auth' ? configured : `Bearer ${configured}`; + const provider = vi.fn(async () => configured); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(scheme === 'auth' + ? { apiKey: configured } + : { azureADTokenProvider: provider, adminAPIKey: configured }), + fetch, + maxRetries: 0, + }); + client.mutationScheme = scheme; + client.mutateCarrier = (headers) => { + expect(Object.keys(headers)).toEqual(Object.keys(new Headers())); + expect({ ...headers }).toEqual({ ...new Headers() }); + const copied = {}; + const native = {}; + expect(Object.assign(copied, headers)).toEqual(Object.assign(native, new Headers())); + for (const method of [ + 'get', + 'getSetCookie', + 'has', + 'entries', + 'keys', + 'values', + 'forEach', + 'append', + 'set', + 'delete', + ]) { + expect(Object.getOwnPropertyDescriptor(headers, method)).toBeUndefined(); + expect(typeof Object.getOwnPropertyDescriptor(Object.getPrototypeOf(headers), method)?.value).toBe( + 'function', + ); + } + expect(Object.getOwnPropertyDescriptor(headers, Symbol.iterator)).toBeUndefined(); + expect(new Headers(headers).get(expectedName)).toBe(expected); + const detached = headers.get; + expect(() => detached(expectedName)).toThrow(TypeError); + }; + + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: true, adminAPIKeyAuth: scheme === 'admin' }, + }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(expectedName)).toBe(expected); + expect(provider).toHaveBeenCalledTimes(scheme === 'auth' ? 0 : 1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + const coercedCredentialCases = authenticationModes.flatMap((authentication) => + (['api-key', 'Authorization'] as const).flatMap((header) => + (['object', 'proxy'] as const).flatMap((representation) => + (['unsafe serialization', 'safe serialization'] as const).map((direction) => ({ + authentication, + header, + representation, + direction, + })), + ), + ), + ); + + test.each(coercedCredentialCases)( + '$authentication snapshots $representation $header $direction exactly once', + async ({ authentication, header, representation, direction }) => { + const malformed = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const serialized = direction === 'unsafe serialization' ? malformed : 'safe-coerced-token'; + const iterated = direction === 'unsafe serialization' ? 'safe-iterator-value' : malformed; + let coercions = 0; + let iteratorReads = 0; + const source = { + *[Symbol.iterator](): IterableIterator { + iteratorReads += 1; + yield* iterated; + }, + toString(): string { + coercions += 1; + return serialized; + }, + }; + const credential = + representation === 'proxy' + ? new Proxy(source, { + get(target, property, receiver) { + return Reflect.get(target, property, receiver); + }, + }) + : source; + const headers: Record = {}; + Object.defineProperty(headers, header, { enumerable: true, value: credential }); + const provider = vi.fn(async () => 'safe-provider-token'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'safe-configured-token' } + : { azureADTokenProvider: provider }), + fetch, + maxRetries: 0, + }); + const operation = () => client.request({ method: 'get', path: '/models', headers }); + + if (direction === 'unsafe serialization') { + await expectPrivateCredentialFailure(operation, malformed); + expect(fetch).not.toHaveBeenCalled(); + } else { + await operation(); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(header)).toBe(serialized); + expect(fetch).toHaveBeenCalledTimes(1); + } + + expect(coercions).toBe(1); + expect(iteratorReads).toBe(0); + expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'does not coerce a shadowed $header credential before final overrides', + async (header) => { + let coercions = 0; + const shadowed = { + *[Symbol.iterator](): IterableIterator { + yield* 'safe-iterator-value'; + }, + toString(): string { + coercions += 1; + return `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + }, + }; + const defaults: Record = {}; + Object.defineProperty(defaults, header, { enumerable: true, value: shadowed }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'safe-configured-token', + defaultHeaders: defaults, + fetch, + maxRetries: 0, + }); + + await client.request({ method: 'get', path: '/models', headers: { [header]: 'safe-final-token' } }); + + expect(coercions).toBe(0); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(header)).toBe('safe-final-token'); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + const carrierCloneCases = (['spread', 'assign'] as const).flatMap((clone) => + (['auth', 'bearer', 'admin'] as const).map((scheme) => ({ clone, scheme })), + ); + + test.each(carrierCloneCases)( + 'preserves deferred $scheme authentication through a $clone carrier clone', + async ({ clone, scheme }) => { + const configured = 'cloned-credential'; + const expectedName = scheme === 'auth' ? 'api-key' : 'authorization'; + const expected = scheme === 'auth' ? configured : `Bearer ${configured}`; + const provider = vi.fn(async () => configured); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(scheme === 'auth' + ? { apiKey: configured } + : { azureADTokenProvider: provider, adminAPIKey: configured }), + fetch, + maxRetries: 0, + }); + client.cloneAuthenticationCarrier = clone; + client.mutationScheme = scheme; + client.mutateCarrier = (headers) => { + expect(headers).toBeInstanceOf(Headers); + }; + + await client.request({ + method: 'get', + path: '/models', + __security: { bearerAuth: true, adminAPIKeyAuth: scheme === 'admin' }, + }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(expectedName)).toBe(expected); + expect(provider).toHaveBeenCalledTimes(scheme === 'auth' ? 0 : 1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['spread', 'assign'] as const)( + 'preserves an explicit null tombstone through a $clone carrier clone', + async (clone) => { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'suppressed-credential', + fetch, + maxRetries: 0, + }); + client.cloneAuthenticationCarrier = clone; + client.mutation = 'auth-null'; + + await client.request({ method: 'get', path: '/models' }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).has('api-key')).toBe(false); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); +}); + +describe('Azure deferred credential and request registration regressions', () => { + test.each(['api-key', 'Authorization'] as const)( + 'coerces an object-backed deferred $header before virtual reads and iteration', + (header) => { + const headers: Record = {}; + Object.defineProperty(headers, header, { + enumerable: true, + value: { toString: () => ' object-backed-token ' }, + }); + const carrier = buildAzureAuthenticationHeaders(headers); + + expect(carrier.values.get(header)).toBe('object-backed-token'); + expect([...carrier.values]).toContainEqual([header.toLowerCase(), 'object-backed-token']); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'sanitizes throwing $header getters and credential coercion hooks', + (header) => { + const secret = `${PRIVATE_CREDENTIAL}-${PRIVATE_SUFFIX}`; + const source: Record = {}; + Object.defineProperty(source, header, { + enumerable: true, + get() { + throw new Error(secret); + }, + }); + const credential: Record = {}; + Object.defineProperty(credential, header, { + enumerable: true, + value: { + toString() { + throw new Error(secret); + }, + }, + }); + + expect(() => buildAzureAuthenticationHeaders(source).values.get(header)).toThrow(SAFE_ERROR); + expect(() => buildAzureAuthenticationHeaders(credential).values.get(header)).toThrow(SAFE_ERROR); + }, + ); + + test('snapshots an inherited rotating request headers accessor exactly once', async () => { + const records = [{ 'api-key': 'tenant-a-token' }, { 'api-key': 'tenant-b-token' }]; + let reads = 0; + const prototype = { + get headers() { + const index = reads; + reads += 1; + return records[index]; + }, + }; + const options = Object.assign(Object.create(prototype) as FinalRequestOptions, { + method: 'post' as const, + path: '/models', + body: { safe: true }, + }); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + maxRetries: 0, + }); + + const built = await client.buildRequest(options); + + expect(built.req.headers.get('api-key')).toBe('tenant-a-token'); + expect(reads).toBe(1); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toBeUndefined(); + expect(Object.getPrototypeOf(options)).toBe(prototype); + }); + + test('retains protected custom authentication for a nonextensible Azure client', async () => { + const observed: FinalRequestOptions[] = []; + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.observeProtectedHookOptions = (hook, options) => { + if (hook === 'auth') { + observed.push(options); + } + }; + Object.preventExtensions(client); + + await client.request({ + method: 'post', + path: '/models', + body: { safe: true }, + headers: { 'api-key': 'request-token' }, + }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('request-token'); + expect(observed).toHaveLength(1); + expect(Object.getOwnPropertyDescriptor(client, 'authHeaders')).toBeUndefined(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('isolates concurrent nonextensible requests sharing tenant headers', async () => { + const releases: (() => void)[] = []; + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + maxRetries: 0, + }); + client.observeAuthenticationOptions = async () => { + const gate = new AbortController(); + releases.push(() => gate.abort()); + await once(gate.signal, 'abort'); + }; + Object.preventExtensions(client); + const headers = { 'api-key': 'tenant-a-token' }; + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { safe: true }, + headers, + }; + + const first = client.buildRequest(options); + headers['api-key'] = 'tenant-b-token'; + const second = client.buildRequest(options); + expect(releases).toHaveLength(2); + + releases[0]?.(); + const firstBuilt = await first; + releases[1]?.(); + const secondBuilt = await second; + expect(firstBuilt.req.headers.get('api-key')).toBe('tenant-a-token'); + expect(secondBuilt.req.headers.get('api-key')).toBe('tenant-b-token'); + expect(Object.getOwnPropertyDescriptor(client, 'authHeaders')).toBeUndefined(); + }); + + test('preserves more than 1,024 genuine cross-realm undici header fields', async () => { + const entries: [string, string][] = [['api-key', 'foreign-tenant-token']]; + for (let index = 0; index < 1200; index += 1) { + entries.push([`x-foreign-header-${index}`, `value-${index}`]); + } + const injected = createForeignHeaders(entries); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ProtectedHookAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-token', + fetch, + maxRetries: 0, + }); + client.injectedHeaders = injected; + + await client.request({ method: 'get', path: '/models' }); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get('api-key')).toBe('foreign-tenant-token'); + expect(sent.get('x-foreign-header-1199')).toBe('value-1199'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test.each([ + 'union', + 'intersection', + 'difference', + 'symmetricDifference', + 'isSubsetOf', + 'isSupersetOf', + 'isDisjointFrom', + ] as const)('initializes deferred authentication tombstones before Set.%s', (operation) => { + const carrier = buildAzureAuthenticationHeaders({ 'api-key': null }); + const method: unknown = Reflect.get(carrier.nulls, operation); + if (typeof method !== 'function') { + return; + } + + const argument = operation === 'isSubsetOf' ? new Set() : new Set(['api-key']); + const result: unknown = Reflect.apply(method, carrier.nulls, [argument]); + + if (result instanceof Set) { + expect(result.has('api-key')).toBe(operation === 'union' || operation === 'intersection'); + } else { + expect(result).toBe(operation === 'isSupersetOf'); + } + }); +}); diff --git a/tests/lib/azure-deployment-path-safety.test.ts b/tests/lib/azure-deployment-path-safety.test.ts index 98300d196..7e5308803 100644 --- a/tests/lib/azure-deployment-path-safety.test.ts +++ b/tests/lib/azure-deployment-path-safety.test.ts @@ -4,7 +4,7 @@ import type { RequestInit, RequestInfo, Response } from 'openai/internal/builtin const apiVersion = '2024-02-15-preview'; const testFetch = async (url: RequestInfo): Promise => - Response.json({ url }, { headers: { 'content-type': 'application/json' } }); + globalThis.Response.json({ url }, { headers: { 'content-type': 'application/json' } }); describe('deployment path safety', () => { const endpoint = 'https://azure.example.com'; @@ -27,12 +27,13 @@ describe('deployment path safety', () => { const requestClient = new AzureOpenAI({ endpoint, apiKey, apiVersion, fetch: testFetch }); test('keeps authenticated public chat requests inside the deployment route', async () => { - const authenticatedFetch = vi.fn(async (url: RequestInfo, init?: RequestInit): Promise => - Response.json( + const authenticatedFetch = vi.fn(async (url: RequestInfo, init?: RequestInit): Promise => { + const headers = { 'content-type': 'application/json' }; + return globalThis.Response.json( { url, apiKey: new Headers(init?.headers).get('api-key') }, - { headers: { 'content-type': 'application/json' } }, - ), - ); + { headers }, + ); + }); const client = new AzureOpenAI({ endpoint, apiKey, apiVersion, fetch: authenticatedFetch }); expect( diff --git a/tests/lib/azure-post-hook-proxy-privacy.test.ts b/tests/lib/azure-post-hook-proxy-privacy.test.ts new file mode 100644 index 000000000..8ad734a79 --- /dev/null +++ b/tests/lib/azure-post-hook-proxy-privacy.test.ts @@ -0,0 +1,105 @@ +import { vi } from 'vitest'; + +import { APIConnectionError, AzureOpenAI } from 'openai'; +import type { RequestInit } from 'openai/internal/builtin-types'; + +const PRIVATE_CREDENTIAL = 'azure-private-credential-75da'; +const PRIVATE_SUFFIX = 'private-patient-record-21f8'; +const SAFE_ERROR = 'Azure OpenAI credential contains an invalid HTTP header value.'; + +class PostHookProxyAzure extends AzureOpenAI { + suppliedHeaders: NonNullable = []; + + protected override async prepareRequest(request: RequestInit): Promise { + request.headers = this.suppliedHeaders; + } +} + +describe('Azure post-hook proxy credential privacy', () => { + test.each( + (['ownKeys', 'getOwnPropertyDescriptor', 'getPrototypeOf'] as const).flatMap((operation) => + (['off', 'debug'] as const).flatMap((logLevel) => + (['api-key', 'Authorization'] as const).map((header) => ({ operation, logLevel, header })), + ), + ), + )( + 'sanitizes $header thrown by $operation before $logLevel logging or dispatch', + async ({ header, logLevel, operation }) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const fail = vi.fn(() => { + throw Object.assign(new Error(credential), { cause: new Error(credential) }); + }); + const headers = new Proxy( + { [header]: credential }, + { + ownKeys(target) { + return operation === 'ownKeys' ? fail() : Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor(target, property) { + return operation === 'getOwnPropertyDescriptor' && property === header + ? fail() + : Reflect.getOwnPropertyDescriptor(target, property); + }, + getPrototypeOf(target) { + return operation === 'getPrototypeOf' ? fail() : Reflect.getPrototypeOf(target); + }, + }, + ); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const fetch = vi.fn(async () => globalThis.Response.json({ ok: true })); + const client = new PostHookProxyAzure({ + baseURL: 'https://azure-resource.example.com/openai', + apiVersion: '2024-02-15-preview', + apiKey: 'safe-configured-token', + fetch, + logger, + logLevel, + maxRetries: 0, + }); + client.suppliedHeaders = headers; + + let failure: unknown; + try { + await client.request({ method: 'get', path: '/models' }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(APIConnectionError); + if (!(failure instanceof APIConnectionError)) { + throw new Error('Expected Azure authentication failures to retain their connection wrapper.'); + } + const { cause } = failure as APIConnectionError & { cause?: unknown }; + expect(cause).toBeInstanceOf(TypeError); + if (!(cause instanceof TypeError)) { + throw new Error('Expected Azure proxy authentication failures to have a sanitized TypeError cause.'); + } + + expect(cause.message).toBe(SAFE_ERROR); + expect((cause as TypeError & { cause?: unknown }).cause).toBeUndefined(); + const logs = JSON.stringify([ + ...logger.debug.mock.calls, + ...logger.info.mock.calls, + ...logger.warn.mock.calls, + ...logger.error.mock.calls, + ]); + for (const diagnostic of [ + failure.message, + failure.stack ?? '', + cause.message, + cause.stack ?? '', + logs, + ]) { + expect(diagnostic).not.toContain(PRIVATE_CREDENTIAL); + expect(diagnostic).not.toContain(PRIVATE_SUFFIX); + } + expect(fail).toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/tests/lib/azure-pr2421-carrier-review.test.ts b/tests/lib/azure-pr2421-carrier-review.test.ts new file mode 100644 index 000000000..bf073f812 --- /dev/null +++ b/tests/lib/azure-pr2421-carrier-review.test.ts @@ -0,0 +1,277 @@ +import { once } from 'node:events'; +import { vi } from 'vitest'; + +import { AzureOpenAI } from 'openai'; +import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; +import { buildAzureAuthenticationHeaders } from 'openai/internal/headers'; +import type { NullableHeaders } from 'openai/internal/headers'; +import type { FinalRequestOptions } from 'openai/internal/request-options'; + +const BASE_URL = 'https://azure-resource.example.com/openai'; +const API_VERSION = '2024-02-15-preview'; +const SAFE_ERROR = 'Azure OpenAI credential contains an invalid HTTP header value.'; +const PRIVATE_CREDENTIAL = 'private-pr2421-carrier-credential-4b9a'; +const intrinsicSetDelete = Set.prototype.delete; + +class CarrierReviewAzure extends AzureOpenAI { + suppliedAuthenticationCarrier: NullableHeaders | undefined; + observeAuthentication: + | ((options: FinalRequestOptions, carrier: NullableHeaders) => Promise | void) + | undefined; + + protected override async authHeaders( + options: FinalRequestOptions, + schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, + ): Promise { + const carrier = this.suppliedAuthenticationCarrier ?? (await super.authHeaders(options, schemes)); + if (carrier !== undefined) { + await this.observeAuthentication?.(options, carrier); + } + return carrier; + } +} + +function createClient() { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => + globalThis.Response.json({ ok: true }), + ); + const client = new CarrierReviewAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-tenant-token', + fetch, + maxRetries: 0, + }); + return { client, fetch }; +} + +describe('Azure request-local authentication carrier review regressions', () => { + test.each(['get', 'post'] as const)( + 'enumerates a stateful %s request-header proxy exactly once before dispatch', + async (method) => { + const { client, fetch } = createClient(); + let enumerations = 0; + const headers = new Proxy( + { 'api-key': 'request-tenant-token', 'x-request-metadata': 'preserved' }, + { + ownKeys(target) { + enumerations += 1; + if (enumerations !== 1) { + throw Object.assign(new Error(PRIVATE_CREDENTIAL), { cause: new Error(PRIVATE_CREDENTIAL) }); + } + return Reflect.ownKeys(target); + }, + }, + ); + + await client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + headers, + }); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get('api-key')).toBe('request-tenant-token'); + expect(sent.get('x-request-metadata')).toBe('preserved'); + expect(enumerations).toBe(1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['ownKeys', 'getOwnPropertyDescriptor'] as const)( + 'sanitizes a credential-bearing request proxy %s failure', + async (operation) => { + const { client, fetch } = createClient(); + const fail = () => { + throw Object.assign(new Error(PRIVATE_CREDENTIAL), { cause: new Error(PRIVATE_CREDENTIAL) }); + }; + const headers = new Proxy( + { 'api-key': 'request-tenant-token' }, + { + ownKeys(target) { + return operation === 'ownKeys' ? fail() : Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor(target, name) { + return operation === 'getOwnPropertyDescriptor' && name === 'api-key' + ? fail() + : Reflect.getOwnPropertyDescriptor(target, name); + }, + }, + ); + + let failure: unknown; + try { + await client.request({ method: 'get', path: '/models', headers }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(TypeError); + if (!(failure instanceof TypeError)) { + throw new Error('Expected a sanitized Azure credential failure.'); + } + expect(failure.message).toBe(SAFE_ERROR); + expect((failure as TypeError & { cause?: unknown }).cause).toBeUndefined(); + expect(failure.stack).not.toContain(PRIVATE_CREDENTIAL); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test('enumerates a shared stateful proxy once for each isolated overlapping request', async () => { + const { client, fetch } = createClient(); + let enumerations = 0; + const source = { 'api-key': 'first-tenant-token', 'x-request-metadata': 'first' }; + const headers = new Proxy(source, { + ownKeys(target) { + enumerations += 1; + if (enumerations > 2) { + throw new Error(PRIVATE_CREDENTIAL); + } + return Reflect.ownKeys(target); + }, + }); + const gates: AbortController[] = []; + client.observeAuthentication = async () => { + const gate = new AbortController(); + gates.push(gate); + await once(gate.signal, 'abort'); + }; + + const first = client.request({ method: 'get', path: '/models', headers }); + await vi.waitFor(() => expect(gates).toHaveLength(1), { interval: 1 }); + source['api-key'] = 'second-tenant-token'; + source['x-request-metadata'] = 'second'; + const second = client.request({ method: 'get', path: '/models', headers }); + await vi.waitFor(() => expect(gates).toHaveLength(2), { interval: 1 }); + + gates[1]?.abort(); + await second; + gates[0]?.abort(); + await first; + + expect(enumerations).toBe(2); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('second-tenant-token'); + expect(new Headers(fetch.mock.calls[1]?.[1]?.headers).get('api-key')).toBe('first-tenant-token'); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + test.each(['api-key', 'authorization'] as const)( + 'exposes inherited %s tombstones immediately to a captured native Set.delete', + async (name) => { + const { client, fetch } = createClient(); + client.suppliedAuthenticationCarrier = buildAzureAuthenticationHeaders({ [name]: null }); + client.observeAuthentication = (_options, carrier) => { + expect(intrinsicSetDelete.call(carrier.nulls, name)).toBe(true); + carrier.values.set(name, name === 'authorization' ? 'Bearer restored-token' : 'restored-token'); + }; + + await client.request({ method: 'get', path: '/models' }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(name)).toBe( + name === 'authorization' ? 'Bearer restored-token' : 'restored-token', + ); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['delete', 'clear'] as const)( + 'never replays an inherited tombstone removed first by captured native Set.%s', + async (operation) => { + const { client, fetch } = createClient(); + client.apiKey = null; + client.observeAuthentication = (_options, carrier) => { + if (operation === 'delete') { + expect(intrinsicSetDelete.call(carrier.nulls, 'api-key')).toBe(true); + } else { + Set.prototype.clear.call(carrier.nulls); + } + }; + + await expect(client.request({ method: 'get', path: '/models' })).rejects.toThrow( + 'Could not resolve authentication method.', + ); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each(['get', 'post'] as const)( + 'keeps ordinary %s request headers live while isolating the credential snapshot', + async (method) => { + const { client, fetch } = createClient(); + const headers = { 'api-key': 'original-tenant-token', 'x-request-metadata': 'before-authentication' }; + client.observeAuthentication = (options) => { + expect(options.headers).toBe(headers); + headers['api-key'] = 'different-tenant-token'; + headers['x-request-metadata'] = 'updated-during-authentication'; + }; + + await client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + headers, + }); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get('api-key')).toBe('original-tenant-token'); + expect(sent.get('x-request-metadata')).toBe('updated-during-authentication'); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each([ + { + description: 'array-to-array replacement', + initial: ['before-first', 'before-second'], + updated: ['after-first', 'after-second'], + expected: 'after-first, after-second', + }, + { + description: 'array-to-scalar replacement', + initial: ['before-first', 'before-second'], + updated: 'after-scalar', + expected: 'after-scalar', + }, + { + description: 'scalar-to-array replacement', + initial: 'before-scalar', + updated: ['after-first', 'after-second'], + expected: 'after-first, after-second', + }, + ])( + 'keeps ordinary request header $description live during protected authentication', + async ({ initial, updated, expected }) => { + const { client, fetch } = createClient(); + const headers: Record = { + 'api-key': 'original-tenant-token', + 'x-request-metadata': initial, + }; + client.observeAuthentication = () => { + headers['x-request-metadata'] = updated; + }; + + await client.request({ method: 'get', path: '/models', headers }); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get('api-key')).toBe('original-tenant-token'); + expect(sent.get('x-request-metadata')).toBe(expected); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test('preserves the original error from an unrelated ordinary request-header getter', async () => { + const { client, fetch } = createClient(); + const failure = new Error('ordinary request metadata failed'); + const headers: Record = { 'api-key': 'request-tenant-token' }; + Object.defineProperty(headers, 'x-request-metadata', { + enumerable: true, + get() { + throw failure; + }, + }); + + await expect(client.request({ method: 'get', path: '/models', headers })).rejects.toBe(failure); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/lib/azure-pr2421-occurrence-review.test.ts b/tests/lib/azure-pr2421-occurrence-review.test.ts new file mode 100644 index 000000000..3cac6f873 --- /dev/null +++ b/tests/lib/azure-pr2421-occurrence-review.test.ts @@ -0,0 +1,472 @@ +import { vi } from 'vitest'; + +import { AzureOpenAI } from 'openai'; +import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; +import type { HeadersLike } from 'openai/internal/headers'; + +const BASE_URL = 'https://azure-resource.example.com/openai'; +const API_VERSION = '2024-02-15-preview'; +const SAFE_ERROR = 'Azure OpenAI credential contains an invalid HTTP header value.'; + +describe('Azure authentication header occurrence snapshots', () => { + const requestCredentialCases = (['get', 'post'] as const).flatMap((method) => + (['api-key', 'Authorization'] as const).map((name) => ({ method, name })), + ); + const repeatedCredentialCases = (['get', 'post'] as const).flatMap((method) => + (['api-key', 'Authorization'] as const).flatMap((name) => + (['tuple entries', 'record array'] as const).map((representation) => ({ + method, + name, + representation, + })), + ), + ); + + test.each(repeatedCredentialCases)( + 'snapshots repeated $method $name credentials independently in $representation', + async ({ method, name, representation }) => { + let coercions = 0; + const credential = { + toString(): string { + coercions += 1; + return coercions === 1 ? 'tenant-a-token' : 'tenant-b-token'; + }, + }; + let headers: HeadersLike; + if (representation === 'tuple entries') { + const tuples: [string, string][] = [ + [name, 'first-placeholder'], + [name, 'second-placeholder'], + ]; + for (const tuple of tuples) { + Object.defineProperty(tuple, 1, { value: credential }); + } + headers = tuples; + } else { + const record: Record = {}; + Object.defineProperty(record, name, { + enumerable: true, + value: [credential, credential], + }); + headers = record; + } + + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-tenant-token', + fetch, + maxRetries: 0, + }); + + await client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + headers, + }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(name)).toBe('tenant-a-token, tenant-b-token'); + expect(coercions).toBe(2); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'snapshots the effective %s credential before probing a later tuple metadata value', + async (name) => { + let effective = 'tenant-a-token'; + let coercions = 0; + let metadataReads = 0; + const defaults: Record = {}; + Object.defineProperty(defaults, name, { + enumerable: true, + value: { + toString(): string { + coercions += 1; + return effective; + }, + }, + }); + + const metadata: [string, string] = ['x-metadata', 'placeholder']; + Object.defineProperty(metadata, 1, { + configurable: true, + enumerable: true, + get(): string { + metadataReads += 1; + effective = 'tenant-b-token'; + return 'preserved'; + }, + }); + + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-tenant-token', + defaultHeaders: defaults, + fetch, + maxRetries: 0, + }); + + await client.request({ + method: 'get', + path: '/models', + headers: [metadata], + }); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get(name)).toBe('tenant-a-token'); + expect(sent.get('x-metadata')).toBe('preserved'); + expect(coercions).toBe(1); + expect(metadataReads).toBeGreaterThan(0); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(requestCredentialCases)( + 'rejects a $method tuple-name accessor before it can mutate an earlier $name credential', + async ({ method, name }) => { + let effective = 'tenant-a-token'; + let coercions = 0; + let nameReads = 0; + const defaults: Record = {}; + Object.defineProperty(defaults, name, { + enumerable: true, + value: { + toString(): string { + coercions += 1; + return effective; + }, + }, + }); + + const metadata: [string, string] = ['placeholder', 'preserved']; + Object.defineProperty(metadata, 0, { + configurable: true, + enumerable: true, + get(): string { + nameReads += 1; + effective = 'tenant-b-token'; + return 'x-metadata'; + }, + }); + + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-tenant-token', + defaultHeaders: defaults, + fetch, + maxRetries: 0, + }); + + let failure: unknown; + try { + await client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + headers: [metadata], + }); + } catch (error) { + failure = error; + } + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(name)).not.toBe('tenant-b-token'); + expect(failure).toBeInstanceOf(TypeError); + if (!(failure instanceof TypeError)) { + throw new Error('Expected a sanitized Azure credential failure.'); + } + expect(failure.message).toBe(SAFE_ERROR); + expect((failure as TypeError & { cause?: unknown }).cause).toBeUndefined(); + expect(nameReads).toBe(0); + expect(coercions).toBe(0); + expect(effective).toBe('tenant-a-token'); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each(requestCredentialCases)( + 'does not coerce a $method $name credential shadowed by a tuple with an own data name', + async ({ method, name }) => { + let coercions = 0; + const defaults: Record = {}; + Object.defineProperty(defaults, name, { + enumerable: true, + value: { + toString(): string { + coercions += 1; + throw new Error('private-shadowed-tenant-token'); + }, + }, + }); + + const replacement: [string, string] = [name.toUpperCase(), 'placeholder']; + Object.defineProperty(replacement, 1, { + configurable: true, + enumerable: true, + get: () => 'effective-tenant-token', + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-tenant-token', + defaultHeaders: defaults, + fetch, + maxRetries: 0, + }); + + await client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + headers: [replacement], + }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(name)).toBe('effective-tenant-token'); + expect(coercions).toBe(0); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'does not coerce a %s credential shadowed by a case-insensitive tuple accessor', + async (name) => { + let coercions = 0; + const defaults: Record = {}; + Object.defineProperty(defaults, name, { + enumerable: true, + value: { + toString(): string { + coercions += 1; + throw new Error('private-shadowed-tenant-token'); + }, + }, + }); + + const replacement: [string, string] = [name.toUpperCase(), 'placeholder']; + Object.defineProperty(replacement, 1, { + configurable: true, + enumerable: true, + get: () => 'effective-tenant-token', + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-tenant-token', + defaultHeaders: defaults, + fetch, + maxRetries: 0, + }); + + await client.request({ method: 'get', path: '/models', headers: [replacement] }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(name)).toBe('effective-tenant-token'); + expect(coercions).toBe(0); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'ignores undefined values and array holes when probing later %s tuple overrides', + async (name) => { + let effective = 'tenant-a-token'; + let coercions = 0; + const defaults: Record = {}; + Object.defineProperty(defaults, name, { + enumerable: true, + value: { + toString(): string { + coercions += 1; + return effective; + }, + }, + }); + + const emptyValues = [undefined, undefined, undefined]; + Reflect.deleteProperty(emptyValues, 1); + const ignoredOverride: [string, string] = [name.toUpperCase(), 'placeholder']; + Object.defineProperty(ignoredOverride, 1, { value: emptyValues }); + const metadata: [string, string] = ['x-metadata', 'placeholder']; + Object.defineProperty(metadata, 1, { + configurable: true, + enumerable: true, + get(): string { + effective = 'tenant-b-token'; + return 'preserved'; + }, + }); + + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-tenant-token', + defaultHeaders: defaults, + fetch, + maxRetries: 0, + }); + + await client.request({ method: 'get', path: '/models', headers: [ignoredOverride, metadata] }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(name)).toBe('tenant-a-token'); + expect(coercions).toBe(1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'recognizes an inherited %s tuple-array accessor without coercing its shadowed credential', + async (name) => { + let coercions = 0; + const defaults: Record = {}; + Object.defineProperty(defaults, name, { + enumerable: true, + value: { + toString(): string { + coercions += 1; + throw new Error('private-shadowed-tenant-token'); + }, + }, + }); + + const values = ['placeholder']; + Reflect.deleteProperty(values, 0); + const inherited = Object.create(Array.prototype) as object; + Object.defineProperty(inherited, 0, { + configurable: true, + get: () => 'effective-tenant-token', + }); + Object.setPrototypeOf(values, inherited); + const replacement: [string, string] = [name.toUpperCase(), 'placeholder']; + Object.defineProperty(replacement, 1, { value: values }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-tenant-token', + defaultHeaders: defaults, + fetch, + maxRetries: 0, + }); + + await client.request({ method: 'get', path: '/models', headers: [replacement] }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(name)).toBe('effective-tenant-token'); + expect(coercions).toBe(0); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['tuple value', 'array element'] as const)( + 'sanitizes an untrusted %s descriptor that prevents proving credential precedence', + async (representation) => { + const privateCredential = 'private-proxy-descriptor-tenant-token'; + const values = new Proxy(['safe-tenant-token'], { + getOwnPropertyDescriptor(target, property) { + if (representation === 'array element' && property === '0') { + throw new Error(privateCredential); + } + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }); + const target: [string, string] = ['api-key', 'placeholder']; + Object.defineProperty(target, 1, { value: values }); + const entry = new Proxy(target, { + getOwnPropertyDescriptor(tuple, property) { + if (representation === 'tuple value' && property === '1') { + throw new Error(privateCredential); + } + return Reflect.getOwnPropertyDescriptor(tuple, property); + }, + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-tenant-token', + fetch, + maxRetries: 0, + }); + + let failure: unknown; + try { + await client.request({ method: 'get', path: '/models', headers: [entry] }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(TypeError); + if (!(failure instanceof TypeError)) { + throw new Error('Expected a sanitized Azure credential failure.'); + } + expect(failure.message).toBe(SAFE_ERROR); + expect((failure as TypeError & { cause?: unknown }).cause).toBeUndefined(); + expect(failure.stack).not.toContain(privateCredential); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each(requestCredentialCases)( + 'sanitizes a $method tuple-name descriptor trap before inspecting an earlier $name credential', + async ({ method, name }) => { + const privateCredential = 'private-proxy-name-descriptor-tenant-token'; + let coercions = 0; + const defaults: Record = {}; + Object.defineProperty(defaults, name, { + enumerable: true, + value: { + toString(): string { + coercions += 1; + return 'tenant-a-token'; + }, + }, + }); + const entry = new Proxy<[string, string]>(['x-metadata', 'preserved'], { + getOwnPropertyDescriptor(tuple, property) { + if (property === '0') { + throw new Error(privateCredential); + } + return Reflect.getOwnPropertyDescriptor(tuple, property); + }, + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new AzureOpenAI({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: 'configured-tenant-token', + defaultHeaders: defaults, + fetch, + maxRetries: 0, + }); + + let failure: unknown; + try { + await client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + headers: [entry], + }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(TypeError); + if (!(failure instanceof TypeError)) { + throw new Error('Expected a sanitized Azure credential failure.'); + } + expect(failure.message).toBe(SAFE_ERROR); + expect((failure as TypeError & { cause?: unknown }).cause).toBeUndefined(); + expect(failure.stack).not.toContain(privateCredential); + expect(coercions).toBe(0); + expect(fetch).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/tests/lib/azure-pr2421-options-review.test.ts b/tests/lib/azure-pr2421-options-review.test.ts new file mode 100644 index 000000000..337a079f5 --- /dev/null +++ b/tests/lib/azure-pr2421-options-review.test.ts @@ -0,0 +1,334 @@ +import { once } from 'node:events'; +import { vi } from 'vitest'; + +import { AzureOpenAI } from 'openai'; +import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; +import type { NullableHeaders } from 'openai/internal/headers'; +import type { FinalRequestOptions } from 'openai/internal/request-options'; + +const SAFE_ERROR = 'Azure OpenAI credential contains an invalid HTTP header value.'; +const PRIVATE_CREDENTIAL = 'private-azure-review-credential-82fe'; + +class ReviewAzure extends AzureOpenAI { + observeAuthentication: ((options: FinalRequestOptions) => Promise | void) | undefined; + + protected override async authHeaders( + options: FinalRequestOptions, + schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, + ): Promise { + await this.observeAuthentication?.(options); + return super.authHeaders(options, schemes); + } +} + +function createClient() { + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new ReviewAzure({ + baseURL: 'https://azure-resource.example.com/openai', + apiVersion: '2024-02-15-preview', + apiKey: 'configured-tenant-token', + fetch, + maxRetries: 0, + }); + return { client, fetch }; +} + +describe('Azure request-options review regressions', () => { + test.each([40, 128] as const)( + 'never invokes an inherited body getter at prototype depth %s when the base option spread ignores it', + async (depth) => { + const { client, fetch } = createClient(); + const read = vi.fn(() => { + throw Object.assign(new Error(PRIVATE_CREDENTIAL), { cause: new Error(PRIVATE_CREDENTIAL) }); + }); + const owner = Object.create(null) as object; + Object.defineProperty(owner, 'body', { configurable: true, enumerable: true, get: read }); + let prototype = owner; + for (let index = 0; index < depth; index += 1) { + prototype = Object.create(prototype) as object; + } + const options = Object.assign(Object.create(prototype) as FinalRequestOptions, { + method: 'get' as const, + path: '/models', + headers: { 'api-key': 'request-tenant-token' }, + }); + + await client.request(options); + + expect(read).not.toHaveBeenCalled(); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('request-tenant-token'); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'honors an unambiguous configurable %s accessor redefined by the protected authentication hook', + async (header) => { + const { client, fetch } = createClient(); + const options: FinalRequestOptions = { method: 'post', path: '/models', body: { safe: true } }; + Object.defineProperty(options, 'headers', { + configurable: true, + enumerable: true, + get: () => ({ [header]: 'initial-tenant-token' }), + }); + const replacement = { [header]: 'protected-hook-tenant-token', 'x-hook': 'preserved' }; + client.observeAuthentication = (received) => { + expect(received).toBe(options); + Object.defineProperty(received, 'headers', { + configurable: true, + enumerable: true, + writable: true, + value: replacement, + }); + }; + + await client.request(options); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get(header)).toBe('protected-hook-tenant-token'); + expect(sent.get('x-hook')).toBe('preserved'); + expect(Object.getOwnPropertyDescriptor(options, 'headers')?.value).toBe(replacement); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['api-key', 'Authorization'] as const)( + 'sanitizes a malformed %s effective credential when a configurable accessor is redefined', + async (header) => { + const { client, fetch } = createClient(); + const options: FinalRequestOptions = { method: 'post', path: '/models', body: { safe: true } }; + Object.defineProperty(options, 'headers', { + configurable: true, + enumerable: true, + get: () => ({ [header]: 'initial-tenant-token' }), + }); + client.observeAuthentication = (received) => { + Object.defineProperty(received, 'headers', { + configurable: true, + enumerable: true, + writable: true, + value: { [header]: `${PRIVATE_CREDENTIAL}\nprivate-suffix` }, + }); + }; + + let failure: unknown; + try { + await client.request(options); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(TypeError); + if (!(failure instanceof TypeError)) { + throw new Error('Expected a sanitized Azure credential error.'); + } + expect(failure.message).toBe(SAFE_ERROR); + expect((failure as TypeError & { cause?: unknown }).cause).toBeUndefined(); + expect(failure.stack).not.toContain(PRIVATE_CREDENTIAL); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test('fails closed when the authentication hook deletes a configurable request-header snapshot', async () => { + const { client, fetch } = createClient(); + const inherited = { headers: { 'api-key': 'different-inherited-tenant-token' } }; + const options = Object.assign(Object.create(inherited) as FinalRequestOptions, { + method: 'post' as const, + path: '/models', + body: { safe: true }, + }); + Object.defineProperty(options, 'headers', { + configurable: true, + enumerable: true, + get: () => ({ 'api-key': 'initial-tenant-token' }), + }); + client.observeAuthentication = (received) => { + expect(received).toBe(options); + Reflect.deleteProperty(received, 'headers'); + }; + + await expect(client.request(options)).rejects.toThrow(SAFE_ERROR); + + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toBeUndefined(); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('fails both concurrent configurable snapshots when one hook redefines their shared accessor', async () => { + const { client, fetch } = createClient(); + const snapshots = [{ 'api-key': 'first-tenant-token' }, { 'api-key': 'second-tenant-token' }]; + const options: FinalRequestOptions = { method: 'post', path: '/models', body: { safe: true } }; + let reads = 0; + Object.defineProperty(options, 'headers', { + configurable: true, + enumerable: true, + get() { + const snapshot = snapshots[reads]; + reads += 1; + return snapshot; + }, + }); + const gates: AbortController[] = []; + client.observeAuthentication = async (received) => { + const index = gates.length; + const gate = new AbortController(); + gates.push(gate); + await once(gate.signal, 'abort'); + if (index === 0) { + Object.defineProperty(received, 'headers', { + configurable: true, + enumerable: true, + writable: true, + value: { 'api-key': 'first-hook-tenant-token' }, + }); + } + }; + + const first = client.request(options); + const second = client.request(options); + await vi.waitFor(() => expect(gates).toHaveLength(2), { interval: 1 }); + gates[0]?.abort(); + await expect(first).rejects.toThrow(SAFE_ERROR); + gates[1]?.abort(); + await expect(second).rejects.toThrow(SAFE_ERROR); + + expect(reads).toBe(2); + expect(fetch).not.toHaveBeenCalled(); + }); + + test.each(['the same client', 'different clients'] as const)( + 'allows stable immutable getter/setter snapshots to overlap on %s without ambiguous writes', + async (representation) => { + const firstClient = createClient(); + const secondClient = representation === 'the same client' ? firstClient : createClient(); + const headers = { 'api-key': 'stable-tenant-token', 'x-tenant': 'stable' }; + const options: FinalRequestOptions = { method: 'post', path: '/models', body: { safe: true } }; + const setter = vi.fn(); + Object.defineProperty(options, 'headers', { + configurable: false, + enumerable: true, + get: () => headers, + set: setter, + }); + const gates: AbortController[] = []; + const observe = async (received: FinalRequestOptions) => { + expect(received).toBe(options); + const gate = new AbortController(); + gates.push(gate); + await once(gate.signal, 'abort'); + }; + firstClient.client.observeAuthentication = observe; + secondClient.client.observeAuthentication = observe; + + const first = firstClient.client.request(options); + const second = secondClient.client.request(options); + await vi.waitFor(() => expect(gates).toHaveLength(2), { interval: 1 }); + gates[1]?.abort(); + await second; + gates[0]?.abort(); + await first; + + const requests = [ + ...firstClient.fetch.mock.calls, + ...(secondClient === firstClient ? [] : secondClient.fetch.mock.calls), + ]; + for (const [, init] of requests) { + const sent = new Headers(init?.headers); + expect(sent.get('api-key')).toBe('stable-tenant-token'); + expect(sent.get('x-tenant')).toBe('stable'); + } + expect(setter).not.toHaveBeenCalled(); + expect(requests).toHaveLength(2); + }, + ); + + test('honors a stable proxy setter that forwards its effective replacement into the data descriptor', async () => { + const { client, fetch } = createClient(); + const target: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { safe: true }, + headers: { 'api-key': 'initial-tenant-token' }, + }; + let reads = 0; + let writes = 0; + const options = new Proxy(target, { + get(value, property, receiver) { + if (property === 'headers') { + reads += 1; + } + return Reflect.get(value, property, receiver); + }, + set(value, property, replacement, receiver) { + if (property === 'headers') { + writes += 1; + const supplied = replacement as Record; + return Reflect.set(value, property, { + 'api-key': String(supplied['api-key']).toLowerCase(), + 'x-setter': 'normalized', + }); + } + return Reflect.set(value, property, replacement, receiver); + }, + }); + client.observeAuthentication = (received) => { + expect(received).toBe(options); + received.headers = { 'api-key': 'FORWARDED-TENANT-TOKEN' }; + }; + + await client.request(options); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get('api-key')).toBe('forwarded-tenant-token'); + expect(sent.get('x-setter')).toBe('normalized'); + expect(reads).toBe(1); + expect(writes).toBe(1); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test.each(['a mismatched data descriptor', 'no data descriptor'] as const)( + 'fails closed when a virtualized proxy setter conceals a hook replacement with %s', + async (representation) => { + const { client, fetch } = createClient(); + const initial = { 'api-key': 'initial-virtual-tenant-token' }; + let effective = initial; + let reads = 0; + let writes = 0; + const target: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { safe: true }, + ...(representation === 'a mismatched data descriptor' + ? { headers: { 'api-key': 'unrelated-target-tenant-token' } } + : {}), + }; + const options = new Proxy(target, { + get(value, property, receiver) { + if (property === 'headers') { + reads += 1; + return effective; + } + return Reflect.get(value, property, receiver); + }, + set(value, property, replacement, receiver) { + if (property === 'headers') { + writes += 1; + const supplied = replacement as Record; + effective = { 'api-key': String(supplied['api-key']).toLowerCase() }; + return true; + } + return Reflect.set(value, property, replacement, receiver); + }, + }); + client.observeAuthentication = (received) => { + expect(received).toBe(options); + received.headers = { 'api-key': 'REPLACEMENT-VIRTUAL-TENANT-TOKEN' }; + }; + + await expect(client.request(options)).rejects.toThrow(SAFE_ERROR); + + expect(reads).toBe(1); + expect(writes).toBe(1); + expect(fetch).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/tests/lib/azure-request-header-capability.test.ts b/tests/lib/azure-request-header-capability.test.ts new file mode 100644 index 000000000..4e79fe9aa --- /dev/null +++ b/tests/lib/azure-request-header-capability.test.ts @@ -0,0 +1,733 @@ +import { once } from 'node:events'; +import { vi } from 'vitest'; + +import { AzureOpenAI } from 'openai'; +import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; +import type { NullableHeaders } from 'openai/internal/headers'; +import type { FinalRequestOptions } from 'openai/internal/request-options'; + +const BASE_URL = 'https://azure-resource.example.com/openai'; +const API_VERSION = '2024-02-15-preview'; +const SAFE_ERROR = 'Azure OpenAI credential contains an invalid HTTP header value.'; +const PRIVATE_CREDENTIAL = 'private-azure-capability-credential-8a91'; + +const intrinsicAppend = Headers.prototype.append; +const intrinsicDelete = Headers.prototype.delete; +const intrinsicGet = Headers.prototype.get; +const intrinsicHas = Headers.prototype.has; +const intrinsicSet = Headers.prototype.set; + +type Authentication = 'static-api-key' | 'rotating-entra-token'; +type AuthenticationObserver = ( + options: FinalRequestOptions, + carrier: NullableHeaders, + invocation: number, +) => Promise | void; + +class CapabilityAzure extends AzureOpenAI { + observeAuthentication: AuthenticationObserver | undefined; + readonly authenticationOptions: FinalRequestOptions[] = []; + + protected override async authHeaders( + options: FinalRequestOptions, + schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, + ): Promise { + const invocation = this.authenticationOptions.length; + this.authenticationOptions.push(options); + const carrier = await super.authHeaders(options, schemes); + if (carrier) { + await this.observeAuthentication?.(options, carrier, invocation); + } + return carrier; + } +} + +function createClient(authentication: Authentication = 'static-api-key') { + const provider = vi.fn(async () => 'configured-tenant-token'); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => + globalThis.Response.json({ ok: true }), + ); + const client = new CapabilityAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + ...(authentication === 'static-api-key' + ? { apiKey: 'configured-tenant-token' } + : { azureADTokenProvider: provider }), + fetch, + maxRetries: 0, + }); + return { client, fetch, provider }; +} + +async function expectSanitizedFailure(operation: Promise): Promise { + let failure: unknown; + try { + await operation; + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(TypeError); + if (!(failure instanceof TypeError)) { + throw new Error('Expected a sanitized Azure credential failure.'); + } + expect(failure.message).toBe(SAFE_ERROR); + expect((failure as TypeError & { cause?: unknown }).cause).toBeUndefined(); + expect(failure.stack).not.toContain(PRIVATE_CREDENTIAL); +} + +async function captureFailure(operation: Promise): Promise { + try { + await operation; + return undefined; + } catch (error) { + return error; + } +} + +describe('Azure request-local authentication header capabilities', () => { + test.each( + (['static-api-key', 'rotating-entra-token'] as const).flatMap((authentication) => + (['append', 'delete', 'set'] as const).map((operation) => ({ authentication, operation })), + ), + )( + '$authentication exposes its effective credential to captured Headers.prototype.$operation', + async ({ authentication, operation }) => { + const { client, fetch, provider } = createClient(authentication); + const name = authentication === 'static-api-key' ? 'api-key' : 'authorization'; + const configured = + authentication === 'static-api-key' ? 'configured-tenant-token' : 'Bearer configured-tenant-token'; + client.observeAuthentication = (_options, carrier) => { + expect(intrinsicHas.call(carrier.values, name)).toBe(true); + expect(intrinsicGet.call(carrier.values, name)).toBe(configured); + if (operation === 'append') { + intrinsicAppend.call(carrier.values, name.toUpperCase(), 'appended-tenant-token'); + } else if (operation === 'delete') { + intrinsicDelete.call(carrier.values, name.toUpperCase()); + intrinsicSet.call( + carrier.values, + name === 'api-key' ? 'authorization' : 'api-key', + name === 'api-key' ? 'Bearer replacement-tenant-token' : 'replacement-tenant-token', + ); + } else { + intrinsicSet.call(carrier.values, name.toUpperCase(), 'replacement-tenant-token'); + } + }; + + await client.request({ method: 'get', path: '/models' }); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + if (operation === 'append') { + expect(sent.get(name)).toBe(`${configured}, appended-tenant-token`); + } else if (operation === 'delete') { + expect(sent.has(name)).toBe(false); + expect(sent.get(name === 'api-key' ? 'authorization' : 'api-key')).toBe( + name === 'api-key' ? 'Bearer replacement-tenant-token' : 'replacement-tenant-token', + ); + } else { + expect(sent.get(name)).toBe('replacement-tenant-token'); + } + expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['static-api-key', 'rotating-entra-token'] as const)( + '$authentication keeps captured and overridden credential mutations in native order', + async (authentication) => { + const { client, fetch } = createClient(authentication); + const name = authentication === 'static-api-key' ? 'api-key' : 'authorization'; + client.observeAuthentication = (_options, carrier) => { + carrier.values.append(name, 'discarded-wrapper-token'); + intrinsicSet.call(carrier.values, name.toUpperCase(), 'native-replacement-token'); + carrier.values.append(name, 'final-wrapper-token'); + }; + + await client.request({ method: 'get', path: '/models' }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(name)).toBe( + 'native-replacement-token, final-wrapper-token', + ); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test('keeps captured intrinsic mutations local to overlapping protected authentication invocations', async () => { + const { client, fetch } = createClient(); + const gates: AbortController[] = []; + client.observeAuthentication = async (_options, carrier, invocation) => { + const gate = new AbortController(); + gates.push(gate); + await once(gate.signal, 'abort'); + intrinsicAppend.call(carrier.values, 'API-KEY', `tenant-${invocation}-suffix`); + }; + + const first = client.request({ + method: 'post', + path: '/models', + body: { tenant: 'first' }, + headers: { 'x-tenant': 'first' }, + }); + const second = client.request({ + method: 'post', + path: '/models', + body: { tenant: 'second' }, + headers: { 'x-tenant': 'second' }, + }); + await vi.waitFor(() => expect(gates).toHaveLength(2), { interval: 1 }); + + gates[1]?.abort(); + await second; + gates[0]?.abort(); + await first; + + const secondHeaders = new Headers(fetch.mock.calls[0]?.[1]?.headers); + const firstHeaders = new Headers(fetch.mock.calls[1]?.[1]?.headers); + expect(secondHeaders.get('api-key')).toBe('configured-tenant-token, tenant-1-suffix'); + expect(secondHeaders.get('x-tenant')).toBe('second'); + expect(firstHeaders.get('api-key')).toBe('configured-tenant-token, tenant-0-suffix'); + expect(firstHeaders.get('x-tenant')).toBe('first'); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + test('fails closed when a captured intrinsic appends to an unmaterializable malformed credential', async () => { + const malformed = `${PRIVATE_CREDENTIAL}\nprivate-suffix`; + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => + globalThis.Response.json({ ok: true }), + ); + const client = new CapabilityAzure({ + baseURL: BASE_URL, + apiVersion: API_VERSION, + apiKey: malformed, + fetch, + maxRetries: 0, + }); + client.observeAuthentication = (_options, carrier) => { + intrinsicAppend.call(carrier.values, 'api-key', 'safe-suffix'); + }; + + await expectSanitizedFailure(client.request({ method: 'get', path: '/models' })); + + expect(fetch).not.toHaveBeenCalled(); + }); + + test.each( + (['immutable own', 'sealed own', 'frozen own', 'nonextensible inherited'] as const).flatMap( + (representation) => + (['api-key', 'Authorization'] as const).map((header) => ({ header, representation })), + ), + )( + 'dispatches the normalized protected-hook $header replacement from a $representation setter', + async ({ header, representation }) => { + const { client, fetch } = createClient(); + let effective: Record = { [header]: 'initial-tenant-token' }; + let reads = 0; + let writes = 0; + const owner = Object.create(null) as object; + Object.defineProperty(owner, 'headers', { + configurable: representation === 'nonextensible inherited', + enumerable: true, + get() { + reads += 1; + return effective; + }, + set(value: Record) { + writes += 1; + effective = { + [header]: String(value[header]).toLowerCase(), + 'x-setter': 'normalized', + }; + }, + }); + const options = Object.assign( + representation === 'nonextensible inherited' ? Object.create(owner) : owner, + { method: 'post' as const, path: '/models', body: { safe: true } }, + ) as FinalRequestOptions; + if (representation === 'sealed own') { + Object.seal(options); + } else if (representation === 'frozen own') { + Object.freeze(options); + } else if (representation === 'nonextensible inherited') { + Object.preventExtensions(options); + } + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + const prototype = Object.getPrototypeOf(options) as object | null; + client.observeAuthentication = (received) => { + expect(received).toBe(options); + received.headers = { [header]: 'NORMALIZED-TENANT-TOKEN' }; + }; + + await client.request(options); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get(header)).toBe('normalized-tenant-token'); + expect(sent.get('x-setter')).toBe('normalized'); + expect(reads).toBe(2); + expect(writes).toBe(1); + expect(client.authenticationOptions).toEqual([options]); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(descriptor); + expect(Object.getPrototypeOf(options)).toBe(prototype); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['throws', 'returns an invalid credential'] as const)( + 'sanitizes an immutable getter that %s after its protected setter runs', + async (behavior) => { + const { client, fetch } = createClient(); + const options: FinalRequestOptions = { method: 'post', path: '/models', body: { safe: true } }; + let writes = 0; + Object.defineProperty(options, 'headers', { + configurable: false, + enumerable: true, + get() { + if (writes === 0) { + return { 'api-key': 'initial-tenant-token' }; + } + if (behavior === 'throws') { + throw Object.assign(new Error(PRIVATE_CREDENTIAL), { cause: new Error(PRIVATE_CREDENTIAL) }); + } + return { 'api-key': `${PRIVATE_CREDENTIAL}\nprivate-suffix` }; + }, + set() { + writes += 1; + }, + }); + client.observeAuthentication = (received) => { + received.headers = { 'api-key': 'safe-supplied-token' }; + }; + + await expectSanitizedFailure(client.request(options)); + + expect(writes).toBe(1); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each([null, undefined] as const)( + 'preserves an immutable setter replacement of %s without reviving the original request credential', + async (replacement) => { + const { client, fetch } = createClient(); + const options: FinalRequestOptions = { method: 'post', path: '/models', body: { safe: true } }; + let effective: FinalRequestOptions['headers'] = { 'api-key': 'initial-tenant-token' }; + Object.defineProperty(options, 'headers', { + configurable: false, + enumerable: true, + get() { + return effective; + }, + set(value: FinalRequestOptions['headers']) { + effective = value; + }, + }); + client.observeAuthentication = (received) => { + received.headers = replacement; + }; + + await client.request(options); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('configured-tenant-token'); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test('fails both overlapping immutable setter requests before either tenant can be dispatched', async () => { + const { client, fetch } = createClient(); + const records = [{ 'api-key': 'first-tenant-token' }, { 'api-key': 'second-tenant-token' }]; + const options: FinalRequestOptions = { method: 'post', path: '/models', body: { safe: true } }; + let reads = 0; + let writes = 0; + Object.defineProperty(options, 'headers', { + configurable: false, + enumerable: true, + get() { + const record = records[reads]; + reads += 1; + return record; + }, + set() { + writes += 1; + }, + }); + const gates: AbortController[] = []; + client.observeAuthentication = async (received, _carrier, invocation) => { + const gate = new AbortController(); + gates.push(gate); + await once(gate.signal, 'abort'); + received.headers = { 'api-key': `replacement-tenant-${invocation}` }; + }; + + const first = captureFailure(client.request(options)); + const second = captureFailure(client.request(options)); + await vi.waitFor(() => expect(gates).toHaveLength(2), { interval: 1 }); + expect(reads).toBe(2); + + gates[0]?.abort(); + const firstFailure = await first; + gates[1]?.abort(); + const secondFailure = await second; + + for (const failure of [firstFailure, secondFailure]) { + expect(failure).toBeInstanceOf(TypeError); + expect((failure as Error).message).toBe(SAFE_ERROR); + } + expect(writes).toBe(2); + expect(fetch).not.toHaveBeenCalled(); + }); + + test.each(['same client', 'different clients'] as const)( + 'fails closed before a concurrent configurable setter can bind request A to request B on %s', + async (representation) => { + const first = createClient(); + const second = representation === 'same client' ? first : createClient(); + const snapshots = [ + { 'api-key': 'first-tenant-token', 'x-tenant': 'first' }, + { 'api-key': 'second-tenant-token', 'x-tenant': 'second' }, + ]; + let reads = 0; + let writes = 0; + let replacement: Record | undefined; + const options: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { safe: true }, + get headers() { + if (replacement) { + reads += 1; + return replacement; + } + const snapshot = snapshots[reads]; + reads += 1; + return snapshot; + }, + set headers(value) { + writes += 1; + const supplied = value as Record; + replacement = { + 'api-key': String(supplied['api-key']).toLowerCase(), + 'x-tenant': supplied['x-tenant'] ?? 'unknown', + }; + }, + }; + const originalDescriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + const gates: AbortController[] = []; + const observe: AuthenticationObserver = async (received, _carrier, invocation) => { + const index = representation === 'same client' ? invocation : gates.length; + const gate = new AbortController(); + gates.push(gate); + await once(gate.signal, 'abort'); + received.headers = { + 'api-key': index === 0 ? 'FIRST-HOOK-TOKEN' : 'SECOND-HOOK-TOKEN', + 'x-tenant': index === 0 ? 'first' : 'second', + }; + }; + first.client.observeAuthentication = observe; + second.client.observeAuthentication = observe; + + const firstRequest = first.client.request(options); + const observedFirst = captureFailure(firstRequest); + const secondRequest = second.client.request(options); + await vi.waitFor(() => expect(gates).toHaveLength(2), { interval: 1 }); + expect(reads).toBe(2); + + gates[0]?.abort(); + const firstFailure = await observedFirst; + expect(firstFailure).toBeInstanceOf(TypeError); + expect((firstFailure as Error).message).toBe(SAFE_ERROR); + expect(writes).toBe(0); + expect(first.fetch).not.toHaveBeenCalled(); + + gates[1]?.abort(); + await secondRequest; + + const sent = new Headers(second.fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get('api-key')).toBe('second-hook-token'); + expect(sent.get('x-tenant')).toBe('second'); + expect(writes).toBe(1); + expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(originalDescriptor); + expect(second.fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each( + (['get', 'post'] as const).flatMap((method) => + (['conflicting tenant', 'malformed credential', 'throwing coercion'] as const).map((behavior) => ({ + behavior, + method, + })), + ), + )( + 'reads virtualized configurable data headers once for a $method with a $behavior on later reads', + async ({ behavior, method }) => { + const { client, fetch } = createClient(); + const first = { 'api-key': 'first-tenant-token', 'x-tenant': 'first' }; + let coercions = 0; + let unsafe: object; + if (behavior === 'conflicting tenant') { + unsafe = { 'api-key': 'second-tenant-token', 'x-tenant': 'second' }; + } else if (behavior === 'malformed credential') { + unsafe = { 'api-key': `${PRIVATE_CREDENTIAL}\nprivate-suffix` }; + } else { + unsafe = { + 'api-key': { + toString(): string { + coercions += 1; + throw new Error(PRIVATE_CREDENTIAL); + }, + }, + }; + } + const target: FinalRequestOptions = { + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + headers: first, + }; + let reads = 0; + const options = new Proxy(target, { + get(value, property, receiver) { + if (property === 'headers') { + reads += 1; + return reads === 1 ? first : unsafe; + } + return Reflect.get(value, property, receiver); + }, + }); + const descriptor = Object.getOwnPropertyDescriptor(target, 'headers'); + client.observeAuthentication = (received) => { + expect(received).toBe(options); + }; + + await client.request(options); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get('api-key')).toBe('first-tenant-token'); + expect(sent.get('x-tenant')).toBe('first'); + expect(reads).toBe(1); + expect(coercions).toBe(0); + expect(client.authenticationOptions).toEqual([options]); + expect(Object.getOwnPropertyDescriptor(target, 'headers')).toEqual(descriptor); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test('fails closed for an untrackable concurrent virtualized configurable data snapshot', async () => { + const { client, fetch } = createClient(); + const tenants = [ + { 'api-key': 'first-tenant-token', 'x-tenant': 'first' }, + { 'api-key': 'second-tenant-token', 'x-tenant': 'second' }, + ]; + const target: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { safe: true }, + headers: tenants[0], + }; + let reads = 0; + const options = new Proxy(target, { + get(value, property, receiver) { + if (property === 'headers') { + const result = tenants[reads]; + reads += 1; + if (!result) { + throw new Error(PRIVATE_CREDENTIAL); + } + return result; + } + return Reflect.get(value, property, receiver); + }, + }); + const gates: AbortController[] = []; + client.observeAuthentication = async () => { + const gate = new AbortController(); + gates.push(gate); + await once(gate.signal, 'abort'); + }; + + const first = client.request(options); + const second = client.request(options); + await vi.waitFor(() => expect(gates).toHaveLength(2), { interval: 1 }); + expect(reads).toBe(2); + + gates[1]?.abort(); + await expectSanitizedFailure(second); + gates[0]?.abort(); + await first; + + const firstHeaders = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(firstHeaders.get('api-key')).toBe('first-tenant-token'); + expect(firstHeaders.get('x-tenant')).toBe('first'); + expect(reads).toBe(2); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('snapshots conflicting shared GET credentials before asynchronous authentication interleaves', async () => { + const { client, fetch } = createClient(); + const shared = { 'api-key': 'first-tenant-token' }; + const gates: AbortController[] = []; + client.observeAuthentication = async () => { + const gate = new AbortController(); + gates.push(gate); + await once(gate.signal, 'abort'); + }; + + const first = client.request({ method: 'get', path: '/models', headers: shared }); + await vi.waitFor(() => expect(gates).toHaveLength(1), { interval: 1 }); + shared['api-key'] = 'second-tenant-token'; + const second = client.request({ method: 'get', path: '/models', headers: shared }); + await vi.waitFor(() => expect(gates).toHaveLength(2), { interval: 1 }); + + gates[1]?.abort(); + await second; + gates[0]?.abort(); + await first; + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('second-tenant-token'); + expect(new Headers(fetch.mock.calls[1]?.[1]?.headers).get('api-key')).toBe('first-tenant-token'); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + test.each(['get', 'post'] as const)( + 'snapshots effective object-backed $method credentials before asynchronous tenant changes', + async (method) => { + const { client, fetch } = createClient(); + let effective = 'first-tenant-token'; + let coercions = 0; + const shared = { + 'api-key': { + toString(): string { + coercions += 1; + return effective; + }, + }, + } as unknown as Record; + const gates: AbortController[] = []; + client.observeAuthentication = async () => { + const gate = new AbortController(); + gates.push(gate); + await once(gate.signal, 'abort'); + }; + + const first = client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + headers: shared, + }); + await vi.waitFor(() => expect(gates).toHaveLength(1), { interval: 1 }); + effective = 'second-tenant-token'; + const second = client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + headers: shared, + }); + await vi.waitFor(() => expect(gates).toHaveLength(2), { interval: 1 }); + + gates[1]?.abort(); + await second; + gates[0]?.abort(); + await first; + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('second-tenant-token'); + expect(new Headers(fetch.mock.calls[1]?.[1]?.headers).get('api-key')).toBe('first-tenant-token'); + expect(coercions).toBe(2); + expect(fetch).toHaveBeenCalledTimes(2); + }, + ); + + test.each(['get', 'post'] as const)( + 'never coerces an object-backed $method credential shadowed in the same request record', + async (method) => { + const { client, fetch } = createClient(); + let coercions = 0; + const headers: Record = {}; + Object.defineProperty(headers, 'api-key', { + enumerable: true, + value: { + toString(): string { + coercions += 1; + throw new Error(PRIVATE_CREDENTIAL); + }, + }, + }); + Object.defineProperty(headers, 'API-KEY', { + enumerable: true, + get: () => 'effective-tenant-token', + }); + + await client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + headers, + }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('effective-tenant-token'); + expect(coercions).toBe(0); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['get', 'post'] as const)( + 'snapshots an effective object-backed $method credential before a later metadata getter', + async (method) => { + const { client, fetch } = createClient(); + let effective = 'first-tenant-token'; + let coercions = 0; + const headers: Record = {}; + Object.defineProperty(headers, 'api-key', { + enumerable: true, + value: { + toString(): string { + coercions += 1; + return effective; + }, + }, + }); + Object.defineProperty(headers, 'x-tenant', { + enumerable: true, + get() { + effective = 'second-tenant-token'; + return 'preserved'; + }, + }); + + await client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + headers, + }); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get('api-key')).toBe('first-tenant-token'); + expect(sent.get('x-tenant')).toBe('preserved'); + expect(coercions).toBe(1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test('sanitizes a throwing virtualized data getter before authentication or transport', async () => { + const { client, fetch } = createClient(); + const target: FinalRequestOptions = { + method: 'post', + path: '/models', + body: { safe: true }, + headers: { 'api-key': 'safe-target-token' }, + }; + const options = new Proxy(target, { + get(value, property, receiver) { + if (property === 'headers') { + throw Object.assign(new Error(PRIVATE_CREDENTIAL), { cause: new Error(PRIVATE_CREDENTIAL) }); + } + return Reflect.get(value, property, receiver); + }, + }); + + await expectSanitizedFailure(client.request(options)); + + expect(client.authenticationOptions).toEqual([]); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/log.test.ts b/tests/log.test.ts index 3aa74b87b..72daaeb07 100644 --- a/tests/log.test.ts +++ b/tests/log.test.ts @@ -2,6 +2,8 @@ import { vi } from 'vitest'; import type { ClientOptions } from 'openai/index'; import OpenAI from 'openai/index'; +import type { RequestOptions } from 'openai/internal/request-options'; +import { formatRequestDetails } from 'openai/internal/utils/log'; const opts: ClientOptions = { apiKey: 'example-api-key', @@ -18,6 +20,167 @@ const opts: ClientOptions = { ), }; +describe('formatRequestDetails()', () => { + test('omits header accessors while preserving own enumerable request options', () => { + const metadata = Symbol('request metadata'); + const options = Object.create({ inherited: 'omitted' }) as RequestOptions; + let visibleReads = 0; + + Object.defineProperties(options, { + headers: { + enumerable: true, + get() { + throw new Error('Request header diagnostics must never access the original headers.'); + }, + }, + visible: { + enumerable: true, + get() { + visibleReads += 1; + return 'preserved'; + }, + }, + hidden: { enumerable: false, value: 'omitted' }, + [metadata]: { enumerable: true, value: 'symbol metadata' }, + [String('__proto__')]: { enumerable: true, value: 'safe data property' }, + }); + + const details = formatRequestDetails({ options }); + const loggedOptions = details.options ?? {}; + const expectedOptions = Object.fromEntries([ + ['visible', 'preserved'], + [metadata, 'symbol metadata'], + [String('__proto__'), 'safe data property'], + ]); + + expect(visibleReads).toBe(1); + expect(loggedOptions).toEqual(expectedOptions); + expect(Object.getPrototypeOf(loggedOptions)).toBe(Object.prototype); + expect(Object.getOwnPropertyDescriptor(loggedOptions, 'headers')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(loggedOptions, 'hidden')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(loggedOptions, 'inherited')).toBeUndefined(); + }); + + test('redacts request paths, queries, and URLs without invoking original header accessors', () => { + const readHeaders = vi.fn(() => { + throw new Error('synthetic-private-header-credential'); + }); + const query = { + X_Access_Token: 'synthetic-query-secret', + visible: 'preserved', + }; + const options = Object.defineProperty( + { + path: '/models?api_key=synthetic-path-secret&visible=preserved#synthetic-private-fragment', + query, + }, + 'headers', + { enumerable: true, get: readHeaders }, + ) as RequestOptions; + + const details = formatRequestDetails({ + options, + url: 'https://synthetic-user:synthetic-password@example.test/models?client_secret=synthetic-url-secret&visible=preserved#synthetic-private-fragment', + }); + + expect(details.options).toEqual({ + path: '/models?api_key=***&visible=preserved', + query: { X_Access_Token: '***', visible: 'preserved' }, + }); + expect(details.url).toBe('https://example.test/models?client_secret=***&visible=preserved'); + expect(readHeaders).not.toHaveBeenCalled(); + expect(query.X_Access_Token).toBe('synthetic-query-secret'); + }); + + test.each([ + 'Authorization', + 'Proxy-Authorization', + 'API-Key', + 'X-API-Key', + 'X-Amz-Security-Token', + 'X-Session-Token', + 'X-Session-Id', + 'X-Auth-Token', + 'X-ID-Token', + 'Client-Secret', + 'X_Access_Token', + 'Password', + 'Cookie', + 'Set-Cookie', + ])('redacts the %s header without invoking its accessor', (name) => { + const secret = 'private-header-credential'; + const readSecret = vi.fn(() => { + throw new Error(secret); + }); + const readVisible = vi.fn(() => 'preserved'); + const headers: Record = {}; + Object.defineProperties(headers, { + [name]: { enumerable: true, get: readSecret }, + 'x-visible': { enumerable: true, get: readVisible }, + }); + + expect(formatRequestDetails({ headers }).headers).toEqual({ [name]: '***', 'x-visible': 'preserved' }); + expect(readSecret).not.toHaveBeenCalled(); + expect(readVisible).toHaveBeenCalledTimes(1); + }); + + test('formats Headers subclasses without invoking an overridden iterator', () => { + const iterate = vi.fn(() => { + throw new Error('private-header-credential'); + }); + class HostileHeaders extends Headers {} + Object.defineProperty(HostileHeaders.prototype, Symbol.iterator, { configurable: true, value: iterate }); + const headers = new HostileHeaders({ authorization: 'private-header-credential', 'x-visible': 'safe' }); + + expect(formatRequestDetails({ headers }).headers).toEqual({ authorization: '***', 'x-visible': 'safe' }); + expect(iterate).not.toHaveBeenCalled(); + }); + + test.each([ + 'Authorization', + 'Proxy-Authorization', + 'API-Key', + 'X-API-Key', + 'X-Amz-Security-Token', + 'X-Session-Token', + 'X-Session-Id', + 'X-Auth-Token', + 'X-ID-Token', + 'Client-Secret', + 'X_Access_Token', + 'Password', + 'Cookie', + 'Set-Cookie', + ])('redacts tuple-array %s headers without invoking their value accessors', (name) => { + const readSecret = vi.fn(() => { + throw new Error('private-header-credential'); + }); + const sensitive: [string, string] = [name, 'unused']; + Object.defineProperty(sensitive, 1, { get: readSecret }); + const headers: [string, string][] = [sensitive, ['x-visible', 'preserved']]; + + expect(formatRequestDetails({ headers }).headers).toEqual({ [name]: '***', 'x-visible': 'preserved' }); + expect(readSecret).not.toHaveBeenCalled(); + }); + + test.each(['ownKeys', 'getOwnPropertyDescriptor', 'getPrototypeOf'] as const)( + 'omits headers when a hostile proxy %s trap prevents safe inspection', + (operation) => { + const inspect = vi.fn(() => { + throw Object.assign(new Error('private-header-credential'), { + cause: new Error('private-header-credential'), + }); + }); + const handler: ProxyHandler> = {}; + Object.defineProperty(handler, operation, { value: inspect }); + const headers = new Proxy({ 'api-key': 'private-header-credential' }, handler); + + expect(formatRequestDetails({ headers }).headers).toEqual({}); + expect(inspect).toHaveBeenCalledTimes(1); + }, + ); +}); + describe('debug()', () => { const env = process.env; const spy = vi.spyOn(console, 'debug'); diff --git a/tests/realtime-websocket.test.ts b/tests/realtime-websocket.test.ts index f4ee62984..402395e9f 100644 --- a/tests/realtime-websocket.test.ts +++ b/tests/realtime-websocket.test.ts @@ -115,6 +115,58 @@ function createAzureClient( }); } +function statefulCredential(first: string, second: string) { + let coercions = 0; + let hookReads = 0; + let iteratorReads = 0; + const value = { + startsWith() { + return false; + }, + [Symbol.iterator]() { + iteratorReads += 1; + return 'safe iterable credential'[Symbol.iterator](); + }, + }; + Object.defineProperty(value, Symbol.toPrimitive, { + get() { + hookReads += 1; + return () => { + coercions += 1; + return coercions === 1 ? first : second; + }; + }, + }); + return { + value, + counts: () => ({ coercions, hookReads, iteratorReads }), + }; +} + +function throwingCredential(kind: 'Symbol.toPrimitive getter' | 'Symbol.toPrimitive method' | 'toString') { + let coercions = 0; + const fail = () => { + coercions += 1; + throw Object.assign(new Error('azure-private-credential-75da'), { + cause: new Error('private-patient-record-21f8'), + }); + }; + const value = { + startsWith() { + return false; + }, + toString: fail, + }; + + if (kind === 'Symbol.toPrimitive getter') { + Object.defineProperty(value, Symbol.toPrimitive, { get: fail }); + } else if (kind === 'Symbol.toPrimitive method') { + Object.defineProperty(value, Symbol.toPrimitive, { value: fail }); + } + + return { value, coercions: () => coercions }; +} + beforeEach(() => { FakeBrowserSocket.instances = []; nodeSocketConstructor.mockClear(); @@ -134,6 +186,617 @@ afterEach(() => { }); }); +describe('Azure realtime credential diagnostic privacy', () => { + const surfaces = [ + { name: 'stable native', open: (client: AzureOpenAI) => StableBrowserRealtime.azure(client) }, + { name: 'beta native', open: (client: AzureOpenAI) => BetaBrowserRealtime.azure(client) }, + { name: 'stable Node ws', open: (client: AzureOpenAI) => StableNodeRealtime.azure(client) }, + { name: 'beta Node ws', open: (client: AzureOpenAI) => BetaNodeRealtime.azure(client) }, + ]; + const invalidCharacters = [ + ...Array.from({ length: 0x20 }, (_, code) => code) + .filter((code) => code !== 0x09) + .map((code) => ({ + name: `U+${code.toString(16).padStart(4, '0')}`, + value: String.fromCodePoint(code), + })), + { name: 'DEL', value: String.fromCodePoint(0x7f) }, + { name: 'Unicode', value: '\u{1F680}' }, + { name: 'lone surrogate', value: String.fromCodePoint(0xd8_00) }, + ]; + const throwingCoercions = ['Symbol.toPrimitive getter', 'Symbol.toPrimitive method', 'toString'] as const; + + test.each( + surfaces.flatMap((surface) => + ([false, true] as const).flatMap((rotating) => + throwingCoercions.map((kind) => ({ ...surface, rotating, kind })), + ), + ), + )( + '$name sanitizes throwing $kind credential coercion (rotating: $rotating)', + async ({ open, rotating, kind }) => { + const observed = throwingCredential(kind); + const provider = vi.fn(async () => 'safe-provider-token'); + const client = new AzureOpenAI({ + baseURL: 'https://azure.example.com/openai/', + apiVersion: '2024-10-01-preview', + deployment: 'chat', + ...(rotating ? { azureADTokenProvider: provider } : { apiKey: 'azure-key' }), + }); + Object.defineProperty(client, 'apiKey', { + configurable: true, + get: () => observed.value, + set() {}, + }); + + let failure: unknown; + try { + await open(client); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(TypeError); + expect((failure as TypeError).message).toBe( + 'Azure OpenAI credential contains an invalid HTTP header value.', + ); + expect((failure as TypeError & { cause?: unknown }).cause).toBeUndefined(); + expect((failure as Error).stack).not.toContain('azure-private-credential-75da'); + expect((failure as Error).stack).not.toContain('private-patient-record-21f8'); + expect(observed.coercions()).toBe(1); + expect(provider).toHaveBeenCalledTimes(rotating ? 1 : 0); + expect(FakeBrowserSocket.instances).toHaveLength(0); + expect(nodeSocketConstructor).not.toHaveBeenCalled(); + }, + ); + + test.each( + [ + { name: 'stable native', open: (client: AzureOpenAI) => StableBrowserRealtime.azure(client) }, + { name: 'beta native', open: (client: AzureOpenAI) => BetaBrowserRealtime.azure(client) }, + ].flatMap((surface) => + ([false, true] as const).flatMap((rotating) => + (['getter', 'method'] as const).map((operation) => ({ ...surface, rotating, operation })), + ), + ), + )( + '$name ignores a throwing credential startsWith $operation (rotating: $rotating)', + async ({ open, rotating, operation }) => { + const secret = 'azure-private-credential-75da'; + const inspect = vi.fn(() => { + throw Object.assign(new Error(secret), { cause: new Error('private-patient-record-21f8') }); + }); + const credential = { + toString() { + throw Object.assign(new Error(secret), { cause: new Error('private-patient-record-21f8') }); + }, + }; + Object.defineProperty( + credential, + 'startsWith', + operation === 'getter' ? { get: inspect } : { value: inspect }, + ); + const provider = vi.fn(async () => 'safe-provider-token'); + const client = new AzureOpenAI({ + baseURL: 'https://azure.example.com/openai/', + apiVersion: '2024-10-01-preview', + deployment: 'chat', + ...(rotating ? { azureADTokenProvider: provider } : { apiKey: 'azure-key' }), + }); + Object.defineProperty(client, 'apiKey', { + configurable: true, + get: () => credential, + set() {}, + }); + + await expect(open(client)).rejects.toEqual( + new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'), + ); + + expect(inspect).not.toHaveBeenCalled(); + expect(provider).toHaveBeenCalledTimes(rotating ? 1 : 0); + expect(FakeBrowserSocket.instances).toHaveLength(0); + }, + ); + + test.each( + surfaces.flatMap((surface) => + ([false, true] as const).flatMap((rotating) => + invalidCharacters.map((invalid) => ({ ...surface, rotating, invalid })), + ), + ), + )( + '$name rejects $invalid.name before constructing a socket (rotating: $rotating)', + async ({ open, rotating, invalid }) => { + const credential = `azure-private-credential-75da${invalid.value}private-patient-record-21f8`; + const provider = vi.fn(async () => credential); + const client = new AzureOpenAI({ + baseURL: 'https://azure.example.com/openai/', + apiVersion: '2024-10-01-preview', + deployment: 'chat', + ...(rotating ? { azureADTokenProvider: provider } : { apiKey: credential }), + }); + let failure: unknown; + try { + await open(client); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(TypeError); + expect((failure as TypeError).message).toBe( + 'Azure OpenAI credential contains an invalid HTTP header value.', + ); + expect((failure as TypeError & { cause?: unknown }).cause).toBeUndefined(); + expect((failure as Error).stack).not.toContain('azure-private-credential-75da'); + expect(FakeBrowserSocket.instances).toHaveLength(0); + expect(nodeSocketConstructor).not.toHaveBeenCalled(); + expect(provider).toHaveBeenCalledTimes(rotating ? 1 : 0); + }, + ); + + test.each( + surfaces.flatMap((surface) => + ([false, true] as const).flatMap((rotating) => + (['safe key', 'safe\tkey', 'safe\u0080key', 'safe\u00FFkey'] as const).map((credential) => ({ + ...surface, + rotating, + credential, + })), + ), + ), + )( + '$name preserves valid Azure HTTP field bytes (rotating: $rotating)', + async ({ open, rotating, credential }) => { + const provider = vi.fn(async () => credential); + const client = new AzureOpenAI({ + baseURL: 'https://azure.example.com/openai/', + apiVersion: '2024-10-01-preview', + deployment: 'chat', + ...(rotating ? { azureADTokenProvider: provider } : { apiKey: credential }), + }); + await open(client); + expect(provider).toHaveBeenCalledTimes(rotating ? 1 : 0); + }, + ); + + test.each( + surfaces.flatMap((surface) => + ([false, true] as const).flatMap((rotating) => + ([false, true] as const).map((malformedFirst) => ({ ...surface, rotating, malformedFirst })), + ), + ), + )( + '$name snapshots mutable credential coercion once (rotating: $rotating, malformed first: $malformedFirst)', + async ({ name, open, rotating, malformedFirst }) => { + const safe = 'safe credential\tvalue\u00FF'; + const malformed = 'azure-private-credential-75da\nprivate-patient-record-21f8'; + const observed = statefulCredential( + malformedFirst ? malformed : safe, + malformedFirst ? safe : malformed, + ); + const provider = vi.fn(async () => 'safe-provider-token'); + const client = new AzureOpenAI({ + baseURL: 'https://azure.example.com/openai/', + apiVersion: '2024-10-01-preview', + deployment: 'chat', + ...(rotating ? { azureADTokenProvider: provider } : { apiKey: 'azure-key' }), + }); + Object.defineProperty(client, 'apiKey', { + configurable: true, + get: () => observed.value, + set() {}, + }); + + if (malformedFirst) { + let failure: unknown; + try { + await open(client); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(TypeError); + expect((failure as TypeError).message).toBe( + 'Azure OpenAI credential contains an invalid HTTP header value.', + ); + expect((failure as TypeError & { cause?: unknown }).cause).toBeUndefined(); + expect((failure as Error).stack).not.toContain('azure-private-credential-75da'); + expect(FakeBrowserSocket.instances).toHaveLength(0); + expect(nodeSocketConstructor).not.toHaveBeenCalled(); + } else { + await open(client); + const headers = name.includes('native') + ? lastBrowserSocket().headers + : lastNodeSocket().options.headers; + const field = rotating ? 'Authorization' : 'api-key'; + expect(Reflect.get(headers ?? {}, field)).toBe(rotating ? `Bearer ${safe}` : safe); + } + + expect(observed.counts()).toEqual({ coercions: 1, hookReads: 1, iteratorReads: 0 }); + expect(provider).toHaveBeenCalledTimes(rotating ? 1 : 0); + }, + ); + + test.each( + [ + { name: 'stable', open: StableNodeRealtime.azure.bind(StableNodeRealtime) }, + { name: 'beta', open: BetaNodeRealtime.azure.bind(BetaNodeRealtime) }, + ].flatMap((surface) => + ([false, true] as const).flatMap((rotating) => + (['scalar', 'array'] as const).flatMap((shape) => + throwingCoercions.map((kind) => ({ ...surface, rotating, shape, kind })), + ), + ), + ), + )( + '$name Node ws sanitizes throwing $kind $shape header coercion (rotating: $rotating)', + async ({ open, rotating, shape, kind }) => { + const observed = throwingCredential(kind); + const provider = vi.fn(async () => 'safe-provider-token'); + const client = new AzureOpenAI({ + baseURL: 'https://azure.example.com/openai/', + apiVersion: '2024-10-01-preview', + deployment: 'chat', + ...(rotating ? { azureADTokenProvider: provider } : { apiKey: 'azure-key' }), + }); + const field = rotating ? 'api-key' : 'Authorization'; + const headers: Record = {}; + Object.defineProperty(headers, field, { + enumerable: true, + value: shape === 'array' ? [observed.value] : observed.value, + }); + + let failure: unknown; + try { + await open(client, { options: { headers } }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(TypeError); + expect((failure as TypeError).message).toBe( + 'Azure OpenAI credential contains an invalid HTTP header value.', + ); + expect((failure as TypeError & { cause?: unknown }).cause).toBeUndefined(); + expect((failure as Error).stack).not.toContain('azure-private-credential-75da'); + expect((failure as Error).stack).not.toContain('private-patient-record-21f8'); + expect(observed.coercions()).toBe(1); + expect(provider).toHaveBeenCalledTimes(rotating ? 1 : 0); + expect(nodeSocketConstructor).not.toHaveBeenCalled(); + }, + ); + + test.each( + [ + { name: 'stable', open: StableNodeRealtime.azure.bind(StableNodeRealtime) }, + { name: 'beta', open: BetaNodeRealtime.azure.bind(BetaNodeRealtime) }, + ].flatMap((surface) => + ([false, true] as const).flatMap((rotating) => + (['length', 'index'] as const).map((operation) => ({ ...surface, rotating, operation })), + ), + ), + )( + '$name Node ws sanitizes a throwing credential array $operation accessor (rotating: $rotating)', + async ({ open, rotating, operation }) => { + const secret = 'azure-private-credential-75da'; + const inspect = vi.fn(() => { + throw Object.assign(new Error(secret), { cause: new Error('private-patient-record-21f8') }); + }); + const credential = new Proxy(['safe-token'], { + get(target, property, receiver) { + if (property === (operation === 'length' ? 'length' : '0')) { + return inspect(); + } + return Reflect.get(target, property, receiver); + }, + }); + const provider = vi.fn(async () => 'safe-provider-token'); + const client = new AzureOpenAI({ + baseURL: 'https://azure.example.com/openai/', + apiVersion: '2024-10-01-preview', + deployment: 'chat', + ...(rotating ? { azureADTokenProvider: provider } : { apiKey: 'azure-key' }), + }); + const header = rotating ? 'api-key' : 'Authorization'; + const headers: Record = {}; + Object.defineProperty(headers, header, { enumerable: true, value: credential }); + + await expect(open(client, { options: { headers } })).rejects.toEqual( + new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'), + ); + + expect(inspect).toHaveBeenCalledTimes(1); + expect(provider).toHaveBeenCalledTimes(rotating ? 1 : 0); + expect(nodeSocketConstructor).not.toHaveBeenCalled(); + }, + ); + + test.each( + [ + { name: 'stable', open: StableNodeRealtime.azure.bind(StableNodeRealtime) }, + { name: 'beta', open: BetaNodeRealtime.azure.bind(BetaNodeRealtime) }, + ].flatMap((surface) => + ([false, true] as const).flatMap((rotating) => + (['scalar', 'array'] as const).flatMap((shape) => + ([false, true] as const).map((malformedFirst) => ({ + ...surface, + rotating, + shape, + malformedFirst, + })), + ), + ), + ), + )( + '$name Node ws snapshots $shape header coercion once (rotating: $rotating, malformed first: $malformedFirst)', + async ({ open, rotating, shape, malformedFirst }) => { + const safe = 'safe header\tvalue\u0080'; + const malformed = 'azure-private-credential-75da\nprivate-patient-record-21f8'; + const observed = statefulCredential( + malformedFirst ? malformed : safe, + malformedFirst ? safe : malformed, + ); + const provider = vi.fn(async () => 'safe-provider-token'); + const client = new AzureOpenAI({ + baseURL: 'https://azure.example.com/openai/', + apiVersion: '2024-10-01-preview', + deployment: 'chat', + ...(rotating ? { azureADTokenProvider: provider } : { apiKey: 'azure-key' }), + }); + const field = rotating ? 'api-key' : 'Authorization'; + const headers: Record = {}; + Object.defineProperty(headers, field, { + enumerable: true, + value: shape === 'array' ? [observed.value] : observed.value, + }); + + if (malformedFirst) { + let failure: unknown; + try { + await open(client, { options: { headers } }); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(TypeError); + expect((failure as TypeError).message).toBe( + 'Azure OpenAI credential contains an invalid HTTP header value.', + ); + expect((failure as TypeError & { cause?: unknown }).cause).toBeUndefined(); + expect((failure as Error).stack).not.toContain('azure-private-credential-75da'); + expect(nodeSocketConstructor).not.toHaveBeenCalled(); + } else { + await open(client, { options: { headers } }); + expect(Reflect.get(lastNodeSocket().options.headers ?? {}, field)).toEqual( + shape === 'array' ? [safe] : safe, + ); + } + + expect(observed.counts()).toEqual({ coercions: 1, hookReads: 1, iteratorReads: 0 }); + expect(provider).toHaveBeenCalledTimes(rotating ? 1 : 0); + }, + ); + + test.each([ + { name: 'stable', open: StableNodeRealtime.azure.bind(StableNodeRealtime) }, + { name: 'beta', open: BetaNodeRealtime.azure.bind(BetaNodeRealtime) }, + ])('$name Node ws rejects invalid custom authentication overrides', async ({ open }) => { + const client = createAzureClient({ deployment: 'chat' }); + await expect( + open(client, { + options: { headers: { Authorization: 'azure-private-credential-75da\nprivate-patient-record-21f8' } }, + }), + ).rejects.toThrow('Azure OpenAI credential contains an invalid HTTP header value.'); + expect(nodeSocketConstructor).not.toHaveBeenCalled(); + }); + + const arrayHeaderSurfaces = [ + { name: 'stable', open: StableNodeRealtime.azure.bind(StableNodeRealtime) }, + { name: 'beta', open: BetaNodeRealtime.azure.bind(BetaNodeRealtime) }, + ].flatMap((surface) => + (['getter', 'proxy'] as const).flatMap((kind) => + ([false, true] as const).map((rotating) => ({ ...surface, kind, rotating })), + ), + ); + + test.each(arrayHeaderSurfaces)( + '$name Node ws snapshots $kind credential arrays before dispatch (rotating: $rotating)', + async ({ open, kind, rotating }) => { + const malformed = 'azure-private-credential-75da\nprivate-patient-record-21f8'; + const safe = 'safe-array credential\tvalue\u00FF'; + let reads = 0; + const original = [safe]; + if (kind === 'getter') { + Object.defineProperty(original, '0', { + configurable: true, + enumerable: true, + get() { + reads += 1; + return reads === 1 ? safe : malformed; + }, + }); + } + const credential = + kind === 'proxy' + ? new Proxy(original, { + get(target, property, receiver) { + if (property === '0') { + reads += 1; + return reads === 1 ? safe : malformed; + } + return Reflect.get(target, property, receiver); + }, + }) + : original; + const unrelated = ['keep caller array']; + const provider = vi.fn(async () => 'safe-provider-token'); + const client = new AzureOpenAI({ + baseURL: 'https://azure.example.com/openai/', + apiVersion: '2024-10-01-preview', + deployment: 'chat', + ...(rotating ? { azureADTokenProvider: provider } : { apiKey: 'azure-key' }), + }); + const headerName = rotating ? 'api-key' : 'Authorization'; + const headers: Record = {}; + Object.defineProperties(headers, { + [headerName]: { enumerable: true, value: credential }, + 'X-Unrelated': { enumerable: true, value: unrelated }, + }); + + await open(client, { options: { headers } }); + + const outgoing = lastNodeSocket().options.headers ?? {}; + const dispatched: unknown = Reflect.get(outgoing, headerName); + expect(reads).toBe(1); + expect(dispatched === credential).toBe(false); + expect(dispatched).toEqual([safe]); + expect(Reflect.get(outgoing, 'X-Unrelated')).toBe(unrelated); + expect(reads).toBe(1); + expect(provider).toHaveBeenCalledTimes(rotating ? 1 : 0); + }, + ); + + test.each([ + { name: 'stable', open: StableNodeRealtime.azure.bind(StableNodeRealtime) }, + { name: 'beta', open: BetaNodeRealtime.azure.bind(BetaNodeRealtime) }, + ])( + '$name Node ws snapshots credential arrays by bounded index without invoking their iterator', + async ({ open }) => { + let iteratorReads = 0; + let indexedReads = 0; + const credential = ['safe-first', 'safe-second']; + Object.defineProperty(credential, '0', { + configurable: true, + enumerable: true, + get() { + indexedReads += 1; + return 'safe-first'; + }, + }); + Object.defineProperty(credential, Symbol.iterator, { + configurable: true, + get() { + iteratorReads += 1; + throw new Error('An untrusted credential iterator must never run.'); + }, + }); + const headers: Record = {}; + Object.defineProperty(headers, 'Authorization', { enumerable: true, value: credential }); + + await open(createAzureClient({ deployment: 'chat' }), { options: { headers } }); + + expect(Reflect.get(lastNodeSocket().options.headers ?? {}, 'Authorization')).toEqual([ + 'safe-first', + 'safe-second', + ]); + expect(indexedReads).toBe(1); + expect(iteratorReads).toBe(0); + expect(nodeSocketConstructor).toHaveBeenCalledTimes(1); + }, + ); + + test.each( + [ + { name: 'stable', open: StableNodeRealtime.azure.bind(StableNodeRealtime) }, + { name: 'beta', open: BetaNodeRealtime.azure.bind(BetaNodeRealtime) }, + ].flatMap((surface) => + ([1025, Number.POSITIVE_INFINITY, -1] as const).map((length) => ({ ...surface, length })), + ), + )( + '$name Node ws rejects an unsafe credential array length $length before iteration', + async ({ open, length }) => { + let lengthReads = 0; + let iteratorReads = 0; + let indexReads = 0; + const credential = new Proxy(['safe-token'], { + get(target, property, receiver) { + if (property === 'length') { + lengthReads += 1; + return length; + } + if (property === Symbol.iterator) { + iteratorReads += 1; + throw new Error('An untrusted credential iterator must never run.'); + } + if (property === '0') { + indexReads += 1; + } + return Reflect.get(target, property, receiver); + }, + }); + const headers: Record = {}; + Object.defineProperty(headers, 'Authorization', { enumerable: true, value: credential }); + + await expect(open(createAzureClient({ deployment: 'chat' }), { options: { headers } })).rejects.toThrow( + 'Azure OpenAI credential contains an invalid HTTP header value.', + ); + + expect(lengthReads).toBe(1); + expect(iteratorReads).toBe(0); + expect(indexReads).toBe(0); + expect(nodeSocketConstructor).not.toHaveBeenCalled(); + }, + ); + + test.each([ + { name: 'stable', open: StableNodeRealtime.azure.bind(StableNodeRealtime) }, + { name: 'beta', open: BetaNodeRealtime.azure.bind(BetaNodeRealtime) }, + ])('$name Node ws preserves finite sparse authentication arrays', async ({ open }) => { + const credential = ['safe-first']; + credential.length = 3; + const headers: Record = {}; + Object.defineProperty(headers, 'Authorization', { enumerable: true, value: credential }); + + await open(createAzureClient({ deployment: 'chat' }), { options: { headers } }); + + expect(Reflect.get(lastNodeSocket().options.headers ?? {}, 'Authorization')).toEqual([ + 'safe-first', + undefined, + undefined, + ]); + }); + + test.each([ + { name: 'stable', open: StableNodeRealtime.azure.bind(StableNodeRealtime) }, + { name: 'beta', open: BetaNodeRealtime.azure.bind(BetaNodeRealtime) }, + ])( + '$name Node ws rejects malformed array credentials before constructing a transport', + async ({ open }) => { + const malformed = 'azure-private-credential-75da\nprivate-patient-record-21f8'; + let reads = 0; + const credential = ['ignored']; + Object.defineProperty(credential, '0', { + configurable: true, + enumerable: true, + get() { + reads += 1; + return malformed; + }, + }); + + const headers: Record = {}; + Object.defineProperty(headers, 'Authorization', { enumerable: true, value: credential }); + + await expect(open(createAzureClient({ deployment: 'chat' }), { options: { headers } })).rejects.toThrow( + 'Azure OpenAI credential contains an invalid HTTP header value.', + ); + expect(reads).toBe(1); + expect(nodeSocketConstructor).not.toHaveBeenCalled(); + }, + ); + + test.each([ + { name: 'stable', open: StableNodeRealtime.azure.bind(StableNodeRealtime) }, + { name: 'beta', open: BetaNodeRealtime.azure.bind(BetaNodeRealtime) }, + ])('$name Node ws preserves safe case-insensitive final credential overrides', async ({ open }) => { + const client = createAzureClient({ deployment: 'chat' }); + await open(client, { + options: { + headers: { 'API-KEY': 'azure-private-credential-75da\nprivate-patient-record-21f8' }, + }, + }); + expect(lastNodeSocket().options.headers).toMatchObject({ 'api-key': 'azure-key' }); + }); +}); + describe.each([ { name: 'stable', Realtime: StableBrowserRealtime, beta: false }, { name: 'beta', Realtime: BetaBrowserRealtime, beta: true },