Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
5e2b22a
fix(security): redact invalid Azure authentication credentials
HAYDEN-OAI Aug 19, 2026
222e643
fix(azure): protect effective HTTP and realtime credentials
HAYDEN-OAI Aug 19, 2026
7ff52b6
fix(azure): protect credentials across headers and hooks
HAYDEN-OAI Aug 19, 2026
eba9440
fix(azure): preserve deferred credential and transport hook contracts
HAYDEN-OAI Aug 19, 2026
953b385
fix(azure): preserve deferred credential header mutations
HAYDEN-OAI Aug 19, 2026
c006e3b
fix: preserve deferred Azure authentication header reads
HAYDEN-OAI Aug 19, 2026
e4947bd
fix(azure): expose deferred authentication tombstones
HAYDEN-OAI Aug 19, 2026
01a5284
fix(azure): bridge cross-runtime header iterator types
HAYDEN-OAI Aug 19, 2026
63f427f
fix(types): preserve Set iterator support on TypeScript 4.9
HAYDEN-OAI Aug 20, 2026
8ea03dc
fix(azure): snapshot socket arrays and normalize deferred headers
HAYDEN-OAI Aug 20, 2026
1cecd41
fix(azure): harden deferred header coercion and carrier compatibility
HAYDEN-OAI Aug 20, 2026
7f1728c
fix(azure): preserve trusted post-hook Headers identity
HAYDEN-OAI Aug 20, 2026
2a85796
fix(azure): protect preprocessing and deferred header boundaries
HAYDEN-OAI Aug 20, 2026
62f306e
fix(azure): snapshot websocket credential serialization
HAYDEN-OAI Aug 20, 2026
c300207
fix(azure): isolate request options and foreign headers
HAYDEN-OAI Aug 20, 2026
59af3c0
fix(azure): preserve authenticated request options identity
HAYDEN-OAI Aug 20, 2026
c04e3d0
fix(azure): isolate credential snapshots across concurrent requests
HAYDEN-OAI Aug 25, 2026
fd1ebc1
test(log): construct prototype-key fixtures safely
HAYDEN-OAI Aug 25, 2026
58e88e8
Merge branch 'main' into codex/azure-credential-header-privacy-202608…
HAYDEN-OAI Aug 25, 2026
36cc2bc
fix(azure): safely snapshot cross-realm Headers subclasses
HAYDEN-OAI Aug 25, 2026
5704232
fix(azure): satisfy credential regression lint rules
HAYDEN-OAI Aug 25, 2026
1695ca7
fix: isolate Azure credential header snapshots and markers
HAYDEN-OAI Aug 25, 2026
99d8e77
fix: satisfy Azure header snapshot lint rules
HAYDEN-OAI Aug 25, 2026
c5299da
fix: protect Azure credentials for stateful request bodies
HAYDEN-OAI Aug 25, 2026
476508d
fix(azure): preserve isolated deferred credential compatibility
HAYDEN-OAI Aug 25, 2026
2111205
fix(azure): sanitize hostile credential access and request headers
HAYDEN-OAI Aug 25, 2026
94bfb1c
fix(azure): bound header snapshots and sanitize proxy traps
HAYDEN-OAI Aug 25, 2026
c611ead
fix(azure): preserve body accessors and live header iteration
HAYDEN-OAI Aug 25, 2026
cf44e55
fix(azure): safely snapshot immutable credential accessors
HAYDEN-OAI Aug 25, 2026
93c4ee3
fix(azure): preserve accessor and cookie header semantics
HAYDEN-OAI Aug 25, 2026
860d06b
Merge origin/main into codex/azure-credential-header-privacy-20260819…
HAYDEN-OAI Aug 27, 2026
b9d4baa
fix(azure): bind credential snapshots to each request
HAYDEN-OAI Aug 27, 2026
9c7a930
fix(azure): isolate request-local credential capabilities
HAYDEN-OAI Aug 27, 2026
828d891
fix(azure): bind credential snapshots to request occurrences
HAYDEN-OAI Aug 27, 2026
fc4957f
fix(azure): iterate effective credential values directly
HAYDEN-OAI Aug 27, 2026
8c9378a
fix(azure): inspect credential tuple names without invoking getters
HAYDEN-OAI Aug 27, 2026
33c43b7
fix(azure): preserve credential isolation across header hooks
HAYDEN-OAI Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions src/azure.ts
Original file line number Diff line number Diff line change
@@ -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, buildHeaders } from './internal/headers';
import * as Errors from './error';
import type { FinalRequestOptions } from './internal/request-options';
import { isObj, readEnv } from './internal/utils';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -183,7 +184,9 @@ export class AzureOpenAI extends OpenAI {
controller: AbortController,
schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean },
): Promise<Response> {
if (new Headers(init.headers).has('api-key')) {
const headers = buildHeaders([buildAzureAuthenticationHeaders(), init.headers]).values;
init.headers = headers;
Comment thread
HAYDEN-OAI marked this conversation as resolved.
Outdated
if (headers.has('api-key')) {
Comment on lines +282 to +283

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve stable post-hook header identity

When an Azure prepareRequest override installs a stable plain record or tuple array as req.headers, this branch replaces it with a new native Headers before the custom fetch transport runs. The parent implementation constructed a temporary Headers only for its redirect check and passed the hook's original representation through, so transports that retain that object, inspect attached metadata, or require identity with the hook-provided value now observe a different object even when every credential is already safe and immutable. Preserve the post-hook representation for stable inputs while validating the same values that will be dispatched.

AGENTS.md reference: AGENTS.md:L52-L57

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid at 33c43b7a7bfb017e7e9d2d0ba4499101c99be041. fetchWithAuth() replaces every non-native stable record or tuple representation with a new native Headers, changing the post-prepareRequest object observed by a custom transport even after its credential values are safely snapshotted. Stable inputs must retain their dispatched identity while validating the same credential snapshot; this requires a code fix, so the thread remains unresolved.

init.redirect = 'manual';
}

Expand All @@ -196,9 +199,33 @@ export class AzureOpenAI extends OpenAI {
): Promise<NullableHeaders | undefined> {
const security = schemes ?? { bearerAuth: true, adminAPIKeyAuth: true };
if (security.bearerAuth && typeof this._options.apiKey === 'string') {
return buildHeaders([{ 'api-key': this.apiKey }]);
return buildAzureAuthenticationHeaders([['api-key', this.apiKey]]);
}
return super.authHeaders(opts, security);

return buildAzureAuthenticationHeaders(
security.bearerAuth ? await this.bearerAuth(opts) : undefined,
security.adminAPIKeyAuth ? await this.adminAPIKeyAuth(opts) : undefined,
);
}

protected override async bearerAuth(_opts: FinalRequestOptions): Promise<NullableHeaders | undefined> {
if (this.apiKey === null) {
return undefined;
}
return buildAzureAuthenticationHeaders([['Authorization', `Bearer ${this.apiKey}`]]);
}

protected override async adminAPIKeyAuth(_opts: FinalRequestOptions): Promise<NullableHeaders | undefined> {
if (this.adminAPIKey === null || this.adminAPIKey === undefined) {
return undefined;
}
return buildAzureAuthenticationHeaders([['Authorization', `Bearer ${this.adminAPIKey}`]]);
}
}

function protectAzureAmbientHeaders(options: Pick<ClientOptions, 'defaultHeaders'>): void {
if (readEnv('OPENAI_CUSTOM_HEADERS')) {
options.defaultHeaders = buildAzureAuthenticationHeaders(options.defaultHeaders);
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/beta/realtime/websocket.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
Expand Down
3 changes: 2 additions & 1 deletion src/beta/realtime/ws.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -77,7 +78,7 @@ export class OpenAIRealtimeWS extends OpenAIRealtimeEmitter {
this.url,
protectWebSocketOptionsFromCredentialRedirects({
...props.options,
headers,
headers: isAzure(client) ? safeAzureWebSocketHeaders(headers) : headers,
}),
);

Expand Down
56 changes: 56 additions & 0 deletions src/internal/azure.ts
Original file line number Diff line number Diff line change
@@ -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 extends Record<string, unknown>>(
headers: Headers,
): Headers {
const safeHeaders = new Map<string, unknown>();
const authenticationNames = new Map<string, string>();

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) {
Comment thread
HAYDEN-OAI marked this conversation as resolved.
Outdated
if (typeof entry === 'string') {
assertAzureCredentialHeaderValue(entry);
}
}
}
return Object.fromEntries(safeHeaders) as Headers;
}
81 changes: 80 additions & 1 deletion src/internal/headers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { assertAzureCredentialHeaderValue, isAzureAuthenticationHeader } from './azure';
import { isReadonlyArray } from './utils/values';

type HeaderValue = string | undefined | null;
Expand Down Expand Up @@ -26,11 +27,46 @@ export type NullableHeaders = {
nulls: Set<string>;
};

type AzureAuthenticationValues = ReadonlyArray<HeadersLike>;

// Object-identity branding cannot be forged by caller-provided header records.
const azureAuthenticationHeaders = new WeakMap<NullableHeaders, AzureAuthenticationValues>();

/**
* Creates an authenticated Azure header carrier without first appending a raw
* credential to native Headers, where rejected values appear in diagnostics.
Comment thread
HAYDEN-OAI marked this conversation as resolved.
*/
export const buildAzureAuthenticationHeaders = (...headers: AzureAuthenticationValues): NullableHeaders => {
const carrier: NullableHeaders = {
[brand_privateNullableHeaders]: true,
values: new Headers(),
nulls: new Set<string>(),
Comment thread
HAYDEN-OAI marked this conversation as resolved.
Outdated
};
azureAuthenticationHeaders.set(carrier, headers);
return carrier;
};

function* iterateHeaders(headers: HeadersLike): IterableIterator<readonly [string, string | null]> {
if (!headers) return;

if (brand_privateNullableHeaders in headers) {
const { values, nulls } = headers;
const visibleNames = new Set([...values.keys(), ...nulls].map((name) => name.toLowerCase()));
const azureHeaders = azureAuthenticationHeaders.get(headers);
if (azureHeaders !== undefined) {
for (const layer of azureHeaders) {
const seen = new Set<string>();
for (const [name, value] of iterateHeaders(layer)) {
const normalized = name.toLowerCase();
if (visibleNames.has(normalized)) continue;
Comment thread
HAYDEN-OAI marked this conversation as resolved.
Outdated
if (!seen.has(normalized)) {
seen.add(normalized);
yield [name, null];
}
yield [name, value];
}
}
}
yield* values.entries();
for (const name of nulls) {
yield [name, null];
Expand Down Expand Up @@ -67,29 +103,72 @@ function* iterateHeaders(headers: HeadersLike): IterableIterator<readonly [strin
}
}

/** Validates only the final authentication values without native construction. */
export const assertAzureAuthenticationHeaders = (headers: HeadersLike): void => {
for (const [name, value] of iterateHeaders(headers)) {
if (value !== null && isAzureAuthenticationHeader(name)) {
assertAzureCredentialHeaderValue(value);
}
}
};

export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => {
const targetHeaders = new Headers();
const nullHeaders = new Set<string>();
const protectsAzureCredentials = newHeaders.some(
(headers) =>
typeof headers === 'object' &&
headers !== null &&
azureAuthenticationHeaders.has(headers as NullableHeaders),
Comment thread
HAYDEN-OAI marked this conversation as resolved.
Outdated
);
const pendingAuthenticationHeaders = new Map<string, string[]>();

for (const headers of newHeaders) {
const seenHeaders = new Set<string>();
for (const [name, value] of iterateHeaders(headers)) {
if (!httpTokenHeaderName.test(name)) {
throw new TypeError(`Header name must be a valid HTTP token ["${name}"]`);
}
const lowerName = name.toLowerCase();
const deferAuthenticationHeader = protectsAzureCredentials && isAzureAuthenticationHeader(lowerName);
Comment thread
HAYDEN-OAI marked this conversation as resolved.
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]);
}
Comment thread
HAYDEN-OAI marked this conversation as resolved.
} else {
targetHeaders.append(lowerName, value);
}
nullHeaders.delete(lowerName);
}
}
}
for (const values of pendingAuthenticationHeaders.values()) {
for (const value of values) {
assertAzureCredentialHeaderValue(value);
}
}
Comment thread
HAYDEN-OAI marked this conversation as resolved.
Outdated
for (const [name, values] of pendingAuthenticationHeaders) {
for (const value of values) {
targetHeaders.append(name, value);
Comment thread
HAYDEN-OAI marked this conversation as resolved.
}
}
return { [brand_privateNullableHeaders]: true, values: targetHeaders, nulls: nullHeaders };
};

Expand Down
2 changes: 2 additions & 0 deletions src/realtime/websocket.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
Expand Down
3 changes: 2 additions & 1 deletion src/realtime/ws.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -70,7 +71,7 @@ export class OpenAIRealtimeWS extends OpenAIRealtimeEmitter {
this.url,
protectWebSocketOptionsFromCredentialRedirects({
...props.options,
headers,
headers: isAzure(client) ? safeAzureWebSocketHeaders(headers) : headers,
}),
);

Expand Down
Loading
Loading