From 5e2b22a09cc23f3a2a6f239167b759d8f8b50114 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 13:32:10 -0700 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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 })),