From 5e2b22a09cc23f3a2a6f239167b759d8f8b50114 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 13:32:10 -0700 Subject: [PATCH 01/35] fix(security): redact invalid Azure authentication credentials --- src/azure.ts | 12 +- .../azure-credential-header-privacy.test.ts | 388 ++++++++++++++++++ 2 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 tests/lib/azure-credential-header-privacy.test.ts diff --git a/src/azure.ts b/src/azure.ts index 8731d3605..12eb5d645 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -195,8 +195,18 @@ export class AzureOpenAI extends OpenAI { schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, ): Promise { const security = schemes ?? { bearerAuth: true, adminAPIKeyAuth: true }; + const credential = this.apiKey; + if (security.bearerAuth && typeof credential === 'string') { + for (const character of credential) { + 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.'); + } + } + } + if (security.bearerAuth && typeof this._options.apiKey === 'string') { - return buildHeaders([{ 'api-key': this.apiKey }]); + return buildHeaders([{ 'api-key': credential }]); } return super.authHeaders(opts, security); } 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..270d968d5 --- /dev/null +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -0,0 +1,388 @@ +import { vi } from 'vitest'; + +import { AzureOpenAI, OpenAIError } from 'openai'; +import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; + +type Authentication = 'static-api-key' | 'rotating-entra-token'; +type PublicRoute = 'generic-request' | 'models-list' | 'chat-completion'; +type Fetch = (url: RequestInfo, init?: RequestInit) => Promise; + +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; +} + +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('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); + }); +}); From 222e643aa245f6a70908afafa9ed8f6b92e82da8 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 21:09:43 +0000 Subject: [PATCH 02/35] fix(azure): protect effective HTTP and realtime credentials --- src/azure.ts | 24 +- src/beta/realtime/websocket.ts | 2 + src/beta/realtime/ws.ts | 3 +- src/internal/azure.ts | 56 +++++ src/internal/headers.ts | 60 ++++- src/realtime/websocket.ts | 2 + src/realtime/ws.ts | 3 +- .../azure-credential-header-privacy.test.ts | 211 ++++++++++++++++++ .../lib/azure-deployment-path-safety.test.ts | 11 +- tests/realtime-websocket.test.ts | 106 +++++++++ 10 files changed, 458 insertions(+), 20 deletions(-) create mode 100644 src/internal/azure.ts diff --git a/src/azure.ts b/src/azure.ts index 12eb5d645..6d5f989cb 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -1,6 +1,6 @@ import type { RequestInit, RequestInfo, Response } from './internal/builtin-types'; import type { NullableHeaders } from './internal/headers'; -import { buildHeaders } from './internal/headers'; +import { buildAzureAuthenticationHeaders } from './internal/headers'; import * as Errors from './error'; import type { FinalRequestOptions } from './internal/request-options'; import { isObj, readEnv } from './internal/utils'; @@ -195,20 +195,20 @@ export class AzureOpenAI extends OpenAI { schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, ): Promise { const security = schemes ?? { bearerAuth: true, adminAPIKeyAuth: true }; - const credential = this.apiKey; - if (security.bearerAuth && typeof credential === 'string') { - for (const character of credential) { - 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.'); - } - } + if (security.bearerAuth && typeof this._options.apiKey === 'string') { + return buildAzureAuthenticationHeaders( + typeof this.apiKey === 'string' ? [['api-key', this.apiKey]] : [], + ); } - if (security.bearerAuth && typeof this._options.apiKey === 'string') { - return buildHeaders([{ 'api-key': credential }]); + let authorization: string | null = null; + if (security.bearerAuth && typeof this.apiKey === 'string') { + authorization = `Bearer ${this.apiKey}`; + } + if (security.adminAPIKeyAuth && typeof this.adminAPIKey === 'string') { + authorization = `Bearer ${this.adminAPIKey}`; } - return super.authHeaders(opts, security); + return buildAzureAuthenticationHeaders(authorization === null ? [] : [['Authorization', authorization]]); } } diff --git a/src/beta/realtime/websocket.ts b/src/beta/realtime/websocket.ts index dfa10a3b7..7bd5b47ae 100644 --- a/src/beta/realtime/websocket.ts +++ b/src/beta/realtime/websocket.ts @@ -1,4 +1,5 @@ import type { AzureOpenAI } from '../../index'; +import { assertAzureCredentialHeaderValue } from '../../internal/azure'; import { assertBedrockWebSocketOrigin } from '../../internal/bedrock'; import { OpenAI } from '../../index'; import { OpenAIError } from '../../error'; @@ -119,6 +120,7 @@ function createAzureWebSocket( throw new Error('Azure OpenAI Realtime requires an API key'); } + assertAzureCredentialHeaderValue(apiKey); redactAzureCredentials(url, isBearerToken); const socketURL = new URL(url); socketURL.searchParams.delete('api-key'); diff --git a/src/beta/realtime/ws.ts b/src/beta/realtime/ws.ts index 20d2de8ff..b02ec5255 100644 --- a/src/beta/realtime/ws.ts +++ b/src/beta/realtime/ws.ts @@ -1,4 +1,5 @@ import * as WS from 'ws'; +import { safeAzureWebSocketHeaders } from '../../internal/azure'; import { assertBedrockWebSocketOrigin } from '../../internal/bedrock'; import { protectWebSocketOptionsFromCredentialRedirects } from '../../internal/ws'; import type { AzureOpenAI } from '../../index'; @@ -77,7 +78,7 @@ export class OpenAIRealtimeWS extends OpenAIRealtimeEmitter { this.url, protectWebSocketOptionsFromCredentialRedirects({ ...props.options, - headers, + headers: isAzure(client) ? safeAzureWebSocketHeaders(headers) : headers, }), ); diff --git a/src/internal/azure.ts b/src/internal/azure.ts new file mode 100644 index 000000000..afcac9705 --- /dev/null +++ b/src/internal/azure.ts @@ -0,0 +1,56 @@ +/** 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.'); + } + } +} + +/** 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); + const values = Array.isArray(value) ? value : [value]; + for (const entry of values) { + if (typeof entry === 'string') { + assertAzureCredentialHeaderValue(entry); + } + } + } + return Object.fromEntries(safeHeaders) as Headers; +} diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 216523774..a13e78ebb 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -1,3 +1,4 @@ +import { assertAzureCredentialHeaderValue, isAzureAuthenticationHeader } from './azure'; import { isReadonlyArray } from './utils/values'; type HeaderValue = string | undefined | null; @@ -26,10 +27,33 @@ export type NullableHeaders = { nulls: Set; }; +type AzureAuthenticationValues = ReadonlyArray; + +// Object-identity branding cannot be forged by caller-provided header records. +const azureAuthenticationHeaders = new WeakMap(); + +/** + * 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 carrier: NullableHeaders = { + [brand_privateNullableHeaders]: true, + values: new Headers(), + nulls: new Set(), + }; + azureAuthenticationHeaders.set(carrier, headers); + return carrier; +}; + function* iterateHeaders(headers: HeadersLike): IterableIterator { if (!headers) return; if (brand_privateNullableHeaders in headers) { + const azureHeaders = azureAuthenticationHeaders.get(headers); + if (azureHeaders !== undefined) { + yield* azureHeaders; + } const { values, nulls } = headers; yield* values.entries(); for (const name of nulls) { @@ -70,6 +94,14 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator { const targetHeaders = new Headers(); const nullHeaders = new Set(); + const protectsAzureCredentials = newHeaders.some( + (headers) => + typeof headers === 'object' && + headers !== null && + azureAuthenticationHeaders.has(headers as NullableHeaders), + ); + const pendingAuthenticationHeaders = new Map(); + for (const headers of newHeaders) { const seenHeaders = new Set(); for (const [name, value] of iterateHeaders(headers)) { @@ -77,19 +109,45 @@ export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { 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) { + const pending = pendingAuthenticationHeaders.get(lowerName); + if (pending) { + pending.push(value); + } else { + pendingAuthenticationHeaders.set(lowerName, [value]); + } + } else { + targetHeaders.append(lowerName, value); + } nullHeaders.delete(lowerName); } } } + for (const values of pendingAuthenticationHeaders.values()) { + for (const value of values) { + assertAzureCredentialHeaderValue(value); + } + } + for (const [name, values] of pendingAuthenticationHeaders) { + for (const value of values) { + targetHeaders.append(name, value); + } + } return { [brand_privateNullableHeaders]: true, values: targetHeaders, nulls: nullHeaders }; }; diff --git a/src/realtime/websocket.ts b/src/realtime/websocket.ts index 196a1180e..9a23bd540 100644 --- a/src/realtime/websocket.ts +++ b/src/realtime/websocket.ts @@ -1,4 +1,5 @@ import type { AzureOpenAI } from '../index'; +import { assertAzureCredentialHeaderValue } from '../internal/azure'; import { assertBedrockWebSocketOrigin } from '../internal/bedrock'; import { OpenAI } from '../index'; import { OpenAIError } from '../error'; @@ -125,6 +126,7 @@ function createAzureWebSocket( throw new Error('Azure OpenAI Realtime requires an API key'); } + assertAzureCredentialHeaderValue(apiKey); redactAzureCredentials(url, isBearerToken); const socketURL = new URL(url); socketURL.searchParams.delete('api-key'); diff --git a/src/realtime/ws.ts b/src/realtime/ws.ts index b18931756..3bdc35ebd 100644 --- a/src/realtime/ws.ts +++ b/src/realtime/ws.ts @@ -1,4 +1,5 @@ import * as WS from 'ws'; +import { safeAzureWebSocketHeaders } from '../internal/azure'; import { assertBedrockWebSocketOrigin } from '../internal/bedrock'; import { protectWebSocketOptionsFromCredentialRedirects } from '../internal/ws'; import type { AzureOpenAI } from '../index'; @@ -70,7 +71,7 @@ export class OpenAIRealtimeWS extends OpenAIRealtimeEmitter { this.url, protectWebSocketOptionsFromCredentialRedirects({ ...props.options, - headers, + headers: isAzure(client) ? safeAzureWebSocketHeaders(headers) : headers, }), ); diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index 270d968d5..d45f332eb 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -362,6 +362,217 @@ describe('Azure credential header diagnostic privacy', () => { 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); + }, + ); + + 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>() diff --git a/tests/lib/azure-deployment-path-safety.test.ts b/tests/lib/azure-deployment-path-safety.test.ts index 98300d196..6bd77aed5 100644 --- a/tests/lib/azure-deployment-path-safety.test.ts +++ b/tests/lib/azure-deployment-path-safety.test.ts @@ -27,11 +27,12 @@ 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( - { url, apiKey: new Headers(init?.headers).get('api-key') }, - { headers: { 'content-type': 'application/json' } }, - ), + const authenticatedFetch = vi.fn( + async (url: RequestInfo, init?: RequestInit): Promise => + Response.json( + { url, apiKey: new Headers(init?.headers).get('api-key') }, + { headers: { 'content-type': 'application/json' } }, + ), ); const client = new AzureOpenAI({ endpoint, apiKey, apiVersion, fetch: authenticatedFetch }); diff --git a/tests/realtime-websocket.test.ts b/tests/realtime-websocket.test.ts index f4ee62984..a536973e0 100644 --- a/tests/realtime-websocket.test.ts +++ b/tests/realtime-websocket.test.ts @@ -134,6 +134,112 @@ 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) }, + ]; + + 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([ + { 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(); + }); + + 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 }, From 7ff52b636cc0d0260c11aa3319b20b53e5beb687 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 21:42:50 +0000 Subject: [PATCH 03/35] fix(azure): protect credentials across headers and hooks --- src/azure.ts | 38 +++- src/internal/headers.ts | 25 ++- .../azure-credential-header-privacy.test.ts | 192 ++++++++++++++++++ .../lib/azure-deployment-path-safety.test.ts | 16 +- 4 files changed, 249 insertions(+), 22 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index 6d5f989cb..c61deafb0 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -1,6 +1,6 @@ import type { RequestInit, RequestInfo, Response } from './internal/builtin-types'; import type { NullableHeaders } from './internal/headers'; -import { buildAzureAuthenticationHeaders } from './internal/headers'; +import { assertAzureAuthenticationHeaders, buildAzureAuthenticationHeaders } from './internal/headers'; import * as Errors from './error'; import type { FinalRequestOptions } from './internal/request-options'; import { isObj, readEnv } from './internal/utils'; @@ -126,6 +126,7 @@ export class AzureOpenAI extends OpenAI { throw new Errors.OpenAIError('baseURL and endpoint are mutually exclusive'); } + protectAzureAmbientHeaders(opts); super({ apiKey: azureADTokenProvider ?? apiKey, baseURL, @@ -176,13 +177,14 @@ export class AzureOpenAI extends OpenAI { return built; } - protected override async fetchWithAuth( + protected override fetchWithAuth( url: RequestInfo, init: RequestInit, timeout: number, controller: AbortController, schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, ): Promise { + assertAzureAuthenticationHeaders(init.headers); if (new Headers(init.headers).has('api-key')) { init.redirect = 'manual'; } @@ -196,19 +198,33 @@ export class AzureOpenAI extends OpenAI { ): Promise { const security = schemes ?? { bearerAuth: true, adminAPIKeyAuth: true }; if (security.bearerAuth && typeof this._options.apiKey === 'string') { - return buildAzureAuthenticationHeaders( - typeof this.apiKey === 'string' ? [['api-key', this.apiKey]] : [], - ); + return buildAzureAuthenticationHeaders([['api-key', this.apiKey]]); } - let authorization: string | null = null; - if (security.bearerAuth && typeof this.apiKey === 'string') { - authorization = `Bearer ${this.apiKey}`; + return 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) { + return undefined; } - if (security.adminAPIKeyAuth && typeof this.adminAPIKey === 'string') { - authorization = `Bearer ${this.adminAPIKey}`; + return buildAzureAuthenticationHeaders([['Authorization', `Bearer ${this.apiKey}`]]); + } + + protected override async adminAPIKeyAuth(_opts: FinalRequestOptions): Promise { + if (this.adminAPIKey === null || this.adminAPIKey === undefined) { + return undefined; } - return buildAzureAuthenticationHeaders(authorization === null ? [] : [['Authorization', authorization]]); + return buildAzureAuthenticationHeaders([['Authorization', `Bearer ${this.adminAPIKey}`]]); + } +} + +function protectAzureAmbientHeaders(options: Pick): void { + if (readEnv('OPENAI_CUSTOM_HEADERS')) { + options.defaultHeaders = buildAzureAuthenticationHeaders(options.defaultHeaders); } } diff --git a/src/internal/headers.ts b/src/internal/headers.ts index a13e78ebb..d4b01c778 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -27,7 +27,7 @@ export type NullableHeaders = { nulls: Set; }; -type AzureAuthenticationValues = ReadonlyArray; +type AzureAuthenticationValues = ReadonlyArray; // Object-identity branding cannot be forged by caller-provided header records. const azureAuthenticationHeaders = new WeakMap(); @@ -36,7 +36,7 @@ const azureAuthenticationHeaders = new WeakMap { +export const buildAzureAuthenticationHeaders = (...headers: AzureAuthenticationValues): NullableHeaders => { const carrier: NullableHeaders = { [brand_privateNullableHeaders]: true, values: new Headers(), @@ -52,7 +52,17 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator(); + for (const [name, value] of iterateHeaders(layer)) { + const normalized = name.toLowerCase(); + if (!seen.has(normalized)) { + seen.add(normalized); + yield [name, null]; + } + yield [name, value]; + } + } } const { values, nulls } = headers; yield* values.entries(); @@ -91,6 +101,15 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator { + for (const [name, value] of iterateHeaders(headers)) { + if (value !== null && isAzureAuthenticationHeader(name)) { + assertAzureCredentialHeaderValue(value); + } + } +}; + export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { const targetHeaders = new Headers(); const nullHeaders = new Set(); diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index d45f332eb..b4145460c 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -2,11 +2,36 @@ import { vi } from 'vitest'; import { AzureOpenAI, OpenAIError } from 'openai'; import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; +import { 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; +class ProtectedHookAzure extends AzureOpenAI { + injectedHeaders: Record | undefined; + bearerCalls = 0; + adminCalls = 0; + + protected override async prepareRequest(request: RequestInit): Promise { + if (this.injectedHeaders) { + request.headers = this.injectedHeaders; + } + } + + protected override async bearerAuth(_options: FinalRequestOptions): Promise { + this.bearerCalls += 1; + return buildHeaders([{ Authorization: 'Bearer custom-bearer-token' }]); + } + + protected override async adminAPIKeyAuth(_options: FinalRequestOptions): Promise { + this.adminCalls += 1; + return buildHeaders([{ Authorization: 'Bearer custom-admin-token' }]); + } +} + const BASE_URL = 'https://azure-resource.example.com/openai'; const API_VERSION = '2024-02-15-preview'; const PRIVATE_CREDENTIAL = 'azure-private-credential-75da'; @@ -596,4 +621,171 @@ describe('Azure credential header diagnostic privacy', () => { 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 expectPrivateCredentialFailure( + () => client.request({ method: 'get', path: '/models' }), + credential, + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + 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('validates every case-variant credential consumed by native post-hook headers', async () => { + const credential = `${PRIVATE_CREDENTIAL}\r${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, + }); + client.injectedHeaders = { AUTHORIZATION: credential, authorization: 'safe-final' }; + await expectPrivateCredentialFailure( + () => client.request({ method: 'get', path: '/models' }), + credential, + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + 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).toBe(injected); + expect(request?.redirect).toBe('manual'); + }); + + 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'); + }, + ); }); diff --git a/tests/lib/azure-deployment-path-safety.test.ts b/tests/lib/azure-deployment-path-safety.test.ts index 6bd77aed5..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,13 +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( - { url, apiKey: new Headers(init?.headers).get('api-key') }, - { headers: { 'content-type': 'application/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 }, + ); + }); const client = new AzureOpenAI({ endpoint, apiKey, apiVersion, fetch: authenticatedFetch }); expect( From eba9440c142fe50e73cb5bffaf7622005e3c193e Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 22:04:54 +0000 Subject: [PATCH 04/35] fix(azure): preserve deferred credential and transport hook contracts --- src/azure.ts | 9 +- src/internal/headers.ts | 4 +- .../azure-credential-header-privacy.test.ts | 204 ++++++++++++++++-- 3 files changed, 200 insertions(+), 17 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index c61deafb0..19494b9a0 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -1,6 +1,6 @@ import type { RequestInit, RequestInfo, Response } from './internal/builtin-types'; import type { NullableHeaders } from './internal/headers'; -import { assertAzureAuthenticationHeaders, buildAzureAuthenticationHeaders } from './internal/headers'; +import { buildAzureAuthenticationHeaders, buildHeaders } from './internal/headers'; import * as Errors from './error'; import type { FinalRequestOptions } from './internal/request-options'; import { isObj, readEnv } from './internal/utils'; @@ -177,15 +177,16 @@ export class AzureOpenAI extends OpenAI { return built; } - protected override fetchWithAuth( + protected override async fetchWithAuth( url: RequestInfo, init: RequestInit, timeout: number, controller: AbortController, schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, ): Promise { - assertAzureAuthenticationHeaders(init.headers); - if (new Headers(init.headers).has('api-key')) { + const headers = buildHeaders([buildAzureAuthenticationHeaders(), init.headers]).values; + init.headers = headers; + if (headers.has('api-key')) { init.redirect = 'manual'; } diff --git a/src/internal/headers.ts b/src/internal/headers.ts index d4b01c778..6639346f9 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -50,12 +50,15 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator name.toLowerCase())); const azureHeaders = azureAuthenticationHeaders.get(headers); if (azureHeaders !== undefined) { for (const layer of azureHeaders) { const seen = new Set(); for (const [name, value] of iterateHeaders(layer)) { const normalized = name.toLowerCase(); + if (visibleNames.has(normalized)) continue; if (!seen.has(normalized)) { seen.add(normalized); yield [name, null]; @@ -64,7 +67,6 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator | undefined; bearerCalls = 0; adminCalls = 0; + fetchFailures = 0; + mutation: 'auth' | 'auth-null' | 'bearer' | 'admin' | undefined; protected override async prepareRequest(request: RequestInit): Promise { if (this.injectedHeaders) { @@ -21,13 +23,68 @@ class ProtectedHookAzure extends AzureOpenAI { } } - protected override async bearerAuth(_options: FinalRequestOptions): Promise { + 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): Promise { + return this.fetchWithAuth( + 'https://azure-resource.example.com/openai/models', + { headers }, + 1000, + new AbortController(), + ); + } + + protected override async authHeaders( + options: FinalRequestOptions, + schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, + ): Promise { + const carrier = 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'); + } + return carrier; + } + + protected override async bearerAuth(options: FinalRequestOptions): Promise { this.bearerCalls += 1; + if (this.mutation === 'bearer') { + const carrier = await super.bearerAuth(options); + if (!carrier) { + throw new Error('Expected a deferred bearer authentication carrier.'); + } + carrier.values.set('AUTHORIZATION', 'Bearer mutated-bearer-token'); + return carrier; + } return buildHeaders([{ Authorization: 'Bearer custom-bearer-token' }]); } - protected override async adminAPIKeyAuth(_options: FinalRequestOptions): Promise { + protected override async adminAPIKeyAuth(options: FinalRequestOptions): Promise { this.adminCalls += 1; + if (this.mutation === 'admin') { + const carrier = await super.adminAPIKeyAuth(options); + if (!carrier) { + throw new Error('Expected a deferred admin authentication carrier.'); + } + carrier.values.set('authorization', 'Bearer mutated-admin-token'); + return carrier; + } return buildHeaders([{ Authorization: 'Bearer custom-admin-token' }]); } } @@ -154,6 +211,34 @@ async function expectPrivateCredentialFailure( 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, @@ -701,7 +786,7 @@ describe('Azure credential header diagnostic privacy', () => { maxRetries: 0, }); client.injectedHeaders = { [header]: credential }; - await expectPrivateCredentialFailure( + await expectPrivateTransportCredentialFailure( () => client.request({ method: 'get', path: '/models' }), credential, ); @@ -734,9 +819,9 @@ describe('Azure credential header diagnostic privacy', () => { }, ); - test('validates every case-variant credential consumed by native post-hook headers', async () => { + test('keeps only the effective case-variant post-hook credential', async () => { const credential = `${PRIVATE_CREDENTIAL}\r${PRIVATE_SUFFIX}`; - const fetch = vi.fn(async () => Response.json({ ok: true })); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); const client = new ProtectedHookAzure({ baseURL: BASE_URL, apiVersion: API_VERSION, @@ -745,11 +830,9 @@ describe('Azure credential header diagnostic privacy', () => { maxRetries: 0, }); client.injectedHeaders = { AUTHORIZATION: credential, authorization: 'safe-final' }; - await expectPrivateCredentialFailure( - () => client.request({ method: 'get', path: '/models' }), - credential, - ); - expect(fetch).not.toHaveBeenCalled(); + 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 () => { @@ -764,7 +847,10 @@ describe('Azure credential header diagnostic privacy', () => { client.injectedHeaders = injected; await client.request({ method: 'get', path: '/models' }); const request = fetch.mock.calls[0]?.[1]; - expect(request?.headers).toBe(injected); + 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'); }); @@ -788,4 +874,98 @@ describe('Azure credential header diagnostic privacy', () => { 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.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); + }, + ); }); From 953b38575926c033c5d413c306f6bfd9799c9f19 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 22:24:53 +0000 Subject: [PATCH 05/35] fix(azure): preserve deferred credential header mutations --- src/internal/headers.ts | 107 +++++++++- .../azure-credential-header-privacy.test.ts | 187 +++++++++++++++++- 2 files changed, 286 insertions(+), 8 deletions(-) diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 6639346f9..87850f667 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -28,9 +28,78 @@ export type NullableHeaders = { }; type AzureAuthenticationValues = ReadonlyArray; +type AzureAuthenticationHeaderMutation = { + kind: 'append' | 'replace' | 'delete'; + values: string[]; +}; // Object-identity branding cannot be forged by caller-provided header records. const azureAuthenticationHeaders = new WeakMap(); +const azureAuthenticationHeaderMutations = new WeakMap< + Headers, + Map +>(); + +class DeferredAzureAuthenticationHeaders extends Headers { + constructor() { + super(); + azureAuthenticationHeaderMutations.set(this, new Map()); + } + + override append = (name: string, value: string): void => { + this.update(name, value, 'append'); + }; + + override set = (name: string, value: string): void => { + this.update(name, value, 'replace'); + }; + + override delete = (name: string): void => { + const normalized = String(name).toLowerCase(); + Headers.prototype.delete.call(this, normalized); + azureAuthenticationHeaderMutations.get(this)?.set(normalized, { kind: 'delete', values: [] }); + }; + + private update(name: string, value: string, operation: 'append' | 'replace'): void { + const normalized = String(name).toLowerCase(); + const authentication = isAzureAuthenticationHeader(normalized); + const normalizedValue = authentication ? String(value) : value; + 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] : [], + }); + } +} /** * Creates an authenticated Azure header carrier without first appending a raw @@ -39,7 +108,7 @@ const azureAuthenticationHeaders = new WeakMap { const carrier: NullableHeaders = { [brand_privateNullableHeaders]: true, - values: new Headers(), + values: new DeferredAzureAuthenticationHeaders(), nulls: new Set(), }; azureAuthenticationHeaders.set(carrier, headers); @@ -51,14 +120,24 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator name.toLowerCase())); + const nullNames = new Set([...nulls].map((name) => name.toLowerCase())); + const visibleNames = new Set([...values.keys(), ...nullNames].map((name) => name.toLowerCase())); + const mutations = azureAuthenticationHeaderMutations.get(values); const azureHeaders = azureAuthenticationHeaders.get(headers); if (azureHeaders !== undefined) { for (const layer of azureHeaders) { const seen = new Set(); for (const [name, value] of iterateHeaders(layer)) { const normalized = name.toLowerCase(); - if (visibleNames.has(normalized)) continue; + const mutation = mutations?.get(normalized); + if ( + nullNames.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]; @@ -67,7 +146,27 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator(); + for (const [name, value] of values.entries()) { + const normalized = name.toLowerCase(); + const mutation = mutations?.get(normalized); + if (mutation && isAzureAuthenticationHeader(normalized)) { + emitted.add(normalized); + 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; + for (const pending of mutation.values) { + yield [name, pending]; + } + } + } for (const name of nulls) { yield [name, null]; } diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index 285acdd6b..8455a6673 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -9,6 +9,7 @@ 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 | undefined; @@ -16,6 +17,8 @@ class ProtectedHookAzure extends AzureOpenAI { adminCalls = 0; fetchFailures = 0; mutation: 'auth' | 'auth-null' | 'bearer' | 'admin' | undefined; + mutationScheme: CarrierAuthenticationScheme = 'auth'; + mutateCarrier: ((headers: Headers) => void) | undefined; protected override async prepareRequest(request: RequestInit): Promise { if (this.injectedHeaders) { @@ -59,17 +62,24 @@ class ProtectedHookAzure extends AzureOpenAI { } else if (this.mutation === 'auth-null') { carrier?.nulls.add('api-key'); } + if (carrier && this.mutationScheme === 'auth') { + this.mutateCarrier?.(carrier.values); + } return carrier; } protected override async bearerAuth(options: FinalRequestOptions): Promise { this.bearerCalls += 1; - if (this.mutation === 'bearer') { + 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.'); } - carrier.values.set('AUTHORIZATION', 'Bearer mutated-bearer-token'); + 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' }]); @@ -77,12 +87,16 @@ class ProtectedHookAzure extends AzureOpenAI { protected override async adminAPIKeyAuth(options: FinalRequestOptions): Promise { this.adminCalls += 1; - if (this.mutation === 'admin') { + 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.'); } - carrier.values.set('authorization', 'Bearer mutated-admin-token'); + 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' }]); @@ -968,4 +982,169 @@ describe('Azure credential header diagnostic privacy', () => { 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(); + }, + ); }); From c006e3bcfe6f16ea0c1a726b1802a05d985e8986 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 23:20:57 +0000 Subject: [PATCH 06/35] fix: preserve deferred Azure authentication header reads --- src/internal/headers.ts | 68 ++++++- .../azure-credential-header-privacy.test.ts | 174 +++++++++++++++++- 2 files changed, 237 insertions(+), 5 deletions(-) diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 87850f667..2fd05ee75 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -28,6 +28,7 @@ export type NullableHeaders = { }; type AzureAuthenticationValues = ReadonlyArray; +type AzureAuthenticationLayer = ReadonlyArray; type AzureAuthenticationHeaderMutation = { kind: 'append' | 'replace' | 'delete'; values: string[]; @@ -35,6 +36,11 @@ type AzureAuthenticationHeaderMutation = { // 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 azureAuthenticationHeaderMutations = new WeakMap< Headers, Map @@ -46,6 +52,51 @@ class DeferredAzureAuthenticationHeaders extends Headers { azureAuthenticationHeaderMutations.set(this, new Map()); } + override get = (name: string): string | null => { + const normalized = String(name).toLowerCase(); + Headers.prototype.has.call(this, normalized); + return this.current().get(normalized) ?? null; + }; + + override has = (name: string): boolean => { + const normalized = String(name).toLowerCase(); + Headers.prototype.has.call(this, normalized); + return this.current().has(normalized); + }; + + override entries = () => this.current().entries(); + + override keys = () => this.current().keys(); + + override values = () => this.current().values(); + + override [Symbol.iterator] = () => this.entries(); + + override forEach = ( + callback: (value: string, key: string, parent: Headers) => void, + thisArg?: unknown, + ): void => { + for (const [name, value] of this.entries()) { + callback.call(thisArg, value, name, this); + } + }; + + 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 previous = effective.get(normalized); + effective.set(normalized, previous === undefined ? value : `${previous}, ${value}`); + } + return new Map([...effective].sort(([left], [right]) => Number(left > right) - Number(left < right))); + } + override append = (name: string, value: string): void => { this.update(name, value, 'append'); }; @@ -112,6 +163,7 @@ export const buildAzureAuthenticationHeaders = (...headers: AzureAuthenticationV nulls: new Set(), }; azureAuthenticationHeaders.set(carrier, headers); + azureAuthenticationHeaderCarriers.set(carrier.values, carrier); return carrier; }; @@ -121,13 +173,20 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator name.toLowerCase())); - const visibleNames = new Set([...values.keys(), ...nullNames].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 azureHeaders = azureAuthenticationHeaders.get(headers); if (azureHeaders !== undefined) { - for (const layer of azureHeaders) { + let layers = azureAuthenticationHeaderSnapshots.get(headers); + if (!layers) { + layers = Object.freeze(azureHeaders.map((layer) => Object.freeze([...iterateHeaders(layer)]))); + azureAuthenticationHeaderSnapshots.set(headers, layers); + } + for (const layer of layers) { const seen = new Set(); - for (const [name, value] of iterateHeaders(layer)) { + for (const [name, value] of layer) { const normalized = name.toLowerCase(); const mutation = mutations?.get(normalized); if ( @@ -147,7 +206,8 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator(); - for (const [name, value] of values.entries()) { + 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 && isAzureAuthenticationHeader(normalized)) { diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index 8455a6673..b5600e0a5 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -2,7 +2,7 @@ import { vi } from 'vitest'; import { APIConnectionError, AzureOpenAI, OpenAIError } from 'openai'; import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; -import { buildHeaders } from 'openai/internal/headers'; +import { buildAzureAuthenticationHeaders, buildHeaders } from 'openai/internal/headers'; import type { NullableHeaders } from 'openai/internal/headers'; import type { FinalRequestOptions } from 'openai/internal/request-options'; @@ -52,6 +52,12 @@ class ProtectedHookAzure extends AzureOpenAI { ); } + 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 }, @@ -1147,4 +1153,170 @@ describe('Azure credential header diagnostic privacy', () => { 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); + }, + ); + + 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); + }); }); From e4947bd73600fd53ef2281d301fdfc7918981267 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 23:37:19 +0000 Subject: [PATCH 07/35] fix(azure): expose deferred authentication tombstones --- src/internal/headers.ts | 124 ++++++++++++++++-- .../azure-credential-header-privacy.test.ts | 98 ++++++++++++++ 2 files changed, 210 insertions(+), 12 deletions(-) diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 2fd05ee75..16fc5d699 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -46,6 +46,22 @@ const azureAuthenticationHeaderMutations = new WeakMap< Map >(); +const azureAuthenticationNullCarriers = new WeakMap, NullableHeaders>(); + +const snapshotAzureAuthenticationHeaders = ( + carrier: NullableHeaders, +): ReadonlyArray | undefined => { + const headers = azureAuthenticationHeaders.get(carrier); + if (headers === undefined) return undefined; + + let layers = azureAuthenticationHeaderSnapshots.get(carrier); + if (!layers) { + layers = Object.freeze(headers.map((layer) => Object.freeze([...iterateHeaders(layer)]))); + azureAuthenticationHeaderSnapshots.set(carrier, layers); + } + return layers; +}; + class DeferredAzureAuthenticationHeaders extends Headers { constructor() { super(); @@ -64,13 +80,15 @@ class DeferredAzureAuthenticationHeaders extends Headers { return this.current().has(normalized); }; - override entries = () => this.current().entries(); + override entries = (): ReturnType => + this.current().entries() as ReturnType; - override keys = () => this.current().keys(); + override keys = (): ReturnType => this.current().keys() as ReturnType; - override values = () => this.current().values(); + override values = (): ReturnType => + this.current().values() as ReturnType; - override [Symbol.iterator] = () => this.entries(); + override [Symbol.iterator] = (): ReturnType => this.entries(); override forEach = ( callback: (value: string, key: string, parent: Headers) => void, @@ -152,6 +170,92 @@ class DeferredAzureAuthenticationHeaders extends Headers { } } +class DeferredAzureAuthenticationNulls extends Set { + private initialized = false; + private readonly inherited = new Set(); + + private initialize(): void { + if (this.initialized) return; + this.initialized = true; + + const carrier = azureAuthenticationNullCarriers.get(this); + if (!carrier) return; + + for (const layer of snapshotAzureAuthenticationHeaders(carrier) ?? []) { + for (const [name, value] of layer) { + const normalized = name.toLowerCase(); + 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(): SetIterator<[string, string]> { + this.initialize(); + return super.entries(); + } + + override keys(): SetIterator { + this.initialize(); + return super.keys(); + } + + override values(): SetIterator { + this.initialize(); + return super.values(); + } + + override [Symbol.iterator](): SetIterator { + 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(); + super.add(value); + return this; + } + + override delete(value: string): boolean { + this.initialize(); + const removed = super.delete(value); + if (removed && this.inherited.delete(value)) { + azureAuthenticationNullCarriers.get(this)?.values.delete(value); + } + 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. @@ -160,10 +264,11 @@ export const buildAzureAuthenticationHeaders = (...headers: AzureAuthenticationV const carrier: NullableHeaders = { [brand_privateNullableHeaders]: true, values: new DeferredAzureAuthenticationHeaders(), - nulls: new Set(), + nulls: new DeferredAzureAuthenticationNulls(), }; azureAuthenticationHeaders.set(carrier, headers); azureAuthenticationHeaderCarriers.set(carrier.values, carrier); + azureAuthenticationNullCarriers.set(carrier.nulls, carrier); return carrier; }; @@ -177,13 +282,8 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator name.toLowerCase())); const mutations = azureAuthenticationHeaderMutations.get(values); - const azureHeaders = azureAuthenticationHeaders.get(headers); - if (azureHeaders !== undefined) { - let layers = azureAuthenticationHeaderSnapshots.get(headers); - if (!layers) { - layers = Object.freeze(azureHeaders.map((layer) => Object.freeze([...iterateHeaders(layer)]))); - azureAuthenticationHeaderSnapshots.set(headers, layers); - } + const layers = snapshotAzureAuthenticationHeaders(headers); + if (layers !== undefined) { for (const layer of layers) { const seen = new Set(); for (const [name, value] of layer) { diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index b5600e0a5..130b65a3f 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -19,6 +19,7 @@ class ProtectedHookAzure extends AzureOpenAI { mutation: 'auth' | 'auth-null' | 'bearer' | 'admin' | undefined; mutationScheme: CarrierAuthenticationScheme = 'auth'; mutateCarrier: ((headers: Headers) => void) | undefined; + inspectAuthenticationCarrier: ((carrier: NullableHeaders) => void) | undefined; protected override async prepareRequest(request: RequestInit): Promise { if (this.injectedHeaders) { @@ -70,6 +71,7 @@ class ProtectedHookAzure extends AzureOpenAI { } if (carrier && this.mutationScheme === 'auth') { this.mutateCarrier?.(carrier.values); + this.inspectAuthenticationCarrier?.(carrier); } return carrier; } @@ -956,6 +958,102 @@ describe('Azure credential header diagnostic privacy', () => { 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) => { From 01a5284885df2b49312ccf79d94b66b2693aa4df Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 23:56:08 +0000 Subject: [PATCH 08/35] fix(azure): bridge cross-runtime header iterator types --- src/internal/headers.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 16fc5d699..62da2f7fb 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -81,12 +81,13 @@ class DeferredAzureAuthenticationHeaders extends Headers { }; override entries = (): ReturnType => - this.current().entries() as ReturnType; + this.current().entries() as unknown as ReturnType; - override keys = (): ReturnType => this.current().keys() as ReturnType; + override keys = (): ReturnType => + this.current().keys() as unknown as ReturnType; override values = (): ReturnType => - this.current().values() as ReturnType; + this.current().values() as unknown as ReturnType; override [Symbol.iterator] = (): ReturnType => this.entries(); From 63f427f025bfb841df0abe0e08d050595eebdca1 Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 20 Aug 2026 00:27:43 +0000 Subject: [PATCH 09/35] fix(types): preserve Set iterator support on TypeScript 4.9 --- src/internal/headers.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 62da2f7fb..5e58ae142 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -206,22 +206,22 @@ class DeferredAzureAuthenticationNulls extends Set { return super.has(value); } - override entries(): SetIterator<[string, string]> { + override entries(): ReturnType['entries']> { this.initialize(); return super.entries(); } - override keys(): SetIterator { + override keys(): ReturnType['keys']> { this.initialize(); return super.keys(); } - override values(): SetIterator { + override values(): ReturnType['values']> { this.initialize(); return super.values(); } - override [Symbol.iterator](): SetIterator { + override [Symbol.iterator](): ReturnType[typeof Symbol.iterator]> { this.initialize(); return super[Symbol.iterator](); } From 8ea03dc2d2556cc8395cf6d617a571c0ee66998a Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 20 Aug 2026 00:47:49 +0000 Subject: [PATCH 10/35] fix(azure): snapshot socket arrays and normalize deferred headers --- src/internal/azure.ts | 14 ++- src/internal/headers.ts | 5 +- .../azure-credential-header-privacy.test.ts | 81 ++++++++++++++++ tests/realtime-websocket.test.ts | 95 +++++++++++++++++++ 4 files changed, 190 insertions(+), 5 deletions(-) diff --git a/src/internal/azure.ts b/src/internal/azure.ts index afcac9705..20c116f42 100644 --- a/src/internal/azure.ts +++ b/src/internal/azure.ts @@ -45,11 +45,17 @@ export function safeAzureWebSocketHeaders Number(left > right) - Number(left < right))); } diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index 130b65a3f..de200c829 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -1343,6 +1343,87 @@ describe('Azure credential header diagnostic privacy', () => { }, ); + 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 })); diff --git a/tests/realtime-websocket.test.ts b/tests/realtime-websocket.test.ts index a536973e0..ad78c808c 100644 --- a/tests/realtime-websocket.test.ts +++ b/tests/realtime-websocket.test.ts @@ -226,6 +226,101 @@ describe('Azure realtime credential diagnostic privacy', () => { 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 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) }, From 1cecd41ce02b02c69f4f44d1c16ccc1aa3b328e2 Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 20 Aug 2026 01:08:34 +0000 Subject: [PATCH 11/35] fix(azure): harden deferred header coercion and carrier compatibility --- src/internal/headers.ts | 150 ++++++++---- .../azure-credential-header-privacy.test.ts | 230 +++++++++++++++++- 2 files changed, 328 insertions(+), 52 deletions(-) diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 8f4db2496..d056edf20 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -68,37 +68,92 @@ class DeferredAzureAuthenticationHeaders extends Headers { azureAuthenticationHeaderMutations.set(this, new Map()); } - override get = (name: string): string | null => { - const normalized = String(name).toLowerCase(); - Headers.prototype.has.call(this, normalized); - return this.current().get(normalized) ?? null; - }; - - override has = (name: string): boolean => { - const normalized = String(name).toLowerCase(); - Headers.prototype.has.call(this, normalized); - return this.current().has(normalized); - }; - - override entries = (): ReturnType => - this.current().entries() as unknown as ReturnType; - - override keys = (): ReturnType => - this.current().keys() as unknown as ReturnType; - - override values = (): ReturnType => - this.current().values() as unknown as ReturnType; - - override [Symbol.iterator] = (): ReturnType => this.entries(); - - override forEach = ( - callback: (value: string, key: string, parent: Headers) => void, - thisArg?: unknown, - ): void => { - for (const [name, value] of this.entries()) { - callback.call(thisArg, value, name, this); - } - }; + 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; + }, + }, + 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.current().entries() as unknown as ReturnType; + }, + }, + keys: { + configurable: true, + writable: true, + value(this: DeferredAzureAuthenticationHeaders): ReturnType { + return this.current().keys() as unknown as ReturnType; + }, + }, + values: { + configurable: true, + writable: true, + value(this: DeferredAzureAuthenticationHeaders): ReturnType { + return this.current().values() as unknown as ReturnType; + }, + }, + [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(); + Headers.prototype.delete.call(this, normalized); + azureAuthenticationHeaderMutations.get(this)?.set(normalized, { kind: 'delete', values: [] }); + }, + }, + }); + } private current(): Map { const carrier = azureAuthenticationHeaderCarriers.get(this); @@ -119,20 +174,6 @@ class DeferredAzureAuthenticationHeaders extends Headers { return new Map([...effective].sort(([left], [right]) => Number(left > right) - Number(left < right))); } - override append = (name: string, value: string): void => { - this.update(name, value, 'append'); - }; - - override set = (name: string, value: string): void => { - this.update(name, value, 'replace'); - }; - - override delete = (name: string): void => { - const normalized = String(name).toLowerCase(); - Headers.prototype.delete.call(this, normalized); - azureAuthenticationHeaderMutations.get(this)?.set(normalized, { kind: 'delete', values: [] }); - }; - private update(name: string, value: string, operation: 'append' | 'replace'): void { const normalized = String(name).toLowerCase(); const authentication = isAzureAuthenticationHeader(normalized); @@ -286,7 +327,9 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator name.toLowerCase())); const mutations = azureAuthenticationHeaderMutations.get(values); - const layers = snapshotAzureAuthenticationHeaders(headers); + const layers = snapshotAzureAuthenticationHeaders( + azureAuthenticationHeaderCarriers.get(values) ?? headers, + ); if (layers !== undefined) { for (const layer of layers) { const seen = new Set(); @@ -382,7 +425,9 @@ export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { (headers) => typeof headers === 'object' && headers !== null && - azureAuthenticationHeaders.has(headers as NullableHeaders), + (azureAuthenticationHeaders.has(headers as NullableHeaders) || + (brand_privateNullableHeaders in headers && + azureAuthenticationHeaderCarriers.has((headers as NullableHeaders).values))), ); const pendingAuthenticationHeaders = new Map(); @@ -422,10 +467,13 @@ export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { } } } - for (const values of pendingAuthenticationHeaders.values()) { - for (const value of values) { - assertAzureCredentialHeaderValue(value); - } + for (const [name, values] of pendingAuthenticationHeaders) { + const snapshots = values.map((value) => { + const snapshot = String(value); + assertAzureCredentialHeaderValue(snapshot); + return snapshot; + }); + pendingAuthenticationHeaders.set(name, snapshots); } for (const [name, values] of pendingAuthenticationHeaders) { for (const value of values) { diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index de200c829..a14f7032d 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -20,6 +20,7 @@ class ProtectedHookAzure extends AzureOpenAI { mutationScheme: CarrierAuthenticationScheme = 'auth'; mutateCarrier: ((headers: Headers) => void) | undefined; inspectAuthenticationCarrier: ((carrier: NullableHeaders) => void) | undefined; + cloneAuthenticationCarrier: 'spread' | 'assign' | undefined; protected override async prepareRequest(request: RequestInit): Promise { if (this.injectedHeaders) { @@ -73,7 +74,14 @@ class ProtectedHookAzure extends AzureOpenAI { this.mutateCarrier?.(carrier.values); this.inspectAuthenticationCarrier?.(carrier); } - return 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 { @@ -1498,4 +1506,224 @@ describe('Azure credential header diagnostic privacy', () => { expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('safe-snapshot-token'); 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', + '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); + }, + ); }); From 7f1728cf94fd12bc902f0e83e2091243adeb6177 Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 20 Aug 2026 01:28:44 +0000 Subject: [PATCH 12/35] fix(azure): preserve trusted post-hook Headers identity --- src/azure.ts | 48 +++++++- .../azure-credential-header-privacy.test.ts | 103 +++++++++++++++++- 2 files changed, 147 insertions(+), 4 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index 19494b9a0..6442cd02f 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -184,8 +184,11 @@ export class AzureOpenAI extends OpenAI { controller: AbortController, schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, ): Promise { - const headers = buildHeaders([buildAzureAuthenticationHeaders(), init.headers]).values; - init.headers = headers; + const suppliedHeaders = init.headers; + const headers = buildHeaders([buildAzureAuthenticationHeaders(), suppliedHeaders]).values; + if (!hasIntrinsicHeadersIdentity(suppliedHeaders)) { + init.headers = headers; + } if (headers.has('api-key')) { init.redirect = 'manual'; } @@ -223,6 +226,47 @@ export class AzureOpenAI extends OpenAI { } } +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) || Object.getPrototypeOf(headers) !== intrinsicHeadersPrototype) { + return false; + } + + try { + intrinsicHeadersHas.call(headers, 'api-key'); + } catch { + return false; + } + + return intrinsicHeadersOperations.every( + (operation) => + Object.getOwnPropertyDescriptor(headers, operation) === undefined && + Object.getOwnPropertyDescriptor(intrinsicHeadersPrototype, operation)?.value === + intrinsicHeadersDescriptors.get(operation), + ); +} + function protectAzureAmbientHeaders(options: Pick): void { if (readEnv('OPENAI_CUSTOM_HEADERS')) { options.defaultHeaders = buildAzureAuthenticationHeaders(options.defaultHeaders); diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index a14f7032d..199f95ad3 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -12,7 +12,7 @@ type Fetch = (url: RequestInfo, init?: RequestInit) => Promise; type CarrierAuthenticationScheme = 'auth' | 'bearer' | 'admin'; class ProtectedHookAzure extends AzureOpenAI { - injectedHeaders: Record | undefined; + injectedHeaders: Record | Headers | undefined; bearerCalls = 0; adminCalls = 0; fetchFailures = 0; @@ -45,7 +45,7 @@ class ProtectedHookAzure extends AzureOpenAI { throw error; } - invokeProtectedFetch(headers: Record): Promise { + invokeProtectedFetch(headers: Record | Headers): Promise { return this.fetchWithAuth( 'https://azure-resource.example.com/openai/models', { headers }, @@ -884,6 +884,105 @@ describe('Azure credential header diagnostic privacy', () => { 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(['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) => { From 2a857968348121df8db88834b241ecbe6941629f Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 20 Aug 2026 01:43:27 +0000 Subject: [PATCH 13/35] fix(azure): protect preprocessing and deferred header boundaries --- src/azure.ts | 19 ++- src/internal/azure.ts | 7 +- src/internal/headers.ts | 26 +++ .../azure-credential-header-privacy.test.ts | 156 ++++++++++++++++++ tests/realtime-websocket.test.ts | 100 +++++++++++ 5 files changed, 303 insertions(+), 5 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index 6442cd02f..7732d16c6 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -170,11 +170,22 @@ export class AzureOpenAI extends OpenAI { options.path = path`/deployments/${model}` + options.path; } } - const built = await super.buildRequest(options, props); - if (built.req.headers.has('api-key')) { - built.req.redirect = 'manual'; + const rawHeaders = options.headers; + if (rawHeaders !== undefined && rawHeaders !== null) { + options.headers = buildAzureAuthenticationHeaders(rawHeaders); + } + + try { + const built = await super.buildRequest(options, props); + if (built.req.headers.has('api-key')) { + built.req.redirect = 'manual'; + } + return built; + } finally { + if (rawHeaders !== undefined && rawHeaders !== null) { + options.headers = rawHeaders; + } } - return built; } protected override async fetchWithAuth( diff --git a/src/internal/azure.ts b/src/internal/azure.ts index 20c116f42..d6baca4cb 100644 --- a/src/internal/azure.ts +++ b/src/internal/azure.ts @@ -46,8 +46,13 @@ export function safeAzureWebSocketHeaders 1024) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } const snapshot: unknown[] = []; - for (const entry of value) { + for (let index = 0; index < length; index += 1) { + const entry = value[index]; if (typeof entry === 'string') { assertAzureCredentialHeaderValue(entry); } diff --git a/src/internal/headers.ts b/src/internal/headers.ts index d056edf20..9e59905b8 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -79,6 +79,32 @@ class DeferredAzureAuthenticationHeaders extends Headers { return this.current().get(normalized) ?? null; }, }, + getSetCookie: { + configurable: true, + writable: true, + value(this: DeferredAzureAuthenticationHeaders): 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; + }, + }, has: { configurable: true, writable: true, diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index 199f95ad3..7fab766c1 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -72,6 +72,8 @@ class ProtectedHookAzure extends AzureOpenAI { } if (carrier && this.mutationScheme === 'auth') { this.mutateCarrier?.(carrier.values); + } + if (carrier) { this.inspectAuthenticationCarrier?.(carrier); } if (!carrier || !this.cloneAuthenticationCarrier) { @@ -537,6 +539,91 @@ describe('Azure credential header diagnostic privacy', () => { }, ); + 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(['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( authenticationModes.flatMap((authentication) => (['valid', 'null'] as const).map((override) => ({ authentication, override })), @@ -1606,6 +1693,74 @@ describe('Azure credential header diagnostic privacy', () => { 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) => { + expect(carrier.values.getSetCookie()).toEqual(expected); + 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); + 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) => { @@ -1632,6 +1787,7 @@ describe('Azure credential header diagnostic privacy', () => { expect(Object.assign(copied, headers)).toEqual(Object.assign(native, new Headers())); for (const method of [ 'get', + 'getSetCookie', 'has', 'entries', 'keys', diff --git a/tests/realtime-websocket.test.ts b/tests/realtime-websocket.test.ts index ad78c808c..b25450a9e 100644 --- a/tests/realtime-websocket.test.ts +++ b/tests/realtime-websocket.test.ts @@ -292,6 +292,106 @@ describe('Azure realtime credential diagnostic privacy', () => { }, ); + 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) }, From 62f306e2148281d117f34fa84d006d6c11d50341 Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 20 Aug 2026 01:59:29 +0000 Subject: [PATCH 14/35] fix(azure): snapshot websocket credential serialization --- src/beta/realtime/websocket.ts | 5 +- src/internal/azure.ts | 15 ++-- src/realtime/websocket.ts | 5 +- tests/realtime-websocket.test.ts | 150 +++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 9 deletions(-) diff --git a/src/beta/realtime/websocket.ts b/src/beta/realtime/websocket.ts index 7bd5b47ae..2e01ef91f 100644 --- a/src/beta/realtime/websocket.ts +++ b/src/beta/realtime/websocket.ts @@ -120,12 +120,13 @@ function createAzureWebSocket( throw new Error('Azure OpenAI Realtime requires an API key'); } - assertAzureCredentialHeaderValue(apiKey); + const credential = String(apiKey); + assertAzureCredentialHeaderValue(credential); 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 }); diff --git a/src/internal/azure.ts b/src/internal/azure.ts index d6baca4cb..b513c10ba 100644 --- a/src/internal/azure.ts +++ b/src/internal/azure.ts @@ -53,14 +53,19 @@ export function safeAzureWebSocketHeaders { + coercions += 1; + return coercions === 1 ? first : second; + }; + }, + }); + return { + value, + counts: () => ({ coercions, hookReads, iteratorReads }), + }; +} + beforeEach(() => { FakeBrowserSocket.instances = []; nodeSocketConstructor.mockClear(); @@ -213,6 +241,128 @@ describe('Azure realtime credential diagnostic privacy', () => { }, ); + 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) => + ([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) }, From c3002079da1df290aaab9ad7aad24bc55f74795e Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 20 Aug 2026 02:19:29 +0000 Subject: [PATCH 15/35] fix(azure): isolate request options and foreign headers --- src/azure.ts | 69 ++++-- .../azure-credential-header-privacy.test.ts | 226 ++++++++++++++++++ 2 files changed, 280 insertions(+), 15 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index 7732d16c6..e4bb8e713 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -171,21 +171,15 @@ export class AzureOpenAI extends OpenAI { } } const rawHeaders = options.headers; - if (rawHeaders !== undefined && rawHeaders !== null) { - options.headers = buildAzureAuthenticationHeaders(rawHeaders); - } - - try { - const built = await super.buildRequest(options, props); - if (built.req.headers.has('api-key')) { - built.req.redirect = 'manual'; - } - return built; - } finally { - if (rawHeaders !== undefined && rawHeaders !== null) { - options.headers = rawHeaders; - } + const requestOptions = + rawHeaders === undefined || rawHeaders === null + ? options + : { ...options, headers: buildAzureAuthenticationHeaders(rawHeaders) }; + const built = await super.buildRequest(requestOptions, props); + if (built.req.headers.has('api-key')) { + built.req.redirect = 'manual'; } + return built; } protected override async fetchWithAuth( @@ -196,7 +190,8 @@ export class AzureOpenAI extends OpenAI { schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, ): Promise { const suppliedHeaders = init.headers; - const headers = buildHeaders([buildAzureAuthenticationHeaders(), suppliedHeaders]).values; + const safeHeaders = snapshotCrossRealmHeaders(suppliedHeaders); + const headers = buildHeaders([buildAzureAuthenticationHeaders(), safeHeaders]).values; if (!hasIntrinsicHeadersIdentity(suppliedHeaders)) { init.headers = headers; } @@ -278,6 +273,50 @@ function hasIntrinsicHeadersIdentity(headers: RequestInit['headers']): headers i ); } +function snapshotCrossRealmHeaders(headers: RequestInit['headers']): RequestInit['headers'] { + if (headers === undefined || headers === null || typeof headers !== 'object') { + return headers; + } + if (headers instanceof Headers || Array.isArray(headers)) { + return headers; + } + + const prototype = Object.getPrototypeOf(headers) as object | null; + if ( + prototype === null || + Object.getOwnPropertyDescriptor(prototype, Symbol.toStringTag)?.value !== 'Headers' + ) { + return headers; + } + + const operations = [Symbol.iterator, 'entries', 'get', 'has'] as const; + const valid = operations.every((operation) => { + const descriptor = Object.getOwnPropertyDescriptor(prototype, operation); + return ( + typeof descriptor?.value === 'function' && + Object.getOwnPropertyDescriptor(headers, operation) === undefined + ); + }); + if (!valid) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + + const iterator = Object.getOwnPropertyDescriptor(prototype, Symbol.iterator) as PropertyDescriptor; + const snapshots: [string, string][] = []; + for (const row of iterator.value.call(headers) as Iterable) { + if (snapshots.length >= 1024 || !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; +} + function protectAzureAmbientHeaders(options: Pick): void { if (readEnv('OPENAI_CUSTOM_HEADERS')) { options.defaultHeaders = buildAzureAuthenticationHeaders(options.defaultHeaders); diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index 7fab766c1..b1766f428 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -1,3 +1,5 @@ +import { createRequire } from 'node:module'; +import { runInNewContext } from 'node:vm'; import { vi } from 'vitest'; import { APIConnectionError, AzureOpenAI, OpenAIError } from 'openai'; @@ -21,6 +23,7 @@ class ProtectedHookAzure extends AzureOpenAI { mutateCarrier: ((headers: Headers) => void) | undefined; inspectAuthenticationCarrier: ((carrier: NullableHeaders) => void) | undefined; cloneAuthenticationCarrier: 'spread' | 'assign' | undefined; + observeAuthenticationOptions: ((options: FinalRequestOptions) => Promise) | undefined; protected override async prepareRequest(request: RequestInit): Promise { if (this.injectedHeaders) { @@ -64,6 +67,9 @@ class ProtectedHookAzure extends AzureOpenAI { options: FinalRequestOptions, schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, ): Promise { + if (this.observeAuthenticationOptions) { + await this.observeAuthenticationOptions(options); + } const carrier = await super.authHeaders(options, schemes); if (this.mutation === 'auth') { carrier?.values.set('API-KEY', 'mutated-static-token'); @@ -121,6 +127,11 @@ class ProtectedHookAzure extends AzureOpenAI { } } +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'; @@ -624,6 +635,65 @@ describe('Azure credential header diagnostic privacy', () => { }, ); + 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, + }); + const rawHeaders = { 'api-key': 'first-token', 'x-custom': 'preserved' }; + 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(observed[0]).not.toBe(options); + expect(observed[1]).not.toBe(options); + expect(observed[0]).not.toBe(observed[1]); + 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(options.headers).toBe(rawHeaders); + }); + test.each( authenticationModes.flatMap((authentication) => (['valid', 'null'] as const).map((override) => ({ authentication, override })), @@ -1020,6 +1090,162 @@ describe('Azure credential header diagnostic privacy', () => { }, ); + 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([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(['subclass override', 'own override'] as const)( 'materializes a mutable post-hook Headers %s exactly once before dispatch', async (override) => { From 59af3c0ce2f0396ecadccb9d699bb293c1aade52 Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 20 Aug 2026 02:35:58 +0000 Subject: [PATCH 16/35] fix(azure): preserve authenticated request options identity --- src/azure.ts | 34 ++- src/internal/headers.ts | 92 +++++++- .../azure-credential-header-privacy.test.ts | 198 +++++++++++++++++- 3 files changed, 301 insertions(+), 23 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index e4bb8e713..bbef35316 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -1,6 +1,10 @@ import type { RequestInit, RequestInfo, Response } from './internal/builtin-types'; import type { NullableHeaders } from './internal/headers'; -import { buildAzureAuthenticationHeaders, buildHeaders } from './internal/headers'; +import { + buildAzureAuthenticationHeaders, + buildHeaders, + protectAzureRequestHeaders, +} from './internal/headers'; import * as Errors from './error'; import type { FinalRequestOptions } from './internal/request-options'; import { isObj, readEnv } from './internal/utils'; @@ -170,16 +174,26 @@ export class AzureOpenAI extends OpenAI { options.path = path`/deployments/${model}` + options.path; } } - const rawHeaders = options.headers; - const requestOptions = - rawHeaders === undefined || rawHeaders === null - ? options - : { ...options, headers: buildAzureAuthenticationHeaders(rawHeaders) }; - const built = await super.buildRequest(requestOptions, props); - if (built.req.headers.has('api-key')) { - built.req.redirect = 'manual'; + const { body, headers } = options; + const preprocessesHeaders = body === undefined ? 'body' in options : Boolean(body); + const protection = preprocessesHeaders ? protectAzureRequestHeaders(headers) : undefined; + + try { + let pending: ReturnType; + try { + pending = super.buildRequest(options, props); + } finally { + protection?.deactivate(); + } + + const built = await pending; + if (built.req.headers.has('api-key')) { + built.req.redirect = 'manual'; + } + return built; + } finally { + protection?.release(); } - return built; } protected override async fetchWithAuth( diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 9e59905b8..c60a7cc18 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -34,6 +34,19 @@ type AzureAuthenticationHeaderMutation = { values: string[]; }; +type AzureRequestHeaderMarker = { + active: boolean; +}; +type AzureRequestHeaderRegistration = { + carrier: NullableHeaders; + references: number; + markers: AzureRequestHeaderMarker[]; +}; +type AzureRequestHeaderProtection = { + deactivate: () => void; + release: () => void; +}; + // Object-identity branding cannot be forged by caller-provided header records. const azureAuthenticationHeaders = new WeakMap(); const azureAuthenticationHeaderCarriers = new WeakMap(); @@ -47,6 +60,7 @@ const azureAuthenticationHeaderMutations = new WeakMap< >(); const azureAuthenticationNullCarriers = new WeakMap, NullableHeaders>(); +const azureRequestHeaders = new WeakMap(); const snapshotAzureAuthenticationHeaders = ( carrier: NullableHeaders, @@ -343,6 +357,59 @@ export const buildAzureAuthenticationHeaders = (...headers: AzureAuthenticationV return carrier; }; +/** Privately protects one synchronous Azure body pass and its authenticated final merge. */ +export const protectAzureRequestHeaders = ( + headers: HeadersLike, +): AzureRequestHeaderProtection | undefined => { + if (headers === undefined || headers === null || typeof headers !== 'object') { + return undefined; + } + + let registration = azureRequestHeaders.get(headers); + if (!registration) { + registration = { + carrier: buildAzureAuthenticationHeaders(headers), + references: 0, + markers: [], + }; + azureRequestHeaders.set(headers, registration); + } + const activeRegistration = registration; + activeRegistration.references += 1; + const marker: AzureRequestHeaderMarker = { active: true }; + activeRegistration.markers.push(marker); + let released = false; + + const deactivate = (): void => { + if (!marker.active) return; + marker.active = false; + const position = activeRegistration.markers.indexOf(marker); + if (position !== -1) { + activeRegistration.markers.splice(position, 1); + } + }; + const release = (): void => { + if (released) return; + released = true; + deactivate(); + activeRegistration.references -= 1; + if (activeRegistration.references === 0) { + azureRequestHeaders.delete(headers); + } + }; + + return { deactivate, release }; +}; + +const consumeAzureBodyMarker = (headers: HeadersLike): NullableHeaders | undefined => { + if (headers === undefined || headers === null || typeof headers !== 'object') return undefined; + const registration = azureRequestHeaders.get(headers); + const marker = registration?.markers.pop(); + if (!registration || !marker) return undefined; + marker.active = false; + return registration.carrier; +}; + function* iterateHeaders(headers: HeadersLike): IterableIterator { if (!headers) return; @@ -445,20 +512,27 @@ export const assertAzureAuthenticationHeaders = (headers: HeadersLike): void => }; export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { + const bodyCarrier = newHeaders.length === 1 ? consumeAzureBodyMarker(newHeaders[0]) : undefined; const targetHeaders = new Headers(); const nullHeaders = new Set(); - const protectsAzureCredentials = newHeaders.some( - (headers) => - typeof headers === 'object' && - headers !== null && - (azureAuthenticationHeaders.has(headers as NullableHeaders) || - (brand_privateNullableHeaders in headers && - azureAuthenticationHeaderCarriers.has((headers as NullableHeaders).values))), - ); + const protectsAzureCredentials = + bodyCarrier !== undefined || + newHeaders.some( + (headers) => + typeof headers === 'object' && + headers !== null && + (azureAuthenticationHeaders.has(headers as NullableHeaders) || + (brand_privateNullableHeaders in headers && + azureAuthenticationHeaderCarriers.has((headers as NullableHeaders).values))), + ); const pendingAuthenticationHeaders = new Map(); - for (const headers of newHeaders) { + for (const source of newHeaders) { const seenHeaders = new Set(); + const headers = + protectsAzureCredentials && typeof source === 'object' && source !== null + ? (azureRequestHeaders.get(source)?.carrier ?? source) + : source; for (const [name, value] of iterateHeaders(headers)) { if (!httpTokenHeaderName.test(name)) { throw new TypeError(`Header name must be a valid HTTP token ["${name}"]`); diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index b1766f428..dc7033411 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -24,8 +24,23 @@ class ProtectedHookAzure extends AzureOpenAI { inspectAuthenticationCarrier: ((carrier: NullableHeaders) => void) | undefined; cloneAuthenticationCarrier: 'spread' | 'assign' | 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): Promise { + 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; } @@ -67,6 +82,7 @@ class ProtectedHookAzure extends AzureOpenAI { options: FinalRequestOptions, schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, ): Promise { + this.observeProtectedHookOptions?.('auth', options); if (this.observeAuthenticationOptions) { await this.observeAuthenticationOptions(options); } @@ -93,6 +109,7 @@ class ProtectedHookAzure extends AzureOpenAI { } 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); @@ -110,6 +127,7 @@ class ProtectedHookAzure extends AzureOpenAI { } 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); @@ -643,7 +661,19 @@ describe('Azure credential header diagnostic privacy', () => { 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(); @@ -679,9 +709,9 @@ describe('Azure credential header diagnostic privacy', () => { expect(whileSecondWaits).toBe(rawHeaders); expect(options.headers).toBe(rawHeaders); expect(observed).toHaveLength(2); - expect(observed[0]).not.toBe(options); - expect(observed[1]).not.toBe(options); - expect(observed[0]).not.toBe(observed[1]); + expect(reads).toBe(1); + 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'); @@ -691,9 +721,169 @@ describe('Azure credential header diagnostic privacy', () => { rawHeaders['api-key'] = 'updated-token'; const reused = await client.buildRequest(options); expect(reused.req.headers.get('api-key')).toBe('updated-token'); + expect(reads).toBe(2); 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('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 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('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 })), From c04e3d063c8602516a9eaf7a604bf97c20341dac Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 25 Aug 2026 18:24:30 +0000 Subject: [PATCH 17/35] fix(azure): isolate credential snapshots across concurrent requests --- src/azure.ts | 177 +++++- src/internal/headers.ts | 100 ++- src/internal/utils/log.ts | 10 +- .../azure-credential-header-privacy.test.ts | 578 +++++++++++++++++- tests/log.test.ts | 43 ++ 5 files changed, 861 insertions(+), 47 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index bbef35316..e926e030e 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -174,15 +174,21 @@ export class AzureOpenAI extends OpenAI { options.path = path`/deployments/${model}` + options.path; } } - const { body, headers } = options; + const { body } = options; + const { headers, restore } = snapshotAzureRequestOptionsHeaders(options); const preprocessesHeaders = body === undefined ? 'body' in options : Boolean(body); - const protection = preprocessesHeaders ? protectAzureRequestHeaders(headers) : undefined; + let protection: ReturnType; try { + protection = preprocessesHeaders ? protectAzureRequestHeaders(headers) : undefined; + const restoreAuthentication = protection + ? snapshotAzureRequestAuthentication(this, this.authHeaders, protection) + : undefined; let pending: ReturnType; try { pending = super.buildRequest(options, props); } finally { + restoreAuthentication?.(); protection?.deactivate(); } @@ -193,6 +199,7 @@ export class AzureOpenAI extends OpenAI { return built; } finally { protection?.release(); + restore?.(); } } @@ -246,6 +253,141 @@ export class AzureOpenAI extends OpenAI { } } +type AzureAuthenticationHook = ( + options: FinalRequestOptions, + schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, +) => Promise; + +const azureRequestAuthenticationOriginals = new WeakMap(); + +function snapshotAzureRequestAuthentication( + client: AzureOpenAI, + authenticate: AzureAuthenticationHook, + protection: NonNullable>, +): () => void { + const descriptor = Object.getOwnPropertyDescriptor(client, 'authHeaders'); + const replaceable = + descriptor === undefined + ? Object.isExtensible(client) + : descriptor.configurable || ('value' in descriptor && descriptor.writable); + if (!replaceable) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + + const original = azureRequestAuthenticationOriginals.get(authenticate) ?? authenticate; + const snapshot = async ( + options: FinalRequestOptions, + schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, + ): Promise => { + restore(); + const carrier = await original.call(client, options, schemes); + return carrier === undefined ? undefined : protection.bind(carrier); + }; + azureRequestAuthenticationOriginals.set(snapshot, original); + const restore = (): void => { + if (Object.getOwnPropertyDescriptor(client, 'authHeaders')?.value !== snapshot) { + return; + } + if (descriptor !== undefined) { + Object.defineProperty(client, 'authHeaders', descriptor); + return; + } + if (!Reflect.deleteProperty(client, 'authHeaders')) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + }; + try { + const temporary = + descriptor !== undefined && 'value' in descriptor + ? { ...descriptor, value: snapshot } + : { + configurable: descriptor?.configurable ?? true, + enumerable: descriptor?.enumerable ?? false, + value: snapshot, + writable: true, + }; + Object.defineProperty(client, 'authHeaders', temporary); + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + + return restore; +} + +const azureRequestHeadersAccessorSnapshots = new WeakMap< + FinalRequestOptions, + { + descriptor: PropertyDescriptor; + getter: () => FinalRequestOptions['headers']; + references: number; + } +>(); + +function snapshotAzureRequestOptionsHeaders(options: FinalRequestOptions): { + headers: FinalRequestOptions['headers']; + restore?: () => void; +} { + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + if (descriptor === undefined || 'value' in descriptor) { + return { headers: options.headers }; + } + if (!descriptor.configurable) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + + const { headers } = options; + return { headers, restore: snapshotAzureRequestHeadersAccessor(options, headers) }; +} + +function snapshotAzureRequestHeadersAccessor( + options: FinalRequestOptions, + headers: FinalRequestOptions['headers'], +): () => void { + let snapshot = azureRequestHeadersAccessorSnapshots.get(options); + if (snapshot === undefined) { + const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + if (descriptor === undefined || 'value' in descriptor || !descriptor.configurable) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + let current = headers; + const getter = () => current; + const originalSetter = descriptor.set; + const setter = + originalSetter === undefined + ? undefined + : function setHeaders(this: FinalRequestOptions, value: FinalRequestOptions['headers']): void { + originalSetter.call(this, value); + current = value; + }; + Object.defineProperty(options, 'headers', { + ...descriptor, + get: getter, + ...(setter === undefined ? {} : { set: setter }), + }); + snapshot = { descriptor, getter, references: 0 }; + azureRequestHeadersAccessorSnapshots.set(options, snapshot); + } + + const active = snapshot; + active.references += 1; + let restored = false; + return () => { + if (restored) { + return; + } + restored = true; + active.references -= 1; + if (active.references !== 0) { + return; + } + + azureRequestHeadersAccessorSnapshots.delete(options); + if (Object.getOwnPropertyDescriptor(options, 'headers')?.get === active.getter) { + Object.defineProperty(options, 'headers', active.descriptor); + } + }; +} + const intrinsicHeadersPrototype = Headers.prototype; const intrinsicHeadersHas = intrinsicHeadersPrototype.has; const intrinsicHeadersOperations = [ @@ -269,22 +411,39 @@ const intrinsicHeadersDescriptors = new Map( ); function hasIntrinsicHeadersIdentity(headers: RequestInit['headers']): headers is Headers { - if (!(headers instanceof Headers) || Object.getPrototypeOf(headers) !== intrinsicHeadersPrototype) { + 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 intrinsicHeadersOperations.every( - (operation) => - Object.getOwnPropertyDescriptor(headers, operation) === undefined && - Object.getOwnPropertyDescriptor(intrinsicHeadersPrototype, operation)?.value === - intrinsicHeadersDescriptors.get(operation), - ); + return false; } function snapshotCrossRealmHeaders(headers: RequestInit['headers']): RequestInit['headers'] { diff --git a/src/internal/headers.ts b/src/internal/headers.ts index c60a7cc18..9449a31a4 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -36,13 +36,18 @@ type AzureAuthenticationHeaderMutation = { type AzureRequestHeaderMarker = { active: boolean; + registration: AzureRequestHeaderRegistration; }; type AzureRequestHeaderRegistration = { carrier: NullableHeaders; + headers: object; +}; +type AzureRequestHeaderRegistrations = { references: number; markers: AzureRequestHeaderMarker[]; }; type AzureRequestHeaderProtection = { + bind: (carrier: NullableHeaders) => NullableHeaders; deactivate: () => void; release: () => void; }; @@ -60,7 +65,8 @@ const azureAuthenticationHeaderMutations = new WeakMap< >(); const azureAuthenticationNullCarriers = new WeakMap, NullableHeaders>(); -const azureRequestHeaders = new WeakMap(); +const azureRequestHeaders = new WeakMap(); +const azureRequestAuthenticationHeaders = new WeakMap(); const snapshotAzureAuthenticationHeaders = ( carrier: NullableHeaders, @@ -365,49 +371,63 @@ export const protectAzureRequestHeaders = ( return undefined; } - let registration = azureRequestHeaders.get(headers); - if (!registration) { - registration = { - carrier: buildAzureAuthenticationHeaders(headers), + let registrations = azureRequestHeaders.get(headers); + if (!registrations) { + registrations = { references: 0, markers: [], }; - azureRequestHeaders.set(headers, registration); + azureRequestHeaders.set(headers, registrations); } - const activeRegistration = registration; - activeRegistration.references += 1; - const marker: AzureRequestHeaderMarker = { active: true }; - activeRegistration.markers.push(marker); + const activeRegistrations = registrations; + const activeRegistration = { + carrier: buildAzureAuthenticationHeaders(headers), + headers, + }; + activeRegistrations.references += 1; + const marker: AzureRequestHeaderMarker = { active: true, registration: activeRegistration }; + activeRegistrations.markers.push(marker); let released = false; const deactivate = (): void => { if (!marker.active) return; marker.active = false; - const position = activeRegistration.markers.indexOf(marker); + const position = activeRegistrations.markers.indexOf(marker); if (position !== -1) { - activeRegistration.markers.splice(position, 1); + activeRegistrations.markers.splice(position, 1); } }; const release = (): void => { if (released) return; released = true; deactivate(); - activeRegistration.references -= 1; - if (activeRegistration.references === 0) { + activeRegistrations.references -= 1; + if (activeRegistrations.references === 0) { azureRequestHeaders.delete(headers); } }; - return { deactivate, release }; + 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 }; }; -const consumeAzureBodyMarker = (headers: HeadersLike): NullableHeaders | undefined => { +const consumeAzureBodyMarker = (headers: HeadersLike): AzureRequestHeaderRegistration | undefined => { if (headers === undefined || headers === null || typeof headers !== 'object') return undefined; - const registration = azureRequestHeaders.get(headers); - const marker = registration?.markers.pop(); - if (!registration || !marker) return undefined; + const marker = azureRequestHeaders.get(headers)?.markers.pop(); + if (!marker) return undefined; marker.active = false; - return registration.carrier; + return marker.registration; }; function* iterateHeaders(headers: HeadersLike): IterableIterator { @@ -512,26 +532,40 @@ export const assertAzureAuthenticationHeaders = (headers: HeadersLike): void => }; export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { - const bodyCarrier = newHeaders.length === 1 ? consumeAzureBodyMarker(newHeaders[0]) : undefined; + const bodyRegistration = newHeaders.length === 1 ? consumeAzureBodyMarker(newHeaders[0]) : undefined; + 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; + } + } + } const targetHeaders = new Headers(); const nullHeaders = new Set(); - const protectsAzureCredentials = - bodyCarrier !== undefined || - newHeaders.some( - (headers) => - typeof headers === 'object' && - headers !== null && - (azureAuthenticationHeaders.has(headers as NullableHeaders) || - (brand_privateNullableHeaders in headers && - azureAuthenticationHeaderCarriers.has((headers as NullableHeaders).values))), - ); const pendingAuthenticationHeaders = new Map(); for (const source of newHeaders) { const seenHeaders = new Set(); const headers = - protectsAzureCredentials && typeof source === 'object' && source !== null - ? (azureRequestHeaders.get(source)?.carrier ?? source) + protectsAzureCredentials && requestRegistration !== undefined && source === requestRegistration.headers + ? requestRegistration.carrier : source; for (const [name, value] of iterateHeaders(headers)) { if (!httpTokenHeaderName.test(name)) { diff --git a/src/internal/utils/log.ts b/src/internal/utils/log.ts index 7136fafdd..434e62b8a 100644 --- a/src/internal/utils/log.ts +++ b/src/internal/utils/log.ts @@ -95,8 +95,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.headers) { details.headers = Object.fromEntries( diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index dc7033411..af0285d24 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -23,6 +23,7 @@ class ProtectedHookAzure extends AzureOpenAI { 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: @@ -86,7 +87,7 @@ class ProtectedHookAzure extends AzureOpenAI { if (this.observeAuthenticationOptions) { await this.observeAuthenticationOptions(options); } - const carrier = await super.authHeaders(options, schemes); + 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') { @@ -653,6 +654,158 @@ describe('Azure credential header diagnostic privacy', () => { }, ); + 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(['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('rejects a nonconfigurable request headers accessor before reading an unsafe credential', 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(0); + 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(1); + 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('keeps shared request options unchanged across overlapping private authentication waits', async () => { const fetch = vi.fn(async () => Response.json({ ok: true })); const client = new ProtectedHookAzure({ @@ -709,7 +862,7 @@ describe('Azure credential header diagnostic privacy', () => { expect(whileSecondWaits).toBe(rawHeaders); expect(options.headers).toBe(rawHeaders); expect(observed).toHaveLength(2); - expect(reads).toBe(1); + expect(reads).toBe(2); expect(observed[0]).toBe(options); expect(observed[1]).toBe(options); expect(observed.every((received) => received.__metadata === metadata)).toBe(true); @@ -721,7 +874,7 @@ describe('Azure credential header diagnostic privacy', () => { rawHeaders['api-key'] = 'updated-token'; const reused = await client.buildRequest(options); expect(reused.req.headers.get('api-key')).toBe('updated-token'); - expect(reads).toBe(2); + expect(reads).toBe(3); expect(options.headers).toBe(rawHeaders); }); @@ -818,6 +971,50 @@ describe('Azure credential header diagnostic privacy', () => { 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({ @@ -861,6 +1058,272 @@ describe('Azure credential header diagnostic privacy', () => { 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 }; @@ -1280,6 +1743,115 @@ describe('Azure credential header diagnostic privacy', () => { }, ); + 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'] 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; + 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, diff --git a/tests/log.test.ts b/tests/log.test.ts index 3aa74b87b..223dbd36f 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,47 @@ 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' }, + }); + Object.defineProperty(options, '__proto__', { enumerable: true, value: 'safe data property' }); + + const details = formatRequestDetails({ options }); + const loggedOptions = details.options ?? {}; + + expect(visibleReads).toBe(1); + expect(loggedOptions).toEqual({ + visible: 'preserved', + [metadata]: 'symbol metadata', + ['__proto__']: 'safe data property', + }); + 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(); + }); +}); + describe('debug()', () => { const env = process.env; const spy = vi.spyOn(console, 'debug'); From fd1ebc1c118d7445e7606c71b2a998d21cae12eb Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 25 Aug 2026 18:28:46 +0000 Subject: [PATCH 18/35] test(log): construct prototype-key fixtures safely --- tests/log.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/log.test.ts b/tests/log.test.ts index 223dbd36f..7a75fb8a7 100644 --- a/tests/log.test.ts +++ b/tests/log.test.ts @@ -42,18 +42,19 @@ describe('formatRequestDetails()', () => { }, hidden: { enumerable: false, value: 'omitted' }, [metadata]: { enumerable: true, value: 'symbol metadata' }, + [String('__proto__')]: { enumerable: true, value: 'safe data property' }, }); - Object.defineProperty(options, '__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({ - visible: 'preserved', - [metadata]: 'symbol metadata', - ['__proto__']: 'safe data property', - }); + expect(loggedOptions).toEqual(expectedOptions); expect(Object.getPrototypeOf(loggedOptions)).toBe(Object.prototype); expect(Object.getOwnPropertyDescriptor(loggedOptions, 'headers')).toBeUndefined(); expect(Object.getOwnPropertyDescriptor(loggedOptions, 'hidden')).toBeUndefined(); From 36cc2bc7ed8537cb24bedeffb53c8242f4dc3fa2 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 25 Aug 2026 19:00:00 +0000 Subject: [PATCH 19/35] fix(azure): safely snapshot cross-realm Headers subclasses --- src/azure.ts | 39 ++++--- .../azure-credential-header-privacy.test.ts | 100 ++++++++++++++++++ 2 files changed, 126 insertions(+), 13 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index e926e030e..c4514867c 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -454,27 +454,40 @@ function snapshotCrossRealmHeaders(headers: RequestInit['headers']): RequestInit return headers; } - const prototype = Object.getPrototypeOf(headers) as object | null; - if ( - prototype === null || - Object.getOwnPropertyDescriptor(prototype, Symbol.toStringTag)?.value !== '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; + } + + if (operations.some((operation) => Object.getOwnPropertyDescriptor(prototype, operation) !== undefined)) { + hasOverriddenOperation = true; + } + prototype = Object.getPrototypeOf(prototype) as object | null; + } + + if (trustedPrototype === undefined) { return headers; } - const operations = [Symbol.iterator, 'entries', 'get', 'has'] as const; - const valid = operations.every((operation) => { - const descriptor = Object.getOwnPropertyDescriptor(prototype, operation); - return ( - typeof descriptor?.value === 'function' && - Object.getOwnPropertyDescriptor(headers, operation) === undefined + 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(prototype, Symbol.iterator) as PropertyDescriptor; + const iterator = Object.getOwnPropertyDescriptor(headerPrototype, Symbol.iterator) as PropertyDescriptor; const snapshots: [string, string][] = []; for (const row of iterator.value.call(headers) as Iterable) { if (snapshots.length >= 1024 || !Array.isArray(row)) { diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index af0285d24..7c85309d7 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -1900,6 +1900,106 @@ describe('Azure credential header diagnostic privacy', () => { }, ); + 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; + const target = + override === 'instance accessor' ? injected : override === 'ancestor accessor' ? ancestor : subclass; + 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) => { From 57042322d652e11e70cce47ba4ec8de4b660d8b4 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 25 Aug 2026 19:03:23 +0000 Subject: [PATCH 20/35] fix(azure): satisfy credential regression lint rules --- src/azure.ts | 8 ++++++-- tests/lib/azure-credential-header-privacy.test.ts | 12 ++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index c4514867c..6f835fe4c 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -467,10 +467,14 @@ function snapshotCrossRealmHeaders(headers: RequestInit['headers']): RequestInit break; } - if (operations.some((operation) => Object.getOwnPropertyDescriptor(prototype, operation) !== undefined)) { + const currentPrototype = prototype; + const overriddenOperation = operations.some( + (operation) => Object.getOwnPropertyDescriptor(currentPrototype, operation) !== undefined, + ); + if (overriddenOperation) { hasOverriddenOperation = true; } - prototype = Object.getPrototypeOf(prototype) as object | null; + prototype = Object.getPrototypeOf(currentPrototype) as object | null; } if (trustedPrototype === undefined) { diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index 7c85309d7..ebb7caa56 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -1969,13 +1969,17 @@ describe('Azure credential header diagnostic privacy', () => { ) as Headers; const subclass = Object.getPrototypeOf(injected) as object; const ancestor = Object.getPrototypeOf(subclass) as object; - const target = - override === 'instance accessor' ? injected : override === 'ancestor accessor' ? ancestor : subclass; + 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); + throw new Error(`${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`); }; Object.defineProperty(target, key, { configurable: true, @@ -1993,7 +1997,7 @@ describe('Azure credential header diagnostic privacy', () => { await expectPrivateCredentialFailure( () => client.invokeProtectedFetch(injected), - PRIVATE_CREDENTIAL + '\n' + PRIVATE_SUFFIX, + `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`, ); expect(operationReads).toBe(0); expect(fetch).not.toHaveBeenCalled(); From 1695ca753af94ebc9d4519fcb9c3d9ef4753e392 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 25 Aug 2026 19:23:16 +0000 Subject: [PATCH 21/35] fix: isolate Azure credential header snapshots and markers --- src/azure.ts | 47 ++++++-- src/internal/headers.ts | 60 ++++++++-- .../azure-credential-header-privacy.test.ts | 105 +++++++++++++++++- 3 files changed, 190 insertions(+), 22 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index 6f835fe4c..5e139dcfa 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -180,7 +180,7 @@ export class AzureOpenAI extends OpenAI { let protection: ReturnType; try { - protection = preprocessesHeaders ? protectAzureRequestHeaders(headers) : undefined; + protection = preprocessesHeaders ? protectAzureRequestHeaders(headers, options) : undefined; const restoreAuthentication = protection ? snapshotAzureRequestAuthentication(this, this.authHeaders, protection) : undefined; @@ -319,7 +319,7 @@ const azureRequestHeadersAccessorSnapshots = new WeakMap< { descriptor: PropertyDescriptor; getter: () => FinalRequestOptions['headers']; - references: number; + snapshots: Array<{ headers: FinalRequestOptions['headers'] }>; } >(); @@ -327,7 +327,8 @@ function snapshotAzureRequestOptionsHeaders(options: FinalRequestOptions): { headers: FinalRequestOptions['headers']; restore?: () => void; } { - const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); + const active = azureRequestHeadersAccessorSnapshots.get(options); + const descriptor = active?.descriptor ?? Object.getOwnPropertyDescriptor(options, 'headers'); if (descriptor === undefined || 'value' in descriptor) { return { headers: options.headers }; } @@ -335,7 +336,7 @@ function snapshotAzureRequestOptionsHeaders(options: FinalRequestOptions): { throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } - const { headers } = options; + const headers = active === undefined ? options.headers : descriptor.get?.call(options); return { headers, restore: snapshotAzureRequestHeadersAccessor(options, headers) }; } @@ -349,35 +350,43 @@ function snapshotAzureRequestHeadersAccessor( if (descriptor === undefined || 'value' in descriptor || !descriptor.configurable) { throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } - let current = headers; - const getter = () => current; + + const snapshots: Array<{ headers: FinalRequestOptions['headers'] }> = []; + const getter = () => snapshots[snapshots.length - 1]?.headers; const originalSetter = descriptor.set; const setter = originalSetter === undefined ? undefined : function setHeaders(this: FinalRequestOptions, value: FinalRequestOptions['headers']): void { originalSetter.call(this, value); - current = value; + const current = snapshots[snapshots.length - 1]; + if (current !== undefined) { + current.headers = value; + } }; Object.defineProperty(options, 'headers', { ...descriptor, get: getter, ...(setter === undefined ? {} : { set: setter }), }); - snapshot = { descriptor, getter, references: 0 }; + snapshot = { descriptor, getter, snapshots }; azureRequestHeadersAccessorSnapshots.set(options, snapshot); } const active = snapshot; - active.references += 1; + const entry = { headers }; + active.snapshots.push(entry); let restored = false; return () => { if (restored) { return; } restored = true; - active.references -= 1; - if (active.references !== 0) { + const index = active.snapshots.indexOf(entry); + if (index !== -1) { + active.snapshots.splice(index, 1); + } + if (active.snapshots.length !== 0) { return; } @@ -450,7 +459,21 @@ function snapshotCrossRealmHeaders(headers: RequestInit['headers']): RequestInit if (headers === undefined || headers === null || typeof headers !== 'object') { return headers; } - if (headers instanceof Headers || Array.isArray(headers)) { + if (headers instanceof Headers) { + if (hasIntrinsicHeadersIdentity(headers)) { + return headers; + } + const entries = intrinsicHeadersDescriptors.get('entries'); + if (typeof entries !== 'function') { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + try { + return Array.from(entries.call(headers)) as [string, string][]; + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + } + if (Array.isArray(headers)) { return headers; } diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 9449a31a4..b17b227db 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -36,15 +36,18 @@ type AzureAuthenticationHeaderMutation = { type AzureRequestHeaderMarker = { active: boolean; + reserved: boolean; registration: AzureRequestHeaderRegistration; }; type AzureRequestHeaderRegistration = { carrier: NullableHeaders; headers: object; + owner: object | undefined; }; type AzureRequestHeaderRegistrations = { references: number; markers: AzureRequestHeaderMarker[]; + registrations: Set; }; type AzureRequestHeaderProtection = { bind: (carrier: NullableHeaders) => NullableHeaders; @@ -366,6 +369,7 @@ export const buildAzureAuthenticationHeaders = (...headers: AzureAuthenticationV /** 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; @@ -376,6 +380,7 @@ export const protectAzureRequestHeaders = ( registrations = { references: 0, markers: [], + registrations: new Set(), }; azureRequestHeaders.set(headers, registrations); } @@ -383,9 +388,15 @@ export const protectAzureRequestHeaders = ( const activeRegistration = { carrier: buildAzureAuthenticationHeaders(headers), headers, + owner, }; activeRegistrations.references += 1; - const marker: AzureRequestHeaderMarker = { active: true, registration: activeRegistration }; + activeRegistrations.registrations.add(activeRegistration); + const marker: AzureRequestHeaderMarker = { + active: true, + reserved: false, + registration: activeRegistration, + }; activeRegistrations.markers.push(marker); let released = false; @@ -401,6 +412,7 @@ export const protectAzureRequestHeaders = ( if (released) return; released = true; deactivate(); + activeRegistrations.registrations.delete(activeRegistration); activeRegistrations.references -= 1; if (activeRegistrations.references === 0) { azureRequestHeaders.delete(headers); @@ -422,12 +434,27 @@ export const protectAzureRequestHeaders = ( return { bind, deactivate, release }; }; -const consumeAzureBodyMarker = (headers: HeadersLike): AzureRequestHeaderRegistration | undefined => { +const reserveAzureBodyMarker = (headers: HeadersLike): AzureRequestHeaderMarker | undefined => { if (headers === undefined || headers === null || typeof headers !== 'object') return undefined; - const marker = azureRequestHeaders.get(headers)?.markers.pop(); - if (!marker) return undefined; - marker.active = false; - return marker.registration; + 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* iterateHeaders(headers: HeadersLike): IterableIterator { @@ -531,8 +558,10 @@ export const assertAzureAuthenticationHeaders = (headers: HeadersLike): void => } }; -export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { - const bodyRegistration = newHeaders.length === 1 ? consumeAzureBodyMarker(newHeaders[0]) : undefined; +const buildHeadersWithRegistration = ( + newHeaders: HeadersLike[], + bodyRegistration: AzureRequestHeaderRegistration | undefined, +): NullableHeaders => { let requestRegistration = bodyRegistration; let protectsAzureCredentials = bodyRegistration !== undefined; if (!protectsAzureCredentials) { @@ -564,7 +593,9 @@ export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { for (const source of newHeaders) { const seenHeaders = new Set(); const headers = - protectsAzureCredentials && requestRegistration !== undefined && source === requestRegistration.headers + protectsAzureCredentials && + requestRegistration !== undefined && + matchesAzureRequestHeaders(source, requestRegistration) ? requestRegistration.carrier : source; for (const [name, value] of iterateHeaders(headers)) { @@ -617,6 +648,17 @@ export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { 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/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index ebb7caa56..dfeaf9bfe 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -806,6 +806,69 @@ describe('Azure credential header diagnostic privacy', () => { expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get('api-key')).toBe('hook-replacement-token'); }); + 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() { + return snapshots[reads++]; + }, + }; + 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({ @@ -933,6 +996,43 @@ describe('Azure credential header diagnostic privacy', () => { }, ); + 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; @@ -1798,7 +1898,7 @@ describe('Azure credential header diagnostic privacy', () => { ); test.each( - (['get', 'has'] as const).flatMap((operation) => + (['get', 'has', 'entries'] as const).flatMap((operation) => (['prototype accessor', 'ancestor accessor', 'instance accessor'] as const).map((override) => ({ operation, override, @@ -1826,6 +1926,9 @@ describe('Azure credential header diagnostic privacy', () => { configurable: true, get() { accessorReads += 1; + if (operation === 'entries') { + throw new Error(`${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`); + } return Headers.prototype[operation]; }, }); From 99d8e7720305d8586d5e50f70959f738d472dfcc Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 25 Aug 2026 19:27:45 +0000 Subject: [PATCH 22/35] fix: satisfy Azure header snapshot lint rules --- src/azure.ts | 36 ++++++++++--------- .../azure-credential-header-privacy.test.ts | 4 ++- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index 5e139dcfa..fe2108fd7 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -319,7 +319,7 @@ const azureRequestHeadersAccessorSnapshots = new WeakMap< { descriptor: PropertyDescriptor; getter: () => FinalRequestOptions['headers']; - snapshots: Array<{ headers: FinalRequestOptions['headers'] }>; + snapshots: { headers: FinalRequestOptions['headers'] }[]; } >(); @@ -351,15 +351,15 @@ function snapshotAzureRequestHeadersAccessor( throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } - const snapshots: Array<{ headers: FinalRequestOptions['headers'] }> = []; - const getter = () => snapshots[snapshots.length - 1]?.headers; + const snapshots: { headers: FinalRequestOptions['headers'] }[] = []; + const getter = () => snapshots.at(-1)?.headers; const originalSetter = descriptor.set; const setter = originalSetter === undefined ? undefined : function setHeaders(this: FinalRequestOptions, value: FinalRequestOptions['headers']): void { originalSetter.call(this, value); - const current = snapshots[snapshots.length - 1]; + const current = snapshots.at(-1); if (current !== undefined) { current.headers = value; } @@ -455,23 +455,27 @@ function hasIntrinsicHeadersIdentity(headers: RequestInit['headers']): headers i return false; } +function snapshotSameRealmHeaders(headers: Headers): RequestInit['headers'] { + if (hasIntrinsicHeadersIdentity(headers)) { + return headers; + } + const entries = intrinsicHeadersDescriptors.get('entries'); + if (typeof entries !== 'function') { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + try { + return [...entries.call(headers)] as [string, string][]; + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } +} + function snapshotCrossRealmHeaders(headers: RequestInit['headers']): RequestInit['headers'] { if (headers === undefined || headers === null || typeof headers !== 'object') { return headers; } if (headers instanceof Headers) { - if (hasIntrinsicHeadersIdentity(headers)) { - return headers; - } - const entries = intrinsicHeadersDescriptors.get('entries'); - if (typeof entries !== 'function') { - throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); - } - try { - return Array.from(entries.call(headers)) as [string, string][]; - } catch { - throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); - } + return snapshotSameRealmHeaders(headers); } if (Array.isArray(headers)) { return headers; diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index dfeaf9bfe..7478f826a 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -836,7 +836,9 @@ describe('Azure credential header diagnostic privacy', () => { path: '/models', body: { shared: true }, get headers() { - return snapshots[reads++]; + const snapshot = snapshots[reads]; + reads += 1; + return snapshot; }, }; const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); From c5299da1dad663987a9c9a6abf0fb6c1e7d63666 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 25 Aug 2026 19:33:42 +0000 Subject: [PATCH 23/35] fix: protect Azure credentials for stateful request bodies --- src/azure.ts | 13 ++- .../azure-credential-header-privacy.test.ts | 94 +++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index fe2108fd7..a766e74c1 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -174,9 +174,12 @@ export class AzureOpenAI extends OpenAI { options.path = path`/deployments/${model}` + options.path; } } + const bodyDescriptor = Object.getOwnPropertyDescriptor(options, 'body'); const { body } = options; const { headers, restore } = snapshotAzureRequestOptionsHeaders(options); - const preprocessesHeaders = body === undefined ? 'body' in options : Boolean(body); + const preprocessesHeaders = + typeof bodyDescriptor?.get === 'function' || + (body === undefined ? 'body' in options : Boolean(body)); let protection: ReturnType; try { @@ -352,14 +355,18 @@ function snapshotAzureRequestHeadersAccessor( } const snapshots: { headers: FinalRequestOptions['headers'] }[] = []; - const getter = () => snapshots.at(-1)?.headers; + const latestSnapshot = () => { + const index = snapshots.length - 1; + return snapshots[index]; + }; + const getter = () => latestSnapshot()?.headers; const originalSetter = descriptor.set; const setter = originalSetter === undefined ? undefined : function setHeaders(this: FinalRequestOptions, value: FinalRequestOptions['headers']): void { originalSetter.call(this, value); - const current = snapshots.at(-1); + const current = latestSnapshot(); if (current !== undefined) { current.headers = value; } diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index 7478f826a..9edaf0ebb 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -617,6 +617,100 @@ describe('Azure credential header diagnostic privacy', () => { }, ); + 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' ? 2 : 1); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + 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) => { From 476508d660b4b8136a06086110c99df5efc8bfa9 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 25 Aug 2026 19:49:10 +0000 Subject: [PATCH 24/35] fix(azure): preserve isolated deferred credential compatibility --- src/azure.ts | 84 ++++++-- src/internal/headers.ts | 85 +++++++- .../azure-credential-header-privacy.test.ts | 184 ++++++++++++++++++ 3 files changed, 330 insertions(+), 23 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index a766e74c1..0bd9fa698 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -174,12 +174,8 @@ export class AzureOpenAI extends OpenAI { options.path = path`/deployments/${model}` + options.path; } } - const bodyDescriptor = Object.getOwnPropertyDescriptor(options, 'body'); - const { body } = options; + const preprocessesHeaders = shouldProtectAzureRequestHeaders(options); const { headers, restore } = snapshotAzureRequestOptionsHeaders(options); - const preprocessesHeaders = - typeof bodyDescriptor?.get === 'function' || - (body === undefined ? 'body' in options : Boolean(body)); let protection: ReturnType; try { @@ -256,6 +252,12 @@ export class AzureOpenAI extends OpenAI { } } +function shouldProtectAzureRequestHeaders(options: FinalRequestOptions): boolean { + const descriptor = Object.getOwnPropertyDescriptor(options, 'body'); + const { body } = options; + return typeof descriptor?.get === 'function' || (body === undefined ? 'body' in options : Boolean(body)); +} + type AzureAuthenticationHook = ( options: FinalRequestOptions, schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, @@ -274,6 +276,9 @@ function snapshotAzureRequestAuthentication( ? Object.isExtensible(client) : descriptor.configurable || ('value' in descriptor && descriptor.writable); if (!replaceable) { + if (descriptor === undefined) { + return () => undefined; + } throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } @@ -322,38 +327,52 @@ const azureRequestHeadersAccessorSnapshots = new WeakMap< { descriptor: PropertyDescriptor; getter: () => FinalRequestOptions['headers']; + inherited: boolean; snapshots: { headers: FinalRequestOptions['headers'] }[]; } >(); +function findAzureRequestHeadersDescriptor(options: FinalRequestOptions): { + descriptor: PropertyDescriptor; + inherited: boolean; +} | undefined { + let prototype: object | null = options; + for (let depth = 0; depth < 32 && 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; + } + return undefined; +} + function snapshotAzureRequestOptionsHeaders(options: FinalRequestOptions): { headers: FinalRequestOptions['headers']; restore?: () => void; } { const active = azureRequestHeadersAccessorSnapshots.get(options); - const descriptor = active?.descriptor ?? Object.getOwnPropertyDescriptor(options, 'headers'); + const found = active ?? findAzureRequestHeadersDescriptor(options); + const descriptor = found?.descriptor; if (descriptor === undefined || 'value' in descriptor) { return { headers: options.headers }; } - if (!descriptor.configurable) { + if (found?.inherited ? !Object.isExtensible(options) : !descriptor.configurable) { throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } const headers = active === undefined ? options.headers : descriptor.get?.call(options); - return { headers, restore: snapshotAzureRequestHeadersAccessor(options, headers) }; + return { headers, restore: snapshotAzureRequestHeadersAccessor(options, headers, descriptor) }; } function snapshotAzureRequestHeadersAccessor( options: FinalRequestOptions, headers: FinalRequestOptions['headers'], + descriptor: PropertyDescriptor, ): () => void { let snapshot = azureRequestHeadersAccessorSnapshots.get(options); if (snapshot === undefined) { - const descriptor = Object.getOwnPropertyDescriptor(options, 'headers'); - if (descriptor === undefined || 'value' in descriptor || !descriptor.configurable) { - throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); - } - + const inherited = Object.getOwnPropertyDescriptor(options, 'headers') === undefined; const snapshots: { headers: FinalRequestOptions['headers'] }[] = []; const latestSnapshot = () => { const index = snapshots.length - 1; @@ -373,10 +392,11 @@ function snapshotAzureRequestHeadersAccessor( }; Object.defineProperty(options, 'headers', { ...descriptor, + configurable: true, get: getter, ...(setter === undefined ? {} : { set: setter }), }); - snapshot = { descriptor, getter, snapshots }; + snapshot = { descriptor, getter, inherited, snapshots }; azureRequestHeadersAccessorSnapshots.set(options, snapshot); } @@ -398,9 +418,16 @@ function snapshotAzureRequestHeadersAccessor( } azureRequestHeadersAccessorSnapshots.delete(options); - if (Object.getOwnPropertyDescriptor(options, 'headers')?.get === active.getter) { - Object.defineProperty(options, 'headers', active.descriptor); + if (Object.getOwnPropertyDescriptor(options, 'headers')?.get !== active.getter) { + return; } + if (active.inherited) { + if (!Reflect.deleteProperty(options, 'headers')) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + return; + } + Object.defineProperty(options, 'headers', active.descriptor); }; } @@ -477,6 +504,27 @@ function snapshotSameRealmHeaders(headers: Headers): RequestInit['headers'] { } } +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 snapshotCrossRealmHeaders(headers: RequestInit['headers']): RequestInit['headers'] { if (headers === undefined || headers === null || typeof headers !== 'object') { return headers; @@ -526,9 +574,11 @@ function snapshotCrossRealmHeaders(headers: RequestInit['headers']): RequestInit } 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 >= 1024 || !Array.isArray(row)) { + 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); diff --git a/src/internal/headers.ts b/src/internal/headers.ts index b17b227db..cb1dcc587 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -85,6 +85,14 @@ const snapshotAzureAuthenticationHeaders = ( return layers; }; +const coerceAzureCredentialHeaderValue = (value: unknown): string => { + try { + return String(value); + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } +}; + class DeferredAzureAuthenticationHeaders extends Headers { constructor() { super(); @@ -215,7 +223,7 @@ class DeferredAzureAuthenticationHeaders extends Headers { continue; } const normalizedValue = isAzureAuthenticationHeader(normalized) - ? value.replace(/^[\t ]+|[\t ]+$/g, '') + ? coerceAzureCredentialHeaderValue(value).replace(/^[\t ]+|[\t ]+$/g, '') : value; const previous = effective.get(normalized); effective.set(normalized, previous === undefined ? normalizedValue : `${previous}, ${normalizedValue}`); @@ -226,7 +234,7 @@ class DeferredAzureAuthenticationHeaders extends Headers { private update(name: string, value: string, operation: 'append' | 'replace'): void { const normalized = String(name).toLowerCase(); const authentication = isAzureAuthenticationHeader(normalized); - const normalizedValue = authentication ? String(value) : value; + const normalizedValue = authentication ? coerceAzureCredentialHeaderValue(value) : value; let safe = true; if (authentication) { @@ -268,6 +276,36 @@ 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]); + }; + } + private initialize(): void { if (this.initialized) return; this.initialized = true; @@ -528,7 +566,17 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator { + try { + const record = headers as Record; + return [name, record[name]] as const; + } catch (error) { + if (isAzureAuthenticationHeader(name)) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + throw error; + } + }); } for (let row of iter) { const name = row[0]; @@ -553,9 +601,33 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator { for (const [name, value] of iterateHeaders(headers)) { if (value !== null && isAzureAuthenticationHeader(name)) { - assertAzureCredentialHeaderValue(value); + assertAzureCredentialHeaderValue(coerceAzureCredentialHeaderValue(value)); + } + } +}; + +const findUnboundAzureRequestRegistration = ( + headers: HeadersLike[], +): AzureRequestHeaderRegistration | undefined => { + let registration: AzureRequestHeaderRegistration | undefined; + for (const source of headers) { + if (source === null || typeof source !== 'object') { + continue; + } + const active = azureRequestHeaders.get(source); + if (active === undefined) { + continue; + } + if (active.registrations.size !== 1) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + const [candidate] = active.registrations; + if (registration !== undefined && registration !== candidate) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } + registration = candidate; } + return registration; }; const buildHeadersWithRegistration = ( @@ -580,7 +652,8 @@ const buildHeadersWithRegistration = ( protectsAzureCredentials = true; requestRegistration = azureRequestAuthenticationHeaders.get(headers as NullableHeaders) ?? - azureRequestAuthenticationHeaders.get(carrier); + azureRequestAuthenticationHeaders.get(carrier) ?? + findUnboundAzureRequestRegistration(newHeaders); if (requestRegistration) { break; } @@ -634,7 +707,7 @@ const buildHeadersWithRegistration = ( } for (const [name, values] of pendingAuthenticationHeaders) { const snapshots = values.map((value) => { - const snapshot = String(value); + const snapshot = coerceAzureCredentialHeaderValue(value); assertAzureCredentialHeaderValue(snapshot); return snapshot; }); diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index 9edaf0ebb..a3526576d 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -3273,3 +3273,187 @@ describe('Azure credential header diagnostic privacy', () => { }, ); }); + +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('fails closed for 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 () => { + await new Promise((resolve) => releases.push(resolve)); + }; + 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); + + const firstOutcome = first.catch((error: unknown) => error); + releases[0]?.(); + expect(await firstOutcome).toEqual(new TypeError(SAFE_ERROR)); + releases[1]?.(); + expect((await second).req.headers.get('api-key')).toBe('tenant-b-token'); + }); + + 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'); + } + }); +}); From 21112059e7027eacf343940ca323769ba8228bb9 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 25 Aug 2026 13:25:36 -0700 Subject: [PATCH 25/35] fix(azure): sanitize hostile credential access and request headers --- src/azure.ts | 182 ++++++---- src/beta/realtime/websocket.ts | 8 +- src/beta/realtime/ws.ts | 11 +- src/internal/azure.ts | 50 +-- src/internal/utils/log.ts | 47 ++- src/realtime/websocket.ts | 8 +- src/realtime/ws.ts | 11 +- .../azure-credential-header-privacy.test.ts | 311 +++++++++++++++++- .../lib/azure-post-hook-proxy-privacy.test.ts | 105 ++++++ tests/log.test.ts | 64 ++++ tests/realtime-websocket.test.ts | 212 ++++++++++++ 11 files changed, 894 insertions(+), 115 deletions(-) create mode 100644 tests/lib/azure-post-hook-proxy-privacy.test.ts diff --git a/src/azure.ts b/src/azure.ts index 0bd9fa698..e96b2bb6f 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -253,9 +253,30 @@ export class AzureOpenAI extends OpenAI { } function shouldProtectAzureRequestHeaders(options: FinalRequestOptions): boolean { - const descriptor = Object.getOwnPropertyDescriptor(options, 'body'); + let owner: object | null = options; + let descriptor = Object.getOwnPropertyDescriptor(options, 'body'); + if (descriptor === undefined) { + try { + 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; + } + } + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + } const { body } = options; - return typeof descriptor?.get === 'function' || (body === undefined ? 'body' in options : Boolean(body)); + return ( + typeof descriptor?.get === 'function' || + (descriptor === undefined && owner !== null) || + (body === undefined ? 'body' in options : Boolean(body)) + ); } type AzureAuthenticationHook = ( @@ -277,7 +298,9 @@ function snapshotAzureRequestAuthentication( : descriptor.configurable || ('value' in descriptor && descriptor.writable); if (!replaceable) { if (descriptor === undefined) { - return () => undefined; + return () => { + // Nonextensible clients retain their existing inherited authentication hook. + }; } throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } @@ -332,10 +355,12 @@ const azureRequestHeadersAccessorSnapshots = new WeakMap< } >(); -function findAzureRequestHeadersDescriptor(options: FinalRequestOptions): { - descriptor: PropertyDescriptor; - inherited: boolean; -} | undefined { +function findAzureRequestHeadersDescriptor(options: FinalRequestOptions): + | { + descriptor: PropertyDescriptor; + inherited: boolean; + } + | undefined { let prototype: object | null = options; for (let depth = 0; depth < 32 && prototype !== null; depth += 1) { const descriptor = Object.getOwnPropertyDescriptor(prototype, 'headers'); @@ -493,12 +518,30 @@ function snapshotSameRealmHeaders(headers: Headers): RequestInit['headers'] { if (hasIntrinsicHeadersIdentity(headers)) { return headers; } - const entries = intrinsicHeadersDescriptors.get('entries'); - if (typeof entries !== 'function') { + const intrinsicEntries = intrinsicHeadersDescriptors.get('entries'); + if (typeof intrinsicEntries !== 'function') { throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } try { - return [...entries.call(headers)] as [string, string][]; + 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; + return [...snapshot] as [string, string][]; } catch { throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } @@ -525,70 +568,87 @@ function hasCanonicalCrossRealmHeaders(headers: object, prototype: object): bool } } -function snapshotCrossRealmHeaders(headers: RequestInit['headers']): RequestInit['headers'] { - if (headers === undefined || headers === null || typeof headers !== 'object') { - return headers; - } - if (headers instanceof Headers) { - return snapshotSameRealmHeaders(headers); +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, + }); } - if (Array.isArray(headers)) { + return snapshot; +} + +function snapshotCrossRealmHeaders(headers: RequestInit['headers']): RequestInit['headers'] { + if (typeof headers !== 'object' || headers === null) { 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; + try { + if (headers instanceof Headers) { + return snapshotSameRealmHeaders(headers); + } + if (Array.isArray(headers)) { + return headers; } - const currentPrototype = prototype; - const overriddenOperation = operations.some( - (operation) => Object.getOwnPropertyDescriptor(currentPrototype, operation) !== undefined, + 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, ); - if (overriddenOperation) { - hasOverriddenOperation = true; - } - prototype = Object.getPrototypeOf(currentPrototype) as object | null; - } - if (trustedPrototype === undefined) { - return headers; - } + for (let depth = 0; depth < 32 && prototype !== null; depth++) { + if (Object.getOwnPropertyDescriptor(prototype, Symbol.toStringTag)?.value === 'Headers') { + trustedPrototype = prototype; + break; + } - 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 currentPrototype = prototype; + const overriddenOperation = operations.some( + (operation) => Object.getOwnPropertyDescriptor(currentPrototype, operation) !== undefined, + ); + if (overriddenOperation) { + hasOverriddenOperation = true; + } + prototype = Object.getPrototypeOf(currentPrototype) as object | null; + } - 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.'); + if (trustedPrototype === undefined) { + return snapshotAzureHeaderRecord(headers); } - const name: unknown = Reflect.get(row, 0); - const value: unknown = Reflect.get(row, 1); - if (typeof name !== 'string' || typeof value !== 'string') { + + 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.'); } - snapshots.push([name, 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.'); } - return snapshots; } function protectAzureAmbientHeaders(options: Pick): void { diff --git a/src/beta/realtime/websocket.ts b/src/beta/realtime/websocket.ts index 2e01ef91f..9fa94e0a8 100644 --- a/src/beta/realtime/websocket.ts +++ b/src/beta/realtime/websocket.ts @@ -1,5 +1,5 @@ import type { AzureOpenAI } from '../../index'; -import { assertAzureCredentialHeaderValue } from '../../internal/azure'; +import { safeAzureCredentialHeaderValue } from '../../internal/azure'; import { assertBedrockWebSocketOrigin } from '../../internal/bedrock'; import { OpenAI } from '../../index'; import { OpenAIError } from '../../error'; @@ -120,8 +120,7 @@ function createAzureWebSocket( throw new Error('Azure OpenAI Realtime requires an API key'); } - const credential = String(apiKey); - assertAzureCredentialHeaderValue(credential); + const credential = safeAzureCredentialHeaderValue(apiKey); redactAzureCredentials(url, isBearerToken); const socketURL = new URL(url); socketURL.searchParams.delete('api-key'); @@ -189,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 b02ec5255..b2dff5e4a 100644 --- a/src/beta/realtime/ws.ts +++ b/src/beta/realtime/ws.ts @@ -1,5 +1,5 @@ import * as WS from 'ws'; -import { safeAzureWebSocketHeaders } from '../../internal/azure'; +import { safeAzureCredentialHeaderValue, safeAzureWebSocketHeaders } from '../../internal/azure'; import { assertBedrockWebSocketOrigin } from '../../internal/bedrock'; import { protectWebSocketOptionsFromCredentialRedirects } from '../../internal/ws'; import type { AzureOpenAI } from '../../index'; @@ -68,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', }; @@ -78,7 +83,7 @@ export class OpenAIRealtimeWS extends OpenAIRealtimeEmitter { this.url, protectWebSocketOptionsFromCredentialRedirects({ ...props.options, - headers: isAzure(client) ? safeAzureWebSocketHeaders(headers) : headers, + headers: azure ? safeAzureWebSocketHeaders(headers) : headers, }), ); diff --git a/src/internal/azure.ts b/src/internal/azure.ts index b513c10ba..b5c6bad4f 100644 --- a/src/internal/azure.ts +++ b/src/internal/azure.ts @@ -8,6 +8,18 @@ export function assertAzureCredentialHeaderValue(value: string): void { } } +/** 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(); @@ -45,27 +57,27 @@ export function safeAzureWebSocketHeaders 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; + 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)); } - const credential = String(entry); - assertAzureCredentialHeaderValue(credential); - snapshot.push(credential); + safeHeaders.set(name, snapshot); + } else { + safeHeaders.set(name, safeAzureCredentialHeaderValue(value)); } - safeHeaders.set(name, snapshot); - } else { - const credential = String(value); - assertAzureCredentialHeaderValue(credential); - safeHeaders.set(name, credential); + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } } return Object.fromEntries(safeHeaders) as Headers; diff --git a/src/internal/utils/log.ts b/src/internal/utils/log.ts index 434e62b8a..41e8e34f2 100644 --- a/src/internal/utils/log.ts +++ b/src/internal/utils/log.ts @@ -57,6 +57,15 @@ const noopLogger = { }; let cachedLoggers = /* @__PURE__ */ new WeakMap(); +const intrinsicHeadersEntries = Headers.prototype.entries; +const redactedHeaderNames = new Set([ + 'authorization', + 'api-key', + 'x-api-key', + 'x-amz-security-token', + 'cookie', + 'set-cookie', +]); export function loggerFor(client: OpenAI): Logger { const logger = client.logger; @@ -84,7 +93,7 @@ export function loggerFor(client: OpenAI): Logger { 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; @@ -105,21 +114,27 @@ export const formatRequestDetails = (details: { ); } if (details.headers) { - details.headers = Object.fromEntries( - (details.headers instanceof Headers ? [...details.headers] : Object.entries(details.headers)).map( - ([name, value]) => [ - name, - name.toLowerCase() === 'authorization' || - name.toLowerCase() === 'api-key' || - name.toLowerCase() === 'x-api-key' || - name.toLowerCase() === 'x-amz-security-token' || - name.toLowerCase() === 'cookie' || - name.toLowerCase() === 'set-cookie' - ? '***' - : value, - ], - ), - ); + const headers = details.headers; + try { + details.headers = Object.fromEntries( + headers instanceof Headers + ? Array.from(intrinsicHeadersEntries.call(headers), ([name, value]) => [ + name, + redactedHeaderNames.has(name.toLowerCase()) ? '***' : value, + ]) + : Array.isArray(headers) + ? headers.map((entry) => { + const name = entry[0]; + return [name, redactedHeaderNames.has(name.toLowerCase()) ? '***' : entry[1]]; + }) + : Object.keys(headers).map((name) => [ + name, + redactedHeaderNames.has(name.toLowerCase()) ? '***' : (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 00ccd9fc7..15e98ca8f 100644 --- a/src/realtime/websocket.ts +++ b/src/realtime/websocket.ts @@ -1,5 +1,5 @@ import type { AzureOpenAI } from '../index'; -import { assertAzureCredentialHeaderValue } from '../internal/azure'; +import { safeAzureCredentialHeaderValue } from '../internal/azure'; import { assertBedrockWebSocketOrigin } from '../internal/bedrock'; import { OpenAI } from '../index'; import { OpenAIError } from '../error'; @@ -126,8 +126,7 @@ function createAzureWebSocket( throw new Error('Azure OpenAI Realtime requires an API key'); } - const credential = String(apiKey); - assertAzureCredentialHeaderValue(credential); + const credential = safeAzureCredentialHeaderValue(apiKey); redactAzureCredentials(url, isBearerToken); const socketURL = new URL(url); socketURL.searchParams.delete('api-key'); @@ -183,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 3bdc35ebd..53ff984e7 100644 --- a/src/realtime/ws.ts +++ b/src/realtime/ws.ts @@ -1,5 +1,5 @@ import * as WS from 'ws'; -import { safeAzureWebSocketHeaders } from '../internal/azure'; +import { safeAzureCredentialHeaderValue, safeAzureWebSocketHeaders } from '../internal/azure'; import { assertBedrockWebSocketOrigin } from '../internal/bedrock'; import { protectWebSocketOptionsFromCredentialRedirects } from '../internal/ws'; import type { AzureOpenAI } from '../index'; @@ -62,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: isAzure(client) ? safeAzureWebSocketHeaders(headers) : headers, + headers: azure ? safeAzureWebSocketHeaders(headers) : headers, }), ); diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index a3526576d..13a944c43 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -1,3 +1,4 @@ +import { once } from 'node:events'; import { createRequire } from 'node:module'; import { runInNewContext } from 'node:vm'; import { vi } from 'vitest'; @@ -14,7 +15,7 @@ type Fetch = (url: RequestInfo, init?: RequestInit) => Promise; type CarrierAuthenticationScheme = 'auth' | 'bearer' | 'admin'; class ProtectedHookAzure extends AzureOpenAI { - injectedHeaders: Record | Headers | undefined; + injectedHeaders: Record | Headers | [string, string][] | undefined; bearerCalls = 0; adminCalls = 0; fetchFailures = 0; @@ -672,6 +673,126 @@ describe('Azure credential header diagnostic privacy', () => { }, ); + 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(2); + 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(['api-key', 'Authorization'] as const)( 'protects $name when a body accessor replaces itself with a truthy value', async (name) => { @@ -1829,6 +1950,184 @@ describe('Azure credential header diagnostic privacy', () => { 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) => { @@ -3385,7 +3684,9 @@ describe('Azure deferred credential and request registration regressions', () => maxRetries: 0, }); client.observeAuthenticationOptions = async () => { - await new Promise((resolve) => releases.push(resolve)); + const gate = new AbortController(); + releases.push(() => gate.abort()); + await once(gate.signal, 'abort'); }; Object.preventExtensions(client); const headers = { 'api-key': 'tenant-a-token' }; @@ -3401,11 +3702,11 @@ describe('Azure deferred credential and request registration regressions', () => const second = client.buildRequest(options); expect(releases).toHaveLength(2); - const firstOutcome = first.catch((error: unknown) => error); releases[0]?.(); - expect(await firstOutcome).toEqual(new TypeError(SAFE_ERROR)); + await expect(first).rejects.toEqual(new TypeError(SAFE_ERROR)); releases[1]?.(); - expect((await second).req.headers.get('api-key')).toBe('tenant-b-token'); + const secondBuilt = await second; + expect(secondBuilt.req.headers.get('api-key')).toBe('tenant-b-token'); }); test('preserves more than 1,024 genuine cross-realm undici header fields', async () => { 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/log.test.ts b/tests/log.test.ts index 7a75fb8a7..346b1abf7 100644 --- a/tests/log.test.ts +++ b/tests/log.test.ts @@ -60,6 +60,70 @@ describe('formatRequestDetails()', () => { expect(Object.getOwnPropertyDescriptor(loggedOptions, 'hidden')).toBeUndefined(); expect(Object.getOwnPropertyDescriptor(loggedOptions, 'inherited')).toBeUndefined(); }); + + test.each(['Authorization', 'API-Key', 'X-API-Key', 'X-Amz-Security-Token', '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', 'API-Key', 'X-API-Key', 'X-Amz-Security-Token', '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()', () => { diff --git a/tests/realtime-websocket.test.ts b/tests/realtime-websocket.test.ts index 1a181fe5d..402395e9f 100644 --- a/tests/realtime-websocket.test.ts +++ b/tests/realtime-websocket.test.ts @@ -143,6 +143,30 @@ function statefulCredential(first: string, second: string) { }; } +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(); @@ -180,6 +204,100 @@ describe('Azure realtime credential diagnostic privacy', () => { { 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) => @@ -298,6 +416,100 @@ describe('Azure realtime credential diagnostic privacy', () => { }, ); + 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) }, From 94bfb1c22b307fa69199a7e93220955bf839828a Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 25 Aug 2026 14:04:43 -0700 Subject: [PATCH 26/35] fix(azure): bound header snapshots and sanitize proxy traps --- src/azure.ts | 206 ++++-- .../azure-credential-header-privacy.test.ts | 585 ++++++++++++++++++ 2 files changed, 734 insertions(+), 57 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index e96b2bb6f..ecbdfa0d8 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -165,17 +165,12 @@ 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'); - } - const model = this.deploymentName || options.body['model'] || options.__metadata?.['model']; - if (model !== undefined && !this.baseURL.includes('/deployments')) { - options.path = path`/deployments/${model}` + options.path; - } - } + prepareAzureDeploymentRequest(options, this.deploymentName, this.baseURL); const preprocessesHeaders = shouldProtectAzureRequestHeaders(options); const { headers, restore } = snapshotAzureRequestOptionsHeaders(options); + const accessorSnapshot = azureRequestHeadersAccessorSnapshots.get(options); + const accessorIndex = (accessorSnapshot?.snapshots.length ?? 0) - 1; + const accessorEntry = accessorSnapshot?.snapshots[accessorIndex]; let protection: ReturnType; try { @@ -191,7 +186,12 @@ export class AzureOpenAI extends OpenAI { protection?.deactivate(); } - const built = await pending; + const built = await pending.catch((error: unknown) => { + if (accessorSnapshot?.descriptor.enumerable && accessorEntry?.copied === 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'; } @@ -252,11 +252,37 @@ export class AzureOpenAI extends OpenAI { } } +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 { - let owner: object | null = options; - let descriptor = Object.getOwnPropertyDescriptor(options, 'body'); - if (descriptor === undefined) { - try { + 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) { @@ -267,16 +293,16 @@ function shouldProtectAzureRequestHeaders(options: FinalRequestOptions): boolean break; } } - } catch { - throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } + const { body } = options; + return ( + typeof descriptor?.get === 'function' || + (descriptor === undefined && owner !== null) || + (body === undefined ? 'body' in options : Boolean(body)) + ); + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } - const { body } = options; - return ( - typeof descriptor?.get === 'function' || - (descriptor === undefined && owner !== null) || - (body === undefined ? 'body' in options : Boolean(body)) - ); } type AzureAuthenticationHook = ( @@ -345,16 +371,36 @@ function snapshotAzureRequestAuthentication( return restore; } +interface AzureRequestHeadersAccessorSnapshot { + descriptor: PropertyDescriptor; + getter: () => FinalRequestOptions['headers']; + inherited: boolean; + snapshots: { copied: boolean; headers: FinalRequestOptions['headers'] }[]; +} + const azureRequestHeadersAccessorSnapshots = new WeakMap< FinalRequestOptions, - { - descriptor: PropertyDescriptor; - getter: () => FinalRequestOptions['headers']; - inherited: boolean; - snapshots: { headers: FinalRequestOptions['headers'] }[]; - } + AzureRequestHeadersAccessorSnapshot >(); +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; @@ -376,18 +422,30 @@ function snapshotAzureRequestOptionsHeaders(options: FinalRequestOptions): { headers: FinalRequestOptions['headers']; restore?: () => void; } { - const active = azureRequestHeadersAccessorSnapshots.get(options); - const found = active ?? findAzureRequestHeadersDescriptor(options); - const descriptor = found?.descriptor; - if (descriptor === undefined || 'value' in descriptor) { - return { headers: options.headers }; - } - if (found?.inherited ? !Object.isExtensible(options) : !descriptor.configurable) { + 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 { headers: options.headers }; + } + if (found?.inherited ? !Object.isExtensible(options) : !descriptor.configurable) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + + 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.'); } - - const headers = active === undefined ? options.headers : descriptor.get?.call(options); - return { headers, restore: snapshotAzureRequestHeadersAccessor(options, headers, descriptor) }; } function snapshotAzureRequestHeadersAccessor( @@ -398,12 +456,18 @@ function snapshotAzureRequestHeadersAccessor( let snapshot = azureRequestHeadersAccessorSnapshots.get(options); if (snapshot === undefined) { const inherited = Object.getOwnPropertyDescriptor(options, 'headers') === undefined; - const snapshots: { headers: FinalRequestOptions['headers'] }[] = []; + const snapshots: { copied: boolean; headers: FinalRequestOptions['headers'] }[] = []; const latestSnapshot = () => { const index = snapshots.length - 1; return snapshots[index]; }; - const getter = () => latestSnapshot()?.headers; + const getter = () => { + const current = latestSnapshot(); + if (current !== undefined) { + current.copied = true; + } + return current?.headers; + }; const originalSetter = descriptor.set; const setter = originalSetter === undefined @@ -415,18 +479,27 @@ function snapshotAzureRequestHeadersAccessor( current.headers = value; } }; - Object.defineProperty(options, 'headers', { - ...descriptor, - configurable: true, - get: getter, - ...(setter === undefined ? {} : { set: setter }), - }); snapshot = { descriptor, getter, inherited, 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 = { headers }; + const entry = { copied: false, headers }; active.snapshots.push(entry); let restored = false; return () => { @@ -442,17 +515,16 @@ function snapshotAzureRequestHeadersAccessor( return; } - azureRequestHeadersAccessorSnapshots.delete(options); - if (Object.getOwnPropertyDescriptor(options, 'headers')?.get !== active.getter) { - return; - } - if (active.inherited) { - if (!Reflect.deleteProperty(options, 'headers')) { - throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + try { + restoreAzureRequestHeadersAccessor(options, active); + } catch { + try { + restoreAzureRequestHeadersAccessor(options, active); + } catch { + // Retain the original snapshot when hostile hooks also prevent restoration. } - return; + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } - Object.defineProperty(options, 'headers', active.descriptor); }; } @@ -541,7 +613,27 @@ function snapshotSameRealmHeaders(headers: Headers): RequestInit['headers'] { ? entriesDescriptor.value : intrinsicEntries; const snapshot = Reflect.apply(entries, headers, []) as ReturnType; - return [...snapshot] as [string, string][]; + if (entries === intrinsicEntries) { + return [...snapshot] as [string, string][]; + } + 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.'); } diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index 13a944c43..a4e52f2b1 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -793,6 +793,60 @@ describe('Azure credential header diagnostic privacy', () => { }, ); + 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) => { @@ -917,6 +971,297 @@ describe('Azure credential header diagnostic privacy', () => { }, ); + 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) => { @@ -2610,6 +2955,246 @@ describe('Azure credential header diagnostic privacy', () => { 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) => { From c611eadc5a5490a069cca9e33540988ea0113c2c Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 25 Aug 2026 14:42:21 -0700 Subject: [PATCH 27/35] fix(azure): preserve body accessors and live header iteration --- src/azure.ts | 69 +- src/internal/headers.ts | 147 +++- .../azure-credential-header-privacy.test.ts | 724 +++++++++++++++++- 3 files changed, 928 insertions(+), 12 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index ecbdfa0d8..f989853fa 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -179,11 +179,17 @@ export class AzureOpenAI extends OpenAI { ? snapshotAzureRequestAuthentication(this, this.authHeaders, protection) : undefined; let pending: ReturnType; + let restoreBody: (() => void) | undefined; try { + restoreBody = snapshotAzureRequestBodyAccessor(options); pending = super.buildRequest(options, props); } finally { - restoreAuthentication?.(); - protection?.deactivate(); + try { + restoreBody?.(); + } finally { + restoreAuthentication?.(); + protection?.deactivate(); + } } const built = await pending.catch((error: unknown) => { @@ -294,11 +300,12 @@ function shouldProtectAzureRequestHeaders(options: FinalRequestOptions): boolean } } } + if (typeof descriptor?.get === 'function') { + return true; + } const { body } = options; return ( - typeof descriptor?.get === 'function' || - (descriptor === undefined && owner !== null) || - (body === undefined ? 'body' in options : Boolean(body)) + (descriptor === undefined && owner !== null) || (body === undefined ? 'body' in options : Boolean(body)) ); } catch { throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); @@ -312,6 +319,58 @@ type AzureAuthenticationHook = ( const azureRequestAuthenticationOriginals = new WeakMap(); +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.'); + } + }; +} + function snapshotAzureRequestAuthentication( client: AzureOpenAI, authenticate: AzureAuthenticationHook, diff --git a/src/internal/headers.ts b/src/internal/headers.ts index cb1dcc587..4b30f3423 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -12,6 +12,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 @@ -33,6 +34,7 @@ type AzureAuthenticationHeaderMutation = { kind: 'append' | 'replace' | 'delete'; values: string[]; }; +type AzureAuthenticationHeaderIteratorResult = IteratorResult<[string, string] | string>; type AzureRequestHeaderMarker = { active: boolean; @@ -66,6 +68,12 @@ const azureAuthenticationHeaderMutations = new WeakMap< Headers, Map >(); +const azureAuthenticationHeaderMutationVersions = new WeakMap(); +const azureAuthenticationHeaderIteratorStates = new WeakMap< + object, + () => AzureAuthenticationHeaderIteratorResult +>(); +const azureAuthenticationHeaderIteratorPrototypes = new WeakMap(); const azureAuthenticationNullCarriers = new WeakMap, NullableHeaders>(); const azureRequestHeaders = new WeakMap(); @@ -93,6 +101,31 @@ const coerceAzureCredentialHeaderValue = (value: unknown): string => { } }; +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(); @@ -149,21 +182,21 @@ class DeferredAzureAuthenticationHeaders extends Headers { configurable: true, writable: true, value(this: DeferredAzureAuthenticationHeaders): ReturnType { - return this.current().entries() as unknown as ReturnType; + return this.iterator('entries'); }, }, keys: { configurable: true, writable: true, value(this: DeferredAzureAuthenticationHeaders): ReturnType { - return this.current().keys() as unknown as ReturnType; + return this.iterator('keys'); }, }, values: { configurable: true, writable: true, value(this: DeferredAzureAuthenticationHeaders): ReturnType { - return this.current().values() as unknown as ReturnType; + return this.iterator('values'); }, }, [Symbol.iterator]: { @@ -205,13 +238,95 @@ class DeferredAzureAuthenticationHeaders extends Headers { 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); - azureAuthenticationHeaderMutations.get(this)?.set(normalized, { kind: 'delete', values: [] }); + mutations?.set(normalized, { kind: 'delete', values: [] }); + if (existed || previous?.kind !== 'delete') { + invalidateAzureAuthenticationHeaderIterators(this); + } }, }, }); } + 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()]; + nativeEntries = [...Headers.prototype.entries.call(this)]; + nativeValues = new Map(nativeEntries); + 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 }; + } + 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); @@ -235,6 +350,7 @@ class DeferredAzureAuthenticationHeaders extends Headers { 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) { @@ -269,6 +385,18 @@ class DeferredAzureAuthenticationHeaders extends Headers { kind, values: authentication ? [...(previousValues ?? []), normalizedValue] : [], }); + 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); + } } } @@ -367,15 +495,24 @@ class DeferredAzureAuthenticationNulls extends Set { 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)) { - azureAuthenticationNullCarriers.get(this)?.values.delete(value); + carrier?.values.delete(value); + } + if (removed && carrier) { + invalidateAzureAuthenticationHeaderIterators(carrier.values); } return removed; } diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index a4e52f2b1..61cf64e6a 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -618,6 +618,274 @@ describe('Azure credential header diagnostic privacy', () => { }, ); + 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 }, @@ -668,7 +936,7 @@ describe('Azure credential header diagnostic privacy', () => { await expectPrivateCredentialFailure(() => client.request(options), credential); - expect(reads).toBe(representation === 'own' ? 2 : 1); + expect(reads).toBe(representation === 'own' ? 1 : 0); expect(fetch).not.toHaveBeenCalled(); }, ); @@ -738,7 +1006,7 @@ describe('Azure credential header diagnostic privacy', () => { await expectPrivateCredentialFailure(() => client.request(options), credential); - expect(reads).toBe(2); + expect(reads).toBe(depth === 'deep' ? 2 : 0); expect(fetch).not.toHaveBeenCalled(); expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); }, @@ -3712,6 +3980,458 @@ describe('Azure credential header diagnostic privacy', () => { }, ); + 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 ' }, From cf44e55767a349f87aa3dc916efc7e06eb9228cc Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 25 Aug 2026 15:02:19 -0700 Subject: [PATCH 28/35] fix(azure): safely snapshot immutable credential accessors --- src/azure.ts | 62 +- .../azure-credential-header-accessors.test.ts | 553 ++++++++++++++++++ .../azure-credential-header-privacy.test.ts | 4 +- 3 files changed, 605 insertions(+), 14 deletions(-) create mode 100644 tests/lib/azure-credential-header-accessors.test.ts diff --git a/src/azure.ts b/src/azure.ts index f989853fa..61641c2e5 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -167,7 +167,12 @@ export class AzureOpenAI extends OpenAI { }> { prepareAzureDeploymentRequest(options, this.deploymentName, this.baseURL); const preprocessesHeaders = shouldProtectAzureRequestHeaders(options); - const { headers, restore } = snapshotAzureRequestOptionsHeaders(options); + const { + copied, + headers, + options: requestOptions = options, + restore, + } = snapshotAzureRequestOptionsHeaders(options); const accessorSnapshot = azureRequestHeadersAccessorSnapshots.get(options); const accessorIndex = (accessorSnapshot?.snapshots.length ?? 0) - 1; const accessorEntry = accessorSnapshot?.snapshots[accessorIndex]; @@ -175,14 +180,20 @@ export class AzureOpenAI extends OpenAI { try { protection = preprocessesHeaders ? protectAzureRequestHeaders(headers, options) : undefined; - const restoreAuthentication = protection - ? snapshotAzureRequestAuthentication(this, this.authHeaders, protection) - : undefined; + const restoreAuthentication = + protection !== undefined || requestOptions !== options + ? snapshotAzureRequestAuthentication( + this, + this.authHeaders, + protection, + requestOptions === options ? undefined : options, + ) + : undefined; let pending: ReturnType; let restoreBody: (() => void) | undefined; try { restoreBody = snapshotAzureRequestBodyAccessor(options); - pending = super.buildRequest(options, props); + pending = super.buildRequest(requestOptions, props); } finally { try { restoreBody?.(); @@ -193,7 +204,10 @@ export class AzureOpenAI extends OpenAI { } const built = await pending.catch((error: unknown) => { - if (accessorSnapshot?.descriptor.enumerable && accessorEntry?.copied === false) { + if ( + (accessorSnapshot?.descriptor.enumerable && accessorEntry?.copied === false) || + copied?.value === false + ) { throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } throw error; @@ -244,7 +258,7 @@ export class AzureOpenAI extends OpenAI { } protected override async bearerAuth(_opts: FinalRequestOptions): Promise { - if (this.apiKey === null) { + if (this.apiKey === null || this.apiKey === undefined) { return undefined; } return buildAzureAuthenticationHeaders([['Authorization', `Bearer ${this.apiKey}`]]); @@ -374,7 +388,8 @@ function snapshotAzureRequestBodyAccessor(options: FinalRequestOptions): (() => function snapshotAzureRequestAuthentication( client: AzureOpenAI, authenticate: AzureAuthenticationHook, - protection: NonNullable>, + protection: ReturnType, + originalOptions?: FinalRequestOptions, ): () => void { const descriptor = Object.getOwnPropertyDescriptor(client, 'authHeaders'); const replaceable = @@ -396,8 +411,8 @@ function snapshotAzureRequestAuthentication( schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, ): Promise => { restore(); - const carrier = await original.call(client, options, schemes); - return carrier === undefined ? undefined : protection.bind(carrier); + const carrier = await original.call(client, originalOptions ?? options, schemes); + return carrier === undefined || protection === undefined ? carrier : protection.bind(carrier); }; azureRequestAuthenticationOriginals.set(snapshot, original); const restore = (): void => { @@ -467,18 +482,23 @@ function findAzureRequestHeadersDescriptor(options: FinalRequestOptions): } | undefined { let prototype: object | null = options; - for (let depth = 0; depth < 32 && prototype !== null; depth += 1) { + 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): { + copied?: { value: boolean }; headers: FinalRequestOptions['headers']; + options?: FinalRequestOptions; restore?: () => void; } { try { @@ -497,7 +517,25 @@ function snapshotAzureRequestOptionsHeaders(options: FinalRequestOptions): { return { headers: options.headers }; } if (found?.inherited ? !Object.isExtensible(options) : !descriptor.configurable) { - throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + const { headers } = options; + const copied = { value: false }; + const requestOptions = 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); + }, + }); + return { + ...(descriptor.enumerable ? { copied } : {}), + headers, + options: requestOptions, + }; } const headers = active === undefined ? options.headers : descriptor.get?.call(options); 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..7087c1959 --- /dev/null +++ b/tests/lib/azure-credential-header-accessors.test.ts @@ -0,0 +1,553 @@ +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-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('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 index 61cf64e6a..1031afe4e 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -1562,7 +1562,7 @@ describe('Azure credential header diagnostic privacy', () => { }, ); - test('rejects a nonconfigurable request headers accessor before reading an unsafe credential', async () => { + 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 } }; @@ -1585,7 +1585,7 @@ describe('Azure credential header diagnostic privacy', () => { await expectPrivateCredentialFailure(() => client.request(options), malformed); - expect(reads).toBe(0); + expect(reads).toBe(1); expect(Object.getOwnPropertyDescriptor(options, 'headers')).toEqual(descriptor); expect(fetch).not.toHaveBeenCalled(); }); From 93c4ee33c9e61b45c42d29619cea3be13d66c6e2 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 25 Aug 2026 15:25:47 -0700 Subject: [PATCH 29/35] fix(azure): preserve accessor and cookie header semantics --- src/azure.ts | 6 +- src/internal/headers.ts | 56 +++++--- .../azure-credential-header-privacy.test.ts | 133 +++++++++++++++++- 3 files changed, 173 insertions(+), 22 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index 61641c2e5..e9726ee98 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -573,7 +573,11 @@ function snapshotAzureRequestHeadersAccessor( originalSetter.call(this, value); const current = latestSnapshot(); if (current !== undefined) { - current.headers = value; + try { + current.headers = descriptor.get?.call(this); + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } } }; snapshot = { descriptor, getter, inherited, snapshots }; diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 4b30f3423..9d77f4015 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -147,26 +147,7 @@ class DeferredAzureAuthenticationHeaders extends Headers { configurable: true, writable: true, value(this: DeferredAzureAuthenticationHeaders): 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; + return this.cookieValues(); }, }, has: { @@ -251,6 +232,29 @@ class DeferredAzureAuthenticationHeaders extends Headers { }); } + 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; @@ -304,8 +308,20 @@ class DeferredAzureAuthenticationHeaders extends Headers { } 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)); diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index 1031afe4e..18bb75d8d 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -1628,12 +1628,114 @@ describe('Azure credential header diagnostic privacy', () => { await client.request(options); - expect(reads).toBe(1); + 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], @@ -4614,7 +4716,35 @@ describe('Azure credential header diagnostic privacy', () => { }; 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') @@ -4636,6 +4766,7 @@ describe('Azure credential header diagnostic privacy', () => { } expect(carrier.values.getSetCookie()).toEqual(expected); + expectCookieIteration(); const detached = carrier.values.getSetCookie; expect(() => detached()).toThrow(TypeError); }; From b9d4baa2ad73be2ce0d0d6477fbf070c6a016259 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 26 Aug 2026 19:36:35 -0700 Subject: [PATCH 30/35] fix(azure): bind credential snapshots to each request --- src/azure.ts | 96 ++---- src/client.ts | 26 +- src/internal/headers.ts | 240 +++++++++++++-- .../azure-credential-header-accessors.test.ts | 287 ++++++++++++++++++ .../azure-credential-header-privacy.test.ts | 6 +- 5 files changed, 541 insertions(+), 114 deletions(-) diff --git a/src/azure.ts b/src/azure.ts index 8320992e7..e418e1a4d 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -4,6 +4,7 @@ import { buildAzureAuthenticationHeaders, buildHeaders, protectAzureRequestHeaders, + withAzureRequestHeaderSnapshot, } from './internal/headers'; import * as Errors from './error'; import type { FinalRequestOptions } from './internal/request-options'; @@ -193,25 +194,31 @@ export class AzureOpenAI extends OpenAI { try { protection = preprocessesHeaders ? protectAzureRequestHeaders(headers, options) : undefined; - const restoreAuthentication = - protection !== undefined || requestOptions !== options - ? snapshotAzureRequestAuthentication( - this, - this.authHeaders, - protection, - requestOptions === options ? undefined : options, - ) - : undefined; let pending: ReturnType; let restoreBody: (() => void) | undefined; try { restoreBody = snapshotAzureRequestBodyAccessor(options); - pending = super.buildRequest(requestOptions, props); + const requestHeaders = (): FinalRequestOptions['headers'] => { + if (accessorEntry !== undefined) { + return accessorEntry.headers; + } + if (requestOptions === options) { + return options.headers; + } + return headers; + }; + pending = withAzureRequestHeaderSnapshot( + this, + requestOptions, + options, + requestHeaders, + protection, + () => super.buildRequest(requestOptions, props), + ); } finally { try { restoreBody?.(); } finally { - restoreAuthentication?.(); protection?.deactivate(); } } @@ -339,13 +346,6 @@ function shouldProtectAzureRequestHeaders(options: FinalRequestOptions): boolean } } -type AzureAuthenticationHook = ( - options: FinalRequestOptions, - schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, -) => Promise; - -const azureRequestAuthenticationOriginals = new WeakMap(); - function snapshotAzureRequestBodyAccessor(options: FinalRequestOptions): (() => void) | undefined { let descriptor: PropertyDescriptor | undefined; try { @@ -398,66 +398,6 @@ function snapshotAzureRequestBodyAccessor(options: FinalRequestOptions): (() => }; } -function snapshotAzureRequestAuthentication( - client: AzureOpenAI, - authenticate: AzureAuthenticationHook, - protection: ReturnType, - originalOptions?: FinalRequestOptions, -): () => void { - const descriptor = Object.getOwnPropertyDescriptor(client, 'authHeaders'); - const replaceable = - descriptor === undefined - ? Object.isExtensible(client) - : descriptor.configurable || ('value' in descriptor && descriptor.writable); - if (!replaceable) { - if (descriptor === undefined) { - return () => { - // Nonextensible clients retain their existing inherited authentication hook. - }; - } - throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); - } - - const original = azureRequestAuthenticationOriginals.get(authenticate) ?? authenticate; - const snapshot = async ( - options: FinalRequestOptions, - schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, - ): Promise => { - restore(); - const carrier = await original.call(client, originalOptions ?? options, schemes); - return carrier === undefined || protection === undefined ? carrier : protection.bind(carrier); - }; - azureRequestAuthenticationOriginals.set(snapshot, original); - const restore = (): void => { - if (Object.getOwnPropertyDescriptor(client, 'authHeaders')?.value !== snapshot) { - return; - } - if (descriptor !== undefined) { - Object.defineProperty(client, 'authHeaders', descriptor); - return; - } - if (!Reflect.deleteProperty(client, 'authHeaders')) { - throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); - } - }; - try { - const temporary = - descriptor !== undefined && 'value' in descriptor - ? { ...descriptor, value: snapshot } - : { - configurable: descriptor?.configurable ?? true, - enumerable: descriptor?.enumerable ?? false, - value: snapshot, - writable: true, - }; - Object.defineProperty(client, 'authHeaders', temporary); - } catch { - throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); - } - - return restore; -} - interface AzureRequestHeadersAccessorSnapshot { descriptor: PropertyDescriptor; getter: () => FinalRequestOptions['headers']; diff --git a/src/client.ts b/src/client.ts index a43437538..69f62a0e8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -251,7 +251,12 @@ 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, + captureAzureRequestHeaderSnapshot, +} 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'; @@ -1686,6 +1691,7 @@ export class OpenAI { inputOptions: FinalRequestOptions, { retryCount = 0 }: { retryCount?: number } = {}, ): Promise<{ req: FinalizedRequestInit; url: string; timeout: number }> { + const azureRequestHeaders = captureAzureRequestHeaderSnapshot(this, inputOptions); if (this.#x509Authentication && !this.#x509Authentication.inRequest(this)) { const authentication = this.#x509Authentication; return await authentication.runRequest(async () => { @@ -1741,6 +1747,7 @@ export class OpenAI { method, bodyHeaders, retryCount, + azureRequestHeaders, x509Headers, x509Timeout: explicitTimeout ? options.timeout : undefined, x509Tenant, @@ -1765,6 +1772,7 @@ export class OpenAI { method, bodyHeaders, retryCount, + azureRequestHeaders, x509Headers, x509Timeout, x509Tenant, @@ -1773,6 +1781,7 @@ export class OpenAI { method: HTTPMethod; bodyHeaders: HeadersLike; retryCount: number; + azureRequestHeaders: ReturnType; x509Headers?: { defaultHeaders: NullableHeaders; requestHeaders: NullableHeaders } | undefined; x509Timeout: number | undefined; x509Tenant?: { organization: string | null; project: string | null } | undefined; @@ -1799,10 +1808,21 @@ export class OpenAI { }, this._provider || this.#x509Authentication?.isPlanningRequest() ? undefined - : await this.authHeaders(options, options.__security ?? { bearerAuth: true }), + : azureRequestHeaders + ? azureRequestHeaders.bindAuthentication( + await this.authHeaders( + azureRequestHeaders.authenticationOptions, + options.__security ?? { bearerAuth: true }, + ), + ) + : await this.authHeaders(options, options.__security ?? { bearerAuth: true }), x509Headers?.defaultHeaders ?? this._options.defaultHeaders, bodyHeaders, - x509Headers?.requestHeaders ?? options.headers, + x509Headers + ? x509Headers.requestHeaders + : azureRequestHeaders + ? azureRequestHeaders.headers() + : options.headers, ]); if (!this._provider && !this.#x509Authentication?.isPlanningRequest()) { diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 9d77f4015..766358c61 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -1,4 +1,5 @@ import { assertAzureCredentialHeaderValue, isAzureAuthenticationHeader } from './azure'; +import type { FinalRequestOptions } from './request-options'; import { isReadonlyArray } from './utils/values'; type HeaderValue = string | undefined | null; @@ -56,6 +57,15 @@ type AzureRequestHeaderProtection = { deactivate: () => void; release: () => void; }; +type AzureRequestHeaderSnapshot = { + authenticationOptions: FinalRequestOptions; + bindAuthentication: (carrier: NullableHeaders | undefined) => NullableHeaders | undefined; + headers: () => HeadersLike; +}; +type AzureRequestHeaderSnapshotContext = { + client: object; + snapshot: AzureRequestHeaderSnapshot; +}; // Object-identity branding cannot be forged by caller-provided header records. const azureAuthenticationHeaders = new WeakMap(); @@ -78,6 +88,7 @@ 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, @@ -625,6 +636,48 @@ export const protectAzureRequestHeaders = ( return { bind, deactivate, release }; }; +/** Captures a request-local Azure header capability before asynchronous authentication starts. */ +export const captureAzureRequestHeaderSnapshot = ( + 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 = { + authenticationOptions, + bindAuthentication: (carrier) => + 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; @@ -648,6 +701,23 @@ const matchesAzureRequestHeaders = ( return false; }; +function* iterateHeaderRecord( + headers: Record, +): IterableIterator { + for (const name of 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): IterableIterator { if (!headers) return; @@ -719,17 +789,7 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator { - try { - const record = headers as Record; - return [name, record[name]] as const; - } catch (error) { - if (isAzureAuthenticationHeader(name)) { - throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); - } - throw error; - } - }); + iter = iterateHeaderRecord(headers as Record); } for (let row of iter) { const name = row[0]; @@ -759,28 +819,119 @@ export const assertAzureAuthenticationHeaders = (headers: HeadersLike): void => } }; -const findUnboundAzureRequestRegistration = ( - headers: HeadersLike[], -): AzureRequestHeaderRegistration | undefined => { - let registration: AzureRequestHeaderRegistration | undefined; +const assertNoUnboundAzureRequestRegistration = (headers: HeadersLike[]): void => { for (const source of headers) { - if (source === null || typeof source !== 'object') { - continue; + 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): boolean => { + const descriptor = 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 overridesAzureAuthenticationHeader = (headers: HeadersLike, name: string): 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 mutations = azureAuthenticationHeaderMutations.get(carrier.values); + const mutation = mutations?.get(name); + if (mutation?.kind === 'replace' || mutation?.kind === 'append') { + return true; + } + if (mutation?.kind === 'delete') { + return false; + } + for (const layer of azureAuthenticationHeaders.get(carrier) ?? []) { + if (overridesAzureAuthenticationHeader(layer, name)) { + 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)) { + return headers.some((entry) => { + const value = entry[1]; + return ( + typeof entry[0] === 'string' && + entry[0].toLowerCase() === name && + (isReadonlyArray(value) ? value.some((candidate) => candidate !== undefined) : value !== undefined) + ); + }); + } + + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === name && overridesAzureAuthenticationRecordValue(headers, key)) { + return true; } - const active = azureRequestHeaders.get(source); - if (active === undefined) { + } + return false; +}; + +const hasRemainingAzureAuthenticationOverride = ( + headers: HeadersLike, + current: string, + name: string, +): boolean => { + if ( + headers === undefined || + headers === null || + typeof headers !== 'object' || + brand_privateNullableHeaders in headers || + headers instanceof Headers || + isReadonlyArray(headers) + ) { + return false; + } + + let found = false; + for (const key of Object.keys(headers)) { + if (!found) { + found = key === current; continue; } - if (active.registrations.size !== 1) { - throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + if (key.toLowerCase() === name && overridesAzureAuthenticationRecordValue(headers, key)) { + return true; } - const [candidate] = active.registrations; - if (registration !== undefined && registration !== candidate) { - throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + return false; +}; + +const hasLaterAzureAuthenticationOverride = ( + sources: HeadersLike[], + index: number, + name: string, +): boolean => { + for (let candidate = index + 1; candidate < sources.length; candidate += 1) { + if (overridesAzureAuthenticationHeader(sources[candidate], name)) { + return true; } - registration = candidate; } - return registration; + return false; }; const buildHeadersWithRegistration = ( @@ -805,18 +956,21 @@ const buildHeadersWithRegistration = ( protectsAzureCredentials = true; requestRegistration = azureRequestAuthenticationHeaders.get(headers as NullableHeaders) ?? - azureRequestAuthenticationHeaders.get(carrier) ?? - findUnboundAzureRequestRegistration(newHeaders); + azureRequestAuthenticationHeaders.get(carrier); if (requestRegistration) { break; } } + if (protectsAzureCredentials && requestRegistration === undefined) { + assertNoUnboundAzureRequestRegistration(newHeaders); + } } const targetHeaders = new Headers(); const nullHeaders = new Set(); const pendingAuthenticationHeaders = new Map(); - for (const source of newHeaders) { + for (let sourceIndex = 0; sourceIndex < newHeaders.length; sourceIndex += 1) { + const source = newHeaders[sourceIndex]; const seenHeaders = new Set(); const headers = protectsAzureCredentials && @@ -824,6 +978,13 @@ const buildHeadersWithRegistration = ( 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)) { + unprovenAuthenticationHeaders.set(name, mutable); + } + } for (const [name, value] of iterateHeaders(headers)) { if (!httpTokenHeaderName.test(name)) { throw new TypeError(`Header name must be a valid HTTP token ["${name}"]`); @@ -845,11 +1006,23 @@ const buildHeadersWithRegistration = ( nullHeaders.add(lowerName); } else { if (deferAuthenticationHeader) { + let snapshot = value; + if (typeof value !== 'string') { + const shadowedHere = hasRemainingAzureAuthenticationOverride(headers, name, lowerName); + if (shadowedHere) { + const uncertain = unprovenAuthenticationHeaders.get(lowerName) ?? []; + uncertain.push(value); + unprovenAuthenticationHeaders.set(lowerName, uncertain); + } + if (!shadowedHere && !hasLaterAzureAuthenticationOverride(newHeaders, sourceIndex, lowerName)) { + snapshot = coerceAzureCredentialHeaderValue(value); + } + } const pending = pendingAuthenticationHeaders.get(lowerName); if (pending) { - pending.push(value); + pending.push(snapshot); } else { - pendingAuthenticationHeaders.set(lowerName, [value]); + pendingAuthenticationHeaders.set(lowerName, [snapshot]); } } else { targetHeaders.append(lowerName, value); @@ -857,6 +1030,11 @@ const buildHeadersWithRegistration = ( 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) => { diff --git a/tests/lib/azure-credential-header-accessors.test.ts b/tests/lib/azure-credential-header-accessors.test.ts index 7087c1959..292f15aa9 100644 --- a/tests/lib/azure-credential-header-accessors.test.ts +++ b/tests/lib/azure-credential-header-accessors.test.ts @@ -3,6 +3,11 @@ 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'; @@ -298,6 +303,288 @@ describe('Azure immutable request-header accessors', () => { 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}`); diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index 18bb75d8d..985371907 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -5111,7 +5111,7 @@ describe('Azure deferred credential and request registration regressions', () => expect(fetch).toHaveBeenCalledTimes(1); }); - test('fails closed for concurrent nonextensible requests sharing tenant headers', async () => { + test('isolates concurrent nonextensible requests sharing tenant headers', async () => { const releases: (() => void)[] = []; const client = new ProtectedHookAzure({ baseURL: BASE_URL, @@ -5139,10 +5139,12 @@ describe('Azure deferred credential and request registration regressions', () => expect(releases).toHaveLength(2); releases[0]?.(); - await expect(first).rejects.toEqual(new TypeError(SAFE_ERROR)); + 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 () => { From 9c7a9304c5b93a04de529912a592e109a25edc95 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 26 Aug 2026 20:14:41 -0700 Subject: [PATCH 31/35] fix(azure): isolate request-local credential capabilities --- src/azure.ts | 169 +++- src/client.ts | 26 +- src/internal/headers.ts | 242 +++++- .../azure-request-header-capability.test.ts | 736 ++++++++++++++++++ 4 files changed, 1113 insertions(+), 60 deletions(-) create mode 100644 tests/lib/azure-request-header-capability.test.ts diff --git a/src/azure.ts b/src/azure.ts index e418e1a4d..10e4adc19 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -3,6 +3,7 @@ import type { NullableHeaders } from './internal/headers'; import { buildAzureAuthenticationHeaders, buildHeaders, + materializeAzureAuthenticationHeaders, protectAzureRequestHeaders, withAzureRequestHeaderSnapshot, } from './internal/headers'; @@ -185,6 +186,7 @@ export class AzureOpenAI extends OpenAI { copied, headers, options: requestOptions = options, + resolve, restore, } = snapshotAzureRequestOptionsHeaders(options); const accessorSnapshot = azureRequestHeadersAccessorSnapshots.get(options); @@ -193,12 +195,18 @@ export class AzureOpenAI extends OpenAI { let protection: ReturnType; try { - protection = preprocessesHeaders ? protectAzureRequestHeaders(headers, options) : undefined; + protection = protectAzureRequestHeaders(headers, options); + if (!preprocessesHeaders) { + protection?.deactivate(); + } let pending: ReturnType; let restoreBody: (() => void) | undefined; try { restoreBody = snapshotAzureRequestBodyAccessor(options); const requestHeaders = (): FinalRequestOptions['headers'] => { + if (resolve !== undefined) { + return resolve(); + } if (accessorEntry !== undefined) { return accessorEntry.headers; } @@ -268,12 +276,16 @@ export class AzureOpenAI extends OpenAI { ): Promise { const security = schemes ?? { bearerAuth: true, adminAPIKeyAuth: true }; if (security.bearerAuth && typeof this._options.apiKey === 'string') { - return buildAzureAuthenticationHeaders([['api-key', this.apiKey]]); + return materializeAzureAuthenticationHeaders( + buildAzureAuthenticationHeaders([['api-key', this.apiKey]]), + ); } - return buildAzureAuthenticationHeaders( - security.bearerAuth ? await this.bearerAuth(opts) : undefined, - security.adminAPIKeyAuth ? await this.adminAPIKeyAuth(opts) : undefined, + return materializeAzureAuthenticationHeaders( + buildAzureAuthenticationHeaders( + security.bearerAuth ? await this.bearerAuth(opts) : undefined, + security.adminAPIKeyAuth ? await this.adminAPIKeyAuth(opts) : undefined, + ), ); } @@ -281,14 +293,18 @@ export class AzureOpenAI extends OpenAI { if (this.apiKey === null || this.apiKey === undefined) { return undefined; } - return buildAzureAuthenticationHeaders([['Authorization', `Bearer ${this.apiKey}`]]); + return materializeAzureAuthenticationHeaders( + buildAzureAuthenticationHeaders([['Authorization', `Bearer ${this.apiKey}`]]), + ); } protected override async adminAPIKeyAuth(_opts: FinalRequestOptions): Promise { if (this.adminAPIKey === null || this.adminAPIKey === undefined) { return undefined; } - return buildAzureAuthenticationHeaders([['Authorization', `Bearer ${this.adminAPIKey}`]]); + return materializeAzureAuthenticationHeaders( + buildAzureAuthenticationHeaders([['Authorization', `Bearer ${this.adminAPIKey}`]]), + ); } } @@ -405,10 +421,26 @@ interface AzureRequestHeadersAccessorSnapshot { 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, @@ -448,12 +480,9 @@ function findAzureRequestHeadersDescriptor(options: FinalRequestOptions): return undefined; } -function snapshotAzureRequestOptionsHeaders(options: FinalRequestOptions): { - copied?: { value: boolean }; - headers: FinalRequestOptions['headers']; - options?: FinalRequestOptions; - restore?: () => void; -} { +function snapshotAzureRequestOptionsHeaders( + options: FinalRequestOptions, +): AzureRequestOptionsHeadersSnapshot { try { let active = azureRequestHeadersAccessorSnapshots.get(options); if ( @@ -467,27 +496,21 @@ function snapshotAzureRequestOptionsHeaders(options: FinalRequestOptions): { const found = active ?? findAzureRequestHeadersDescriptor(options); const descriptor = found?.descriptor; if (descriptor === undefined || 'value' in descriptor) { - return { headers: options.headers }; + return snapshotAzureRequestDataHeaders(options, descriptor, found?.inherited === true); } if (found?.inherited ? !Object.isExtensible(options) : !descriptor.configurable) { const { headers } = options; const copied = { value: false }; - const requestOptions = 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); - }, - }); + 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 }), }; } @@ -498,6 +521,95 @@ function snapshotAzureRequestOptionsHeaders(options: FinalRequestOptions): { } } +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 || descriptor.value === headers; + + return { + ...(descriptor?.enumerable && !inherited ? { copied } : {}), + headers, + options: requestOptions, + resolve: () => { + if (!stable) { + return headers; + } + 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: () => { + if (snapshot.contended) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + try { + return descriptor.get === undefined ? headers : descriptor.get.call(options); + } 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'], @@ -523,8 +635,11 @@ function snapshotAzureRequestHeadersAccessor( 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); - const current = latestSnapshot(); if (current !== undefined) { try { current.headers = descriptor.get?.call(this); diff --git a/src/client.ts b/src/client.ts index 69f62a0e8..1b8e15830 100644 --- a/src/client.ts +++ b/src/client.ts @@ -251,12 +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, - captureAzureRequestHeaderSnapshot, -} 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'; @@ -1691,7 +1686,6 @@ export class OpenAI { inputOptions: FinalRequestOptions, { retryCount = 0 }: { retryCount?: number } = {}, ): Promise<{ req: FinalizedRequestInit; url: string; timeout: number }> { - const azureRequestHeaders = captureAzureRequestHeaderSnapshot(this, inputOptions); if (this.#x509Authentication && !this.#x509Authentication.inRequest(this)) { const authentication = this.#x509Authentication; return await authentication.runRequest(async () => { @@ -1747,7 +1741,6 @@ export class OpenAI { method, bodyHeaders, retryCount, - azureRequestHeaders, x509Headers, x509Timeout: explicitTimeout ? options.timeout : undefined, x509Tenant, @@ -1772,7 +1765,6 @@ export class OpenAI { method, bodyHeaders, retryCount, - azureRequestHeaders, x509Headers, x509Timeout, x509Tenant, @@ -1781,11 +1773,11 @@ export class OpenAI { method: HTTPMethod; bodyHeaders: HeadersLike; retryCount: number; - azureRequestHeaders: ReturnType; x509Headers?: { defaultHeaders: NullableHeaders; requestHeaders: NullableHeaders } | undefined; 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(); @@ -1809,20 +1801,14 @@ export class OpenAI { this._provider || this.#x509Authentication?.isPlanningRequest() ? undefined : azureRequestHeaders - ? azureRequestHeaders.bindAuthentication( - await this.authHeaders( - azureRequestHeaders.authenticationOptions, - options.__security ?? { bearerAuth: true }, - ), + ? await azureRequestHeaders.authenticate( + this.authHeaders, + options.__security ?? { bearerAuth: true }, ) : await this.authHeaders(options, options.__security ?? { bearerAuth: true }), x509Headers?.defaultHeaders ?? this._options.defaultHeaders, bodyHeaders, - x509Headers - ? x509Headers.requestHeaders - : azureRequestHeaders - ? azureRequestHeaders.headers() - : options.headers, + x509Headers?.requestHeaders ?? (azureRequestHeaders ? azureRequestHeaders.headers() : options.headers), ]); if (!this._provider && !this.#x509Authentication?.isPlanningRequest()) { diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 766358c61..a5b565b27 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -44,6 +44,7 @@ type AzureRequestHeaderMarker = { }; type AzureRequestHeaderRegistration = { carrier: NullableHeaders; + credentialSnapshots: WeakMap>; headers: object; owner: object | undefined; }; @@ -56,10 +57,16 @@ type AzureRequestHeaderProtection = { bind: (carrier: NullableHeaders) => NullableHeaders; deactivate: () => void; release: () => void; + snapshot: () => void; }; type AzureRequestHeaderSnapshot = { - authenticationOptions: FinalRequestOptions; - bindAuthentication: (carrier: NullableHeaders | undefined) => NullableHeaders | undefined; + authenticate: ( + authentication: ( + options: FinalRequestOptions, + schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, + ) => Promise, + schemes: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, + ) => Promise; headers: () => HeadersLike; }; type AzureRequestHeaderSnapshotContext = { @@ -78,6 +85,9 @@ 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, @@ -92,13 +102,29 @@ const azureRequestHeaderSnapshots = new WeakMap | undefined => { const headers = azureAuthenticationHeaders.get(carrier); if (headers === undefined) return undefined; let layers = azureAuthenticationHeaderSnapshots.get(carrier); if (!layers) { - layers = Object.freeze(headers.map((layer) => Object.freeze([...iterateHeaders(layer)]))); + layers = Object.freeze( + headers.map((layer) => + Object.freeze( + Array.from(iterateHeaders(layer), ([name, value]) => { + const normalized = name.toLowerCase(); + return registration !== undefined && + value !== null && + typeof value !== 'string' && + isAzureAuthenticationHeader(normalized) && + !hasRemainingAzureAuthenticationOverride(layer, name, normalized) + ? ([name, snapshotAzureRequestCredentialHeaderValue(registration, normalized, value)] as const) + : ([name, value] as const); + }), + ), + ), + ); azureAuthenticationHeaderSnapshots.set(carrier, layers); } return layers; @@ -112,6 +138,28 @@ const coerceAzureCredentialHeaderValue = (value: unknown): string => { } }; +const snapshotAzureRequestCredentialHeaderValue = ( + registration: AzureRequestHeaderRegistration, + name: string, + value: unknown, +): string => { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + return coerceAzureCredentialHeaderValue(value); + } + + let snapshots = registration.credentialSnapshots.get(value); + if (snapshots === undefined) { + snapshots = new Map(); + registration.credentialSnapshots.set(value, snapshots); + } + let snapshot = snapshots.get(name); + if (snapshot === undefined) { + snapshot = coerceAzureCredentialHeaderValue(value); + snapshots.set(name, snapshot); + } + return snapshot; +}; + const invalidateAzureAuthenticationHeaderIterators = (headers: Headers): void => { const version = azureAuthenticationHeaderMutationVersions.get(headers) ?? 0; azureAuthenticationHeaderMutationVersions.set(headers, version + 1); @@ -141,6 +189,7 @@ class DeferredAzureAuthenticationHeaders extends Headers { constructor() { super(); azureAuthenticationHeaderMutations.set(this, new Map()); + azureAuthenticationMutationNativeValues.set(this, new Map()); } static { @@ -235,6 +284,7 @@ class DeferredAzureAuthenticationHeaders extends Headers { 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); } @@ -344,6 +394,15 @@ class DeferredAzureAuthenticationHeaders extends Headers { 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 }; @@ -412,6 +471,9 @@ class DeferredAzureAuthenticationHeaders extends Headers { kind, values: authentication ? [...(previousValues ?? []), normalizedValue] : [], }); + azureAuthenticationMutationNativeValues + .get(this) + ?.set(normalized, Headers.prototype.get.call(this, normalized)); const unchanged = operation === 'replace' && previousValue !== null && @@ -568,6 +630,66 @@ export const buildAzureAuthenticationHeaders = (...headers: AzureAuthenticationV 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, @@ -587,8 +709,9 @@ export const protectAzureRequestHeaders = ( azureRequestHeaders.set(headers, registrations); } const activeRegistrations = registrations; - const activeRegistration = { + const activeRegistration: AzureRequestHeaderRegistration = { carrier: buildAzureAuthenticationHeaders(headers), + credentialSnapshots: new WeakMap(), headers, owner, }; @@ -633,11 +756,49 @@ export const protectAzureRequestHeaders = ( return isolated; }; - return { bind, deactivate, release }; + return { + bind, + deactivate, + release, + snapshot: () => { + if ( + overridesAzureAuthenticationHeader(headers, 'api-key') || + overridesAzureAuthenticationHeader(headers, 'authorization') + ) { + 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 [name, values] of effective) { + for (const value of values) { + if (typeof value !== 'string') { + snapshotAzureRequestCredentialHeaderValue(activeRegistration, name, value); + } + } + } + } + }, + }; }; /** Captures a request-local Azure header capability before asynchronous authentication starts. */ -export const captureAzureRequestHeaderSnapshot = ( +export const captureAzureHeaders = ( client: object, options: FinalRequestOptions, ): AzureRequestHeaderSnapshot | undefined => { @@ -662,9 +823,11 @@ export const withAzureRequestHeaderSnapshot = ( } const contexts = active; const snapshot: AzureRequestHeaderSnapshot = { - authenticationOptions, - bindAuthentication: (carrier) => - carrier === undefined || protection === undefined ? carrier : protection.bind(carrier), + 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 }); @@ -728,6 +891,8 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator name.toLowerCase())); const mutations = azureAuthenticationHeaderMutations.get(values); + const materialized = azureAuthenticationMaterializedHeaders.get(values); + const nativeMutationValues = azureAuthenticationMutationNativeValues.get(values); const layers = snapshotAzureAuthenticationHeaders( azureAuthenticationHeaderCarriers.get(values) ?? headers, ); @@ -739,6 +904,7 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator 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('isolates concurrent public requests sharing virtualized configurable data options', 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 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('second-tenant-token'); + expect(secondHeaders.get('x-tenant')).toBe('second'); + expect(firstHeaders.get('api-key')).toBe('first-tenant-token'); + expect(firstHeaders.get('x-tenant')).toBe('first'); + expect(reads).toBe(2); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + 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(); + }); +}); From 828d89191edb0bb43590149200e58deed58fb6a5 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 26 Aug 2026 20:37:45 -0700 Subject: [PATCH 32/35] fix(azure): bind credential snapshots to request occurrences --- src/azure.ts | 42 ++- src/internal/headers.ts | 311 ++++++++++++---- .../azure-credential-header-privacy.test.ts | 2 +- tests/lib/azure-pr2421-carrier-review.test.ts | 277 +++++++++++++++ .../azure-pr2421-occurrence-review.test.ts | 304 ++++++++++++++++ tests/lib/azure-pr2421-options-review.test.ts | 334 ++++++++++++++++++ .../azure-request-header-capability.test.ts | 11 +- 7 files changed, 1189 insertions(+), 92 deletions(-) create mode 100644 tests/lib/azure-pr2421-carrier-review.test.ts create mode 100644 tests/lib/azure-pr2421-occurrence-review.test.ts create mode 100644 tests/lib/azure-pr2421-options-review.test.ts diff --git a/src/azure.ts b/src/azure.ts index 10e4adc19..9b86af6cc 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -208,7 +208,24 @@ export class AzureOpenAI extends OpenAI { return resolve(); } if (accessorEntry !== undefined) { - return accessorEntry.headers; + 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; @@ -353,10 +370,11 @@ function shouldProtectAzureRequestHeaders(options: FinalRequestOptions): boolean if (typeof descriptor?.get === 'function') { return true; } + if (descriptor === undefined && owner !== null) { + return true; + } const { body } = options; - return ( - (descriptor === undefined && owner !== null) || (body === undefined ? 'body' in options : Boolean(body)) - ); + return body === undefined ? 'body' in options : Boolean(body); } catch { throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } @@ -418,6 +436,7 @@ interface AzureRequestHeadersAccessorSnapshot { descriptor: PropertyDescriptor; getter: () => FinalRequestOptions['headers']; inherited: boolean; + invalidated: boolean; snapshots: { copied: boolean; headers: FinalRequestOptions['headers'] }[]; } @@ -529,7 +548,7 @@ function snapshotAzureRequestDataHeaders( const { headers } = options; const copied = { value: false }; const requestOptions = snapshotAzureRequestOptions(options, headers, copied); - const stable = descriptor === undefined || descriptor.value === headers; + const stable = descriptor === undefined ? headers === undefined : descriptor.value === headers; return { ...(descriptor?.enumerable && !inherited ? { copied } : {}), @@ -537,7 +556,7 @@ function snapshotAzureRequestDataHeaders( options: requestOptions, resolve: () => { if (!stable) { - return headers; + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); } try { const current = Object.getOwnPropertyDescriptor(options, 'headers'); @@ -592,11 +611,12 @@ function snapshotAzureImmutableRequestHeaders( return { resolve: () => { - if (snapshot.contended) { - throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); - } try { - return descriptor.get === undefined ? headers : descriptor.get.call(options); + 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.'); } @@ -648,7 +668,7 @@ function snapshotAzureRequestHeadersAccessor( } } }; - snapshot = { descriptor, getter, inherited, snapshots }; + snapshot = { descriptor, getter, inherited, invalidated: false, snapshots }; azureRequestHeadersAccessorSnapshots.set(options, snapshot); try { Object.defineProperty(options, 'headers', { diff --git a/src/internal/headers.ts b/src/internal/headers.ts index a5b565b27..274e107f4 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -31,6 +31,10 @@ export type NullableHeaders = { type AzureAuthenticationValues = ReadonlyArray; type AzureAuthenticationLayer = ReadonlyArray; +type AzureAuthenticationRecordSnapshot = { + descriptors: Record; + keys: readonly string[]; +}; type AzureAuthenticationHeaderMutation = { kind: 'append' | 'replace' | 'delete'; values: string[]; @@ -44,9 +48,9 @@ type AzureRequestHeaderMarker = { }; type AzureRequestHeaderRegistration = { carrier: NullableHeaders; - credentialSnapshots: WeakMap>; headers: object; owner: object | undefined; + record: AzureAuthenticationRecordSnapshot | undefined; }; type AzureRequestHeaderRegistrations = { references: number; @@ -81,6 +85,10 @@ const azureAuthenticationHeaderSnapshots = new WeakMap< NullableHeaders, ReadonlyArray >(); +const azureAuthenticationHeaderRecordSnapshots = new WeakMap< + NullableHeaders, + WeakMap +>(); const azureAuthenticationHeaderMutations = new WeakMap< Headers, Map @@ -106,24 +114,26 @@ const snapshotAzureAuthenticationHeaders = ( ): 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) => - Object.freeze( - Array.from(iterateHeaders(layer), ([name, value]) => { + 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) - ? ([name, snapshotAzureRequestCredentialHeaderValue(registration, normalized, value)] as const) + !hasRemainingAzureAuthenticationOverride(layer, name, normalized, registration, record) + ? ([name, coerceAzureCredentialHeaderValue(value)] as const) : ([name, value] as const); }), - ), - ), + ); + }), ); azureAuthenticationHeaderSnapshots.set(carrier, layers); } @@ -138,28 +148,6 @@ const coerceAzureCredentialHeaderValue = (value: unknown): string => { } }; -const snapshotAzureRequestCredentialHeaderValue = ( - registration: AzureRequestHeaderRegistration, - name: string, - value: unknown, -): string => { - if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { - return coerceAzureCredentialHeaderValue(value); - } - - let snapshots = registration.credentialSnapshots.get(value); - if (snapshots === undefined) { - snapshots = new Map(); - registration.credentialSnapshots.set(value, snapshots); - } - let snapshot = snapshots.get(name); - if (snapshot === undefined) { - snapshot = coerceAzureCredentialHeaderValue(value); - snapshots.set(name, snapshot); - } - return snapshot; -}; - const invalidateAzureAuthenticationHeaderIterators = (headers: Headers): void => { const version = azureAuthenticationHeaderMutationVersions.get(headers) ?? 0; azureAuthenticationHeaderMutationVersions.set(headers, version + 1); @@ -523,16 +511,36 @@ class DeferredAzureAuthenticationNulls extends Set { }; } - private initialize(): void { - if (this.initialized) return; - this.initialized = true; + 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); @@ -619,14 +627,73 @@ class DeferredAzureAuthenticationNulls extends Set { * 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: new DeferredAzureAuthenticationNulls(), + 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 = row[0]; + 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; }; @@ -709,12 +776,14 @@ export const protectAzureRequestHeaders = ( azureRequestHeaders.set(headers, registrations); } const activeRegistrations = registrations; + const carrier = buildAzureAuthenticationHeaders(headers); const activeRegistration: AzureRequestHeaderRegistration = { - carrier: buildAzureAuthenticationHeaders(headers), - credentialSnapshots: new WeakMap(), + carrier, headers, owner, + record: azureAuthenticationHeaderRecordSnapshots.get(carrier)?.get(headers), }; + azureRequestAuthenticationHeaders.set(carrier, activeRegistration); activeRegistrations.references += 1; activeRegistrations.registrations.add(activeRegistration); const marker: AzureRequestHeaderMarker = { @@ -762,8 +831,8 @@ export const protectAzureRequestHeaders = ( release, snapshot: () => { if ( - overridesAzureAuthenticationHeader(headers, 'api-key') || - overridesAzureAuthenticationHeader(headers, 'authorization') + overridesAzureAuthenticationHeader(headers, 'api-key', activeRegistration) || + overridesAzureAuthenticationHeader(headers, 'authorization', activeRegistration) ) { const effective = new Map(); for (const layer of snapshotAzureAuthenticationHeaders( @@ -788,7 +857,7 @@ export const protectAzureRequestHeaders = ( for (const [name, values] of effective) { for (const value of values) { if (typeof value !== 'string') { - snapshotAzureRequestCredentialHeaderValue(activeRegistration, name, value); + coerceAzureCredentialHeaderValue(value); } } } @@ -866,8 +935,9 @@ const matchesAzureRequestHeaders = ( function* iterateHeaderRecord( headers: Record, + record?: AzureAuthenticationRecordSnapshot, ): IterableIterator { - for (const name of Object.keys(headers)) { + for (const name of record?.keys ?? Object.keys(headers)) { let value: HeaderValue | readonly HeaderValue[]; try { value = headers[name]; @@ -881,7 +951,11 @@ function* iterateHeaderRecord( } } -function* iterateHeaders(headers: HeadersLike): IterableIterator { +function* iterateHeaders( + headers: HeadersLike, + registration?: AzureRequestHeaderRegistration, + record?: AzureAuthenticationRecordSnapshot, +): IterableIterator { if (!headers) return; if (brand_privateNullableHeaders in headers) { @@ -893,13 +967,17 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator(); - for (const [name, value] of layer) { + const refreshed = new Set(); + for (const [name, snapshot] of layer) { const normalized = name.toLowerCase(); const mutation = mutations?.get(normalized); if ( @@ -915,7 +993,25 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator + )[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]; } } } @@ -977,7 +1073,10 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator); + iter = iterateHeaderRecord( + headers as Record, + record ?? (registration?.headers === headers ? registration.record : undefined), + ); } for (let row of iter) { const name = row[0]; @@ -1015,8 +1114,12 @@ const assertNoUnboundAzureRequestRegistration = (headers: HeadersLike[]): void = } }; -const overridesAzureAuthenticationRecordValue = (headers: object, key: string): boolean => { - const descriptor = Object.getOwnPropertyDescriptor(headers, key); +const overridesAzureAuthenticationRecordValue = ( + headers: object, + key: string, + record?: AzureAuthenticationRecordSnapshot, +): boolean => { + const descriptor = record?.descriptors[key] ?? Object.getOwnPropertyDescriptor(headers, key); if (descriptor === undefined) { return false; } @@ -1027,7 +1130,28 @@ const overridesAzureAuthenticationRecordValue = (headers: object, key: string): return Array.isArray(value) ? value.some((candidate) => candidate !== undefined) : value !== undefined; }; -const overridesAzureAuthenticationHeader = (headers: HeadersLike, name: string): boolean => { +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; } @@ -1037,6 +1161,8 @@ const overridesAzureAuthenticationHeader = (headers: HeadersLike, name: string): ? (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); @@ -1064,7 +1190,14 @@ const overridesAzureAuthenticationHeader = (headers: HeadersLike, name: string): ); } for (const layer of azureAuthenticationHeaders.get(carrier) ?? []) { - if (overridesAzureAuthenticationHeader(layer, name)) { + if ( + overridesAzureAuthenticationHeader( + layer, + name, + activeRegistration, + layer !== null && typeof layer === 'object' ? records?.get(layer) : undefined, + ) + ) { return true; } } @@ -1080,20 +1213,47 @@ const overridesAzureAuthenticationHeader = (headers: HeadersLike, name: string): } if (isReadonlyArray(headers)) { - return headers.some((entry) => { - const value = entry[1]; - return ( - typeof entry[0] === 'string' && - entry[0].toLowerCase() === name && - (isReadonlyArray(value) ? value.some((candidate) => candidate !== undefined) : value !== undefined) - ); - }); + try { + return headers.some((entry) => { + const candidate = entry[0]; + 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.'); + } } - for (const key of Object.keys(headers)) { - if (key.toLowerCase() === name && overridesAzureAuthenticationRecordValue(headers, key)) { - return true; + 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; }; @@ -1102,6 +1262,8 @@ const hasRemainingAzureAuthenticationOverride = ( headers: HeadersLike, current: string, name: string, + registration?: AzureRequestHeaderRegistration, + record?: AzureAuthenticationRecordSnapshot, ): boolean => { if ( headers === undefined || @@ -1115,12 +1277,13 @@ const hasRemainingAzureAuthenticationOverride = ( } let found = false; - for (const key of Object.keys(headers)) { + 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)) { + if (key.toLowerCase() === name && overridesAzureAuthenticationRecordValue(headers, key, snapshot)) { return true; } } @@ -1131,9 +1294,10 @@ 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)) { + if (overridesAzureAuthenticationHeader(sources[candidate], name, registration)) { return true; } } @@ -1187,14 +1351,14 @@ const buildHeadersWithRegistration = ( 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)) { + 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)) { + for (const [name, value] of iterateHeaders(headers, requestRegistration)) { if (!httpTokenHeaderName.test(name)) { throw new TypeError(`Header name must be a valid HTTP token ["${name}"]`); } @@ -1221,17 +1385,18 @@ const buildHeadersWithRegistration = ( 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)) { - snapshot = - requestRegistration !== undefined && headers === requestRegistration.carrier - ? snapshotAzureRequestCredentialHeaderValue(requestRegistration, lowerName, value) - : coerceAzureCredentialHeaderValue(value); + if ( + !shadowedHere && + !hasLaterAzureAuthenticationOverride(newHeaders, sourceIndex, lowerName, requestRegistration) + ) { + snapshot = coerceAzureCredentialHeaderValue(value); } } const pending = pendingAuthenticationHeaders.get(lowerName); diff --git a/tests/lib/azure-credential-header-privacy.test.ts b/tests/lib/azure-credential-header-privacy.test.ts index 985371907..a111ee5bf 100644 --- a/tests/lib/azure-credential-header-privacy.test.ts +++ b/tests/lib/azure-credential-header-privacy.test.ts @@ -1006,7 +1006,7 @@ describe('Azure credential header diagnostic privacy', () => { await expectPrivateCredentialFailure(() => client.request(options), credential); - expect(reads).toBe(depth === 'deep' ? 2 : 0); + expect(reads).toBe(0); expect(fetch).not.toHaveBeenCalled(); expect(provider).toHaveBeenCalledTimes(authentication === 'rotating-entra-token' ? 1 : 0); }, 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..5f8e93071 --- /dev/null +++ b/tests/lib/azure-pr2421-occurrence-review.test.ts @@ -0,0 +1,304 @@ +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 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(['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(); + }, + ); +}); 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 index a6e042c19..4e79fe9aa 100644 --- a/tests/lib/azure-request-header-capability.test.ts +++ b/tests/lib/azure-request-header-capability.test.ts @@ -511,7 +511,7 @@ describe('Azure request-local authentication header capabilities', () => { }, ); - test('isolates concurrent public requests sharing virtualized configurable data options', async () => { + 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' }, @@ -550,18 +550,15 @@ describe('Azure request-local authentication header capabilities', () => { expect(reads).toBe(2); gates[1]?.abort(); - await second; + await expectSanitizedFailure(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('second-tenant-token'); - expect(secondHeaders.get('x-tenant')).toBe('second'); + 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(2); + expect(fetch).toHaveBeenCalledTimes(1); }); test('snapshots conflicting shared GET credentials before asynchronous authentication interleaves', async () => { From fc4957f83b11ab8a1c3d9ef2b58fd100601099d1 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 26 Aug 2026 20:43:19 -0700 Subject: [PATCH 33/35] fix(azure): iterate effective credential values directly --- src/internal/headers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 274e107f4..96ef320e1 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -854,7 +854,7 @@ export const protectAzureRequestHeaders = ( } } } - for (const [name, values] of effective) { + for (const values of effective.values()) { for (const value of values) { if (typeof value !== 'string') { coerceAzureCredentialHeaderValue(value); From 8c9378a10e834904c78556e00a59702c3fda1d29 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 26 Aug 2026 20:48:57 -0700 Subject: [PATCH 34/35] fix(azure): inspect credential tuple names without invoking getters --- src/internal/headers.ts | 12 +- .../azure-pr2421-occurrence-review.test.ts | 168 ++++++++++++++++++ 2 files changed, 178 insertions(+), 2 deletions(-) diff --git a/src/internal/headers.ts b/src/internal/headers.ts index 96ef320e1..b4a5d7dc1 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -148,6 +148,14 @@ const coerceAzureCredentialHeaderValue = (value: unknown): string => { } }; +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); @@ -662,7 +670,7 @@ export const buildAzureAuthenticationHeaders = (...headers: AzureAuthenticationV } if (isReadonlyArray(layer)) { for (const row of layer) { - const name = row[0]; + const name = azureAuthenticationTupleName(row); if (typeof name !== 'string' || !isAzureAuthenticationHeader(name)) continue; const value = row[1]; const values = isReadonlyArray(value) ? value : [value]; @@ -1215,7 +1223,7 @@ const overridesAzureAuthenticationHeader = ( if (isReadonlyArray(headers)) { try { return headers.some((entry) => { - const candidate = entry[0]; + const candidate = azureAuthenticationTupleName(entry); if (typeof candidate !== 'string' || candidate.toLowerCase() !== name) { return false; } diff --git a/tests/lib/azure-pr2421-occurrence-review.test.ts b/tests/lib/azure-pr2421-occurrence-review.test.ts index 5f8e93071..3cac6f873 100644 --- a/tests/lib/azure-pr2421-occurrence-review.test.ts +++ b/tests/lib/azure-pr2421-occurrence-review.test.ts @@ -9,6 +9,9 @@ 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) => ({ @@ -123,6 +126,114 @@ describe('Azure authentication header occurrence snapshots', () => { }, ); + 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) => { @@ -301,4 +412,61 @@ describe('Azure authentication header occurrence snapshots', () => { 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(); + }, + ); }); From 33c43b7a7bfb017e7e9d2d0ba4499101c99be041 Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 27 Aug 2026 08:28:37 -0700 Subject: [PATCH 35/35] fix(azure): preserve credential isolation across header hooks --- src/internal/headers.ts | 193 ++++++++++- .../lib/azure-post-hook-proxy-privacy.test.ts | 91 ++++++ tests/lib/azure-pr2421-carrier-review.test.ts | 304 ++++++++++++++++++ .../azure-pr2421-occurrence-review.test.ts | 148 +++++++++ 4 files changed, 719 insertions(+), 17 deletions(-) diff --git a/src/internal/headers.ts b/src/internal/headers.ts index b4a5d7dc1..13fd06232 100644 --- a/src/internal/headers.ts +++ b/src/internal/headers.ts @@ -48,6 +48,8 @@ type AzureRequestHeaderMarker = { }; type AzureRequestHeaderRegistration = { carrier: NullableHeaders; + defaults: HeadersLike; + defaultSnapshots: Map; headers: object; owner: object | undefined; record: AzureAuthenticationRecordSnapshot | undefined; @@ -60,6 +62,7 @@ type AzureRequestHeaderRegistrations = { type AzureRequestHeaderProtection = { bind: (carrier: NullableHeaders) => NullableHeaders; deactivate: () => void; + prepare: (client: object) => void; release: () => void; snapshot: () => void; }; @@ -149,13 +152,45 @@ const coerceAzureCredentialHeaderValue = (value: unknown): string => { }; 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.'); + try { + const descriptor = azureAuthenticationTupleValueDescriptor(entry, 0); + if (descriptor !== undefined && 'value' in descriptor) return descriptor.value; + } catch { + // Descriptor and prototype traps must never leak credential-bearing diagnostics. + } + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); +}; + +const azureAuthenticationTupleValuesOverride = (values: readonly unknown[]): boolean => { + for (let index = 0; index < values.length; index += 1) { + const descriptor = azureAuthenticationTupleValueDescriptor(values, index); + if (descriptor !== undefined && (!('value' in descriptor) || descriptor.value !== undefined)) { + return true; + } } - return descriptor.value; + return false; }; +function* sanitizedAzureAuthenticationTupleValues( + row: readonly (HeaderValue | readonly HeaderValue[])[], + name: string, + protectsAzureCredentials: boolean, +): IterableIterator { + try { + const supplied = row[1]; + if (isReadonlyArray(supplied)) { + yield* supplied; + } else { + yield supplied; + } + } catch (error) { + if (protectsAzureCredentials && isAzureAuthenticationHeader(name)) { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + throw error; + } +} + const invalidateAzureAuthenticationHeaderIterators = (headers: Headers): void => { const version = azureAuthenticationHeaderMutationVersions.get(headers) ?? 0; azureAuthenticationHeaderMutationVersions.set(headers, version + 1); @@ -548,7 +583,12 @@ class DeferredAzureAuthenticationNulls extends Set { for (const layer of snapshotAzureAuthenticationHeaders(carrier) ?? []) { for (const [name, value] of layer) { const normalized = name.toLowerCase(); - if (removed.has(normalized)) continue; + if ( + removed.has(normalized) || + (Set.prototype.has.call(this, normalized) && !this.inherited.has(normalized)) + ) { + continue; + } if (value === null) { super.add(normalized); this.inherited.add(normalized); @@ -784,9 +824,11 @@ export const protectAzureRequestHeaders = ( azureRequestHeaders.set(headers, registrations); } const activeRegistrations = registrations; - const carrier = buildAzureAuthenticationHeaders(headers); + const carrier = buildAzureAuthenticationHeaders(); const activeRegistration: AzureRequestHeaderRegistration = { carrier, + defaults: undefined, + defaultSnapshots: new Map(), headers, owner, record: azureAuthenticationHeaderRecordSnapshots.get(carrier)?.get(headers), @@ -836,6 +878,19 @@ export const protectAzureRequestHeaders = ( return { bind, deactivate, + prepare: (client) => { + const options = Object.getOwnPropertyDescriptor(client, '_options')?.value as object | undefined; + const defaults = + options === undefined + ? undefined + : (Object.getOwnPropertyDescriptor(options, 'defaultHeaders')?.value as HeadersLike); + activeRegistration.defaults = defaults; + activeRegistration.defaultSnapshots = snapshotAzureRequestDefaultCredentials(defaults, headers); + const prepared = buildAzureAuthenticationHeaders(headers); + activeRegistration.carrier = prepared; + activeRegistration.record = azureAuthenticationHeaderRecordSnapshots.get(prepared)?.get(headers); + azureRequestAuthenticationHeaders.set(prepared, activeRegistration); + }, release, snapshot: () => { if ( @@ -874,6 +929,80 @@ export const protectAzureRequestHeaders = ( }; }; +const snapshotAzureRequestDefaultCredentials = ( + defaults: HeadersLike, + headers: object, +): Map => { + const snapshots = new Map(); + if (defaults === undefined || defaults === null) return snapshots; + try { + if (isReadonlyArray(headers)) return snapshots; + try { + Headers.prototype.has.call(headers, 'api-key'); + return snapshots; + } catch { + // Ordinary records and their proxies do not carry the native Headers brand. + } + const carrier = azureAuthenticationHeaders.has(defaults as NullableHeaders) + ? (defaults as NullableHeaders) + : undefined; + const records = carrier === undefined ? undefined : azureAuthenticationHeaderRecordSnapshots.get(carrier); + for (const source of carrier === undefined + ? [defaults] + : (azureAuthenticationHeaders.get(carrier) ?? [])) { + if (source === undefined || source === null || typeof source !== 'object') continue; + let record = records?.get(source); + if (record === undefined) { + const descriptors = Object.getOwnPropertyDescriptors(source); + const keys = Object.keys(descriptors).filter((name) => descriptors[name]?.enumerable === true); + record = { descriptors, keys }; + } + for (const name of record.keys) { + const normalized = name.toLowerCase(); + if (!isAzureAuthenticationHeader(normalized)) continue; + const descriptor = record.descriptors[name]; + if ( + descriptor === undefined || + !('value' in descriptor) || + descriptor.value === undefined || + descriptor.value === null || + typeof descriptor.value === 'string' || + isReadonlyArray(descriptor.value) || + hasRemainingAzureAuthenticationOverride(source, name, normalized, undefined, record) + ) { + continue; + } + const spellings = new Set([ + name, + normalized, + normalized.toUpperCase(), + normalized === 'authorization' ? 'Authorization' : 'Api-Key', + normalized === 'api-key' ? 'API-Key' : 'AUTHORIZATION', + ]); + let shadowed = false; + for (const spelling of spellings) { + const override = Object.getOwnPropertyDescriptor(headers, spelling); + if ( + override !== undefined && + override.enumerable === true && + (!('value' in override) || + (isReadonlyArray(override.value) + ? azureAuthenticationTupleValuesOverride(override.value) + : override.value !== undefined)) + ) { + shadowed = true; + break; + } + } + if (!shadowed) snapshots.set(normalized, coerceAzureCredentialHeaderValue(descriptor.value)); + } + } + } catch { + throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); + } + return snapshots; +}; + /** Captures a request-local Azure header capability before asynchronous authentication starts. */ export const captureAzureHeaders = ( client: object, @@ -893,6 +1022,7 @@ export const withAzureRequestHeaderSnapshot = ( protection: AzureRequestHeaderProtection | undefined, build: () => Result, ): Result => { + protection?.prepare(client); let active = azureRequestHeaderSnapshots.get(options); if (active === undefined) { active = []; @@ -963,6 +1093,7 @@ function* iterateHeaders( headers: HeadersLike, registration?: AzureRequestHeaderRegistration, record?: AzureAuthenticationRecordSnapshot, + protectsAzureCredentials = registration !== undefined, ): IterableIterator { if (!headers) return; @@ -981,8 +1112,29 @@ function* iterateHeaders( const layers = snapshotAzureAuthenticationHeaders(carrier, activeRegistration); if (layers !== undefined) { for (let index = 0; index < layers.length; index += 1) { - const layer = layers[index]; + let layer = layers[index]; if (layer === undefined) continue; + const source = sources?.[index]; + if ( + activeRegistration !== undefined && + source === activeRegistration.headers && + activeRegistration.record === undefined && + (source instanceof Headers || isReadonlyArray(source)) + ) { + const ordinary: [string, string | null][] = []; + const current = source instanceof Headers ? Headers.prototype.entries.call(source) : source; + for (const row of current) { + const name = source instanceof Headers ? row[0] : azureAuthenticationTupleName(row); + if (typeof name !== 'string') throw new TypeError('expected header name to be a string'); + if (isAzureAuthenticationHeader(name)) continue; + const value = row[1]; + const values = (isReadonlyArray(value) ? value : [value]) as readonly HeaderValue[]; + for (const entry of values) { + if (entry !== undefined) ordinary.push([name, entry]); + } + } + layer = [...layer.filter(([name]) => isAzureAuthenticationHeader(name)), ...ordinary]; + } const seen = new Set(); const refreshed = new Set(); for (const [name, snapshot] of layer) { @@ -1087,9 +1239,10 @@ function* iterateHeaders( ); } for (let row of iter) { - const name = row[0]; + const name = + protectsAzureCredentials && isReadonlyArray(headers) ? azureAuthenticationTupleName(row) : row[0]; if (typeof name !== 'string') throw new TypeError('expected header name to be a string'); - const values = isReadonlyArray(row[1]) ? row[1] : [row[1]]; + const values = sanitizedAzureAuthenticationTupleValues(row, name, protectsAzureCredentials); let didClear = false; for (const value of values) { if (value === undefined) continue; @@ -1240,13 +1393,7 @@ const overridesAzureAuthenticationHeader = ( 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; + return azureAuthenticationTupleValuesOverride(value); }); } catch { throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.'); @@ -1366,7 +1513,19 @@ const buildHeadersWithRegistration = ( if (requestRegistration !== undefined && headers === requestRegistration.carrier) { snapshotAzureAuthenticationHeaders(headers, requestRegistration); } - for (const [name, value] of iterateHeaders(headers, requestRegistration)) { + for (const [name, supplied] of iterateHeaders( + headers, + requestRegistration, + undefined, + protectsAzureCredentials, + )) { + const value = + requestRegistration !== undefined && + source === requestRegistration.defaults && + supplied !== null && + typeof supplied !== 'string' + ? (requestRegistration.defaultSnapshots.get(name.toLowerCase()) ?? supplied) + : supplied; if (!httpTokenHeaderName.test(name)) { throw new TypeError(`Header name must be a valid HTTP token ["${name}"]`); } diff --git a/tests/lib/azure-post-hook-proxy-privacy.test.ts b/tests/lib/azure-post-hook-proxy-privacy.test.ts index 8ad734a79..9548110a5 100644 --- a/tests/lib/azure-post-hook-proxy-privacy.test.ts +++ b/tests/lib/azure-post-hook-proxy-privacy.test.ts @@ -16,6 +16,97 @@ class PostHookProxyAzure extends AzureOpenAI { } describe('Azure post-hook proxy credential privacy', () => { + test.each( + (['get', 'post'] as const).flatMap((method) => + (['off', 'debug'] as const).flatMap((logLevel) => + (['api-key', 'Authorization'] as const).flatMap((header) => + (['direct value', 'array element', 'inherited array element'] as const).map((representation) => ({ + method, + logLevel, + header, + representation, + })), + ), + ), + ), + )( + 'sanitizes a protected $method $header tuple $representation accessor before $logLevel logging or dispatch', + async ({ method, logLevel, header, representation }) => { + const credential = `${PRIVATE_CREDENTIAL}\n${PRIVATE_SUFFIX}`; + const fail = vi.fn(() => { + throw Object.assign(new Error(credential), { cause: new Error(credential) }); + }); + const row: [string, string] = [header, 'placeholder']; + if (representation === 'direct value') { + Object.defineProperty(row, 1, { configurable: true, enumerable: true, get: fail }); + } else { + const values = ['placeholder']; + if (representation === 'inherited array element') { + Reflect.deleteProperty(values, 0); + const prototype = Object.create(Array.prototype) as object; + Object.defineProperty(prototype, 0, { configurable: true, enumerable: true, get: fail }); + Object.setPrototypeOf(values, prototype); + } else { + Object.defineProperty(values, 0, { configurable: true, enumerable: true, get: fail }); + } + Object.defineProperty(row, 1, { configurable: true, enumerable: true, value: values }); + } + 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 = [row]; + + let failure: unknown; + try { + await client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(APIConnectionError); + if (!(failure instanceof APIConnectionError)) { + throw new Error('Expected an Azure connection wrapper.'); + } + const { cause } = failure as APIConnectionError & { cause?: unknown }; + expect(cause).toBeInstanceOf(TypeError); + if (!(cause instanceof TypeError)) { + throw new Error('Expected a sanitized Azure credential failure.'); + } + 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(); + }, + ); + test.each( (['ownKeys', 'getOwnPropertyDescriptor', 'getPrototypeOf'] as const).flatMap((operation) => (['off', 'debug'] as const).flatMap((logLevel) => diff --git a/tests/lib/azure-pr2421-carrier-review.test.ts b/tests/lib/azure-pr2421-carrier-review.test.ts index bf073f812..7da7ab774 100644 --- a/tests/lib/azure-pr2421-carrier-review.test.ts +++ b/tests/lib/azure-pr2421-carrier-review.test.ts @@ -220,6 +220,310 @@ describe('Azure request-local authentication carrier review regressions', () => }, ); + test.each( + (['get', 'post'] as const).flatMap((method) => + (['native Headers', 'tuple row mutation', 'tuple row replacement', 'tuple value array'] as const).map( + (representation) => ({ method, representation }), + ), + ), + )( + 'refreshes complete ordinary $method $representation entries without changing its pinned credential', + async ({ method, representation }) => { + const { client, fetch } = createClient(); + const tuples: [string, string | string[]][] = [ + ['api-key', 'original-tenant-token'], + [ + 'x-request-metadata', + representation === 'tuple value array' ? ['before-first', 'before-second'] : 'before', + ], + ['x-removed', 'discarded'], + ['x-duplicate', 'before-first'], + ['x-duplicate', 'before-second'], + ]; + const headers = + representation === 'native Headers' + ? new Headers( + tuples.map(([name, value]) => [name, Array.isArray(value) ? value.join(', ') : value]), + ) + : tuples; + + client.observeAuthentication = () => { + if (headers instanceof Headers) { + headers.set('api-key', 'different-tenant-token'); + headers.set('x-request-metadata', 'after'); + headers.delete('x-removed'); + headers.delete('x-duplicate'); + headers.append('x-duplicate', 'after-first'); + headers.append('x-duplicate', 'after-second'); + headers.set('x-added', 'new metadata'); + return; + } + const [credential, metadata] = headers; + if (credential === undefined || metadata === undefined) { + throw new Error('Expected request credential and metadata tuples.'); + } + credential[1] = 'different-tenant-token'; + if (representation === 'tuple row replacement') { + headers[1] = ['x-request-metadata', 'after']; + } else if (representation === 'tuple value array') { + const [, values] = metadata; + if (Array.isArray(values)) { + values.splice(0, values.length, 'after-first', 'after-second'); + } + } else { + metadata[1] = 'after'; + } + headers.splice(2, 1); + const [firstDuplicate, secondDuplicate] = headers.slice(2); + if (firstDuplicate === undefined || secondDuplicate === undefined) { + throw new Error('Expected duplicate metadata tuples.'); + } + firstDuplicate[1] = 'after-first'; + secondDuplicate[1] = 'after-second'; + headers.push(['x-added', 'new metadata']); + }; + + await client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + headers: headers as FinalRequestOptions['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( + representation === 'tuple value array' ? 'after-first, after-second' : 'after', + ); + expect(sent.has('x-removed')).toBe(false); + expect(sent.get('x-duplicate')).toBe('after-first, after-second'); + expect(sent.get('x-added')).toBe('new metadata'); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each( + (['get', 'post'] as const).flatMap((method) => + (['api-key', 'Authorization'] as const).map((name) => ({ method, name })), + ), + )( + 'pins the earlier $method $name default before a request proxy can switch tenants', + async ({ method, name }) => { + let effective = 'tenant-a-token'; + let coercions = 0; + let enumerations = 0; + const defaults: Record = {}; + Object.defineProperty(defaults, name, { + enumerable: true, + value: { + toString(): string { + coercions += 1; + return effective; + }, + }, + }); + const headers = new Proxy( + { 'x-request-metadata': 'preserved' }, + { + ownKeys(target) { + enumerations += 1; + effective = 'tenant-b-token'; + return Reflect.ownKeys(target); + }, + }, + ); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new CarrierReviewAzure({ + 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, + }); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.get(name)).toBe('tenant-a-token'); + expect(sent.get('x-request-metadata')).toBe('preserved'); + expect(coercions).toBe(1); + expect(enumerations).toBe(1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each( + (['get', 'post'] as const).flatMap((method) => + (['api-key', 'Authorization'] as const).map((name) => ({ method, name })), + ), + )( + 'never lets a nonenumerable $method $name request property hide an effective default', + async ({ method, 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 target: Record = { 'x-request-metadata': 'preserved' }; + Object.defineProperty(target, name, { value: 'ignored-nonenumerable-token' }); + const headers = new Proxy(target, { + ownKeys(source) { + effective = 'tenant-b-token'; + return Reflect.ownKeys(source); + }, + }); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new CarrierReviewAzure({ + 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, + }); + + 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( + (['get', 'post'] as const).flatMap((method) => + (['api-key', 'Authorization'] as const).map((name) => ({ method, name })), + ), + )( + 'never coerces a shadowed earlier $method $name credential while capturing a request proxy', + async ({ method, name }) => { + let coercions = 0; + let enumerations = 0; + const defaults: Record = {}; + Object.defineProperty(defaults, name, { + enumerable: true, + value: { + toString(): string { + coercions += 1; + throw new Error('private-shadowed-proxy-tenant-token'); + }, + }, + }); + const headers = new Proxy( + { [name]: 'request-tenant-token', 'x-request-metadata': 'preserved' }, + { + ownKeys(target) { + enumerations += 1; + return Reflect.ownKeys(target); + }, + }, + ); + const fetch = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => Response.json({ ok: true })); + const client = new CarrierReviewAzure({ + 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, + }); + + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get(name)).toBe('request-tenant-token'); + expect(coercions).toBe(0); + expect(enumerations).toBe(1); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + test.each(['get', 'post'] as const)( + 'sanitizes a hostile tuple-name prototype introduced by the protected %s authentication hook', + async (method) => { + const { client, fetch } = createClient(); + const privateCredential = 'private-post-authentication-prototype-token'; + const row: [string, string] = ['x-request-metadata', 'preserved']; + const headers: [string, string][] = [['api-key', 'original-tenant-token'], row]; + client.observeAuthentication = () => { + Reflect.deleteProperty(row, 0); + Object.setPrototypeOf( + row, + new Proxy(Object.create(Array.prototype) as object, { + getPrototypeOf() { + throw new Error(privateCredential); + }, + }), + ); + }; + + let failure: unknown; + try { + await client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + 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(privateCredential); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each(['get', 'post'] as const)( + 'preserves a captured native Set.add %s credential tombstone before lazy initialization', + async (method) => { + const { client, fetch } = createClient(); + client.observeAuthentication = (_options, carrier) => { + Set.prototype.add.call(carrier.nulls, 'api-key'); + carrier.values.set('authorization', 'Bearer replacement-tenant-token'); + }; + + await client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + }); + + const sent = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(sent.has('api-key')).toBe(false); + expect(sent.get('authorization')).toBe('Bearer replacement-tenant-token'); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + test.each([ { description: 'array-to-array replacement', diff --git a/tests/lib/azure-pr2421-occurrence-review.test.ts b/tests/lib/azure-pr2421-occurrence-review.test.ts index 3cac6f873..657d66d3c 100644 --- a/tests/lib/azure-pr2421-occurrence-review.test.ts +++ b/tests/lib/azure-pr2421-occurrence-review.test.ts @@ -190,6 +190,154 @@ describe('Azure authentication header occurrence snapshots', () => { }, ); + test.each( + requestCredentialCases.flatMap(({ method, name }) => + (['custom prototype', 'array subclass'] as const).map((representation) => ({ + method, + name, + representation, + })), + ), + )( + 'accepts a safe inherited $method $name tuple data descriptor on an $representation', + async ({ method, name, representation }) => { + let coercions = 0; + const defaults: Record = {}; + Object.defineProperty(defaults, name, { + enumerable: true, + value: { + toString(): string { + coercions += 1; + throw new Error('private-shadowed-tenant-token'); + }, + }, + }); + class TupleRow extends Array {} + const row = + representation === 'array subclass' + ? new TupleRow(name, 'effective-tenant-token') + : [name, 'effective-tenant-token']; + Reflect.deleteProperty(row, 0); + const prototype = + representation === 'array subclass' ? TupleRow.prototype : Object.create(Array.prototype); + Object.defineProperty(prototype, 0, { configurable: true, value: name.toUpperCase() }); + if (representation === 'custom prototype') { + Object.setPrototypeOf(row, prototype); + } + + 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: [row] as [string, string][], + }); + + 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(requestCredentialCases)( + 'rejects an inherited $method $name tuple-name accessor without invoking it', + async ({ method, name }) => { + let nameReads = 0; + const row = [name, 'effective-tenant-token']; + Reflect.deleteProperty(row, 0); + const prototype = Object.create(Array.prototype) as object; + Object.defineProperty(prototype, 0, { + configurable: true, + get(): string { + nameReads += 1; + throw new Error('private-inherited-tuple-name-token'); + }, + }); + Object.setPrototypeOf(row, prototype); + 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 expect( + client.request({ + method, + path: '/models', + ...(method === 'post' ? { body: { safe: true } } : {}), + headers: [row] as [string, string][], + }), + ).rejects.toThrow(SAFE_ERROR); + + expect(nameReads).toBe(0); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each(['deep', 'cyclic', 'throwing proxy'] as const)( + 'sanitizes and bounds an untrusted %s inherited tuple-name prototype path', + async (representation) => { + const privateCredential = 'private-inherited-prototype-token'; + const row = ['api-key', 'effective-tenant-token']; + Reflect.deleteProperty(row, 0); + let reads = 0; + let prototype: object = Object.create(Array.prototype) as object; + if (representation === 'deep') { + for (let depth = 0; depth < 40; depth += 1) { + prototype = Object.create(prototype) as object; + } + } else { + prototype = new Proxy(prototype, { + getPrototypeOf(target) { + reads += 1; + if (representation === 'throwing proxy') { + throw new Error(privateCredential); + } + return reads === 1 ? target : prototype; + }, + }); + } + Object.setPrototypeOf(row, prototype); + 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: [row] as [string, string][] }); + } 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(reads).toBeLessThanOrEqual(32); + 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 }) => {