Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
151 changes: 143 additions & 8 deletions src/azure.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { RequestInit, RequestInfo, Response } from './internal/builtin-types';
import type { NullableHeaders } from './internal/headers';
import { 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';
Expand Down Expand Up @@ -126,6 +130,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 @@ -169,11 +174,26 @@ 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 { body, headers } = options;
const preprocessesHeaders = body === undefined ? 'body' in options : Boolean(body);
const protection = preprocessesHeaders ? protectAzureRequestHeaders(headers) : undefined;
Comment on lines +177 to +179

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Snapshot the effective headers property only once

When direct client.request() options expose headers through an accessor that returns different records on successive reads, this destructuring registers protection for the first record, but super.buildRequest() immediately reads the property again while making its shallow copy. For a truthy body, that second record is passed through the preliminary buildHeaders([rawHeaders]) without the marker; if it contains a credential such as secret\nsuffix, native Headers.append throws a diagnostic containing the secret before the protected final merge. Fresh evidence beyond the covered getters inside a header record is that the FinalRequestOptions.headers property itself can change between these reads, so the body and final merge must consume one captured representation.

AGENTS.md reference: AGENTS.md:L96-L100

Useful? React with 👍 / 👎.


try {
let pending: ReturnType<OpenAI['buildRequest']>;
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(
Expand All @@ -183,7 +203,13 @@ export class AzureOpenAI extends OpenAI {
controller: AbortController,
schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean },
): Promise<Response> {
if (new Headers(init.headers).has('api-key')) {
const suppliedHeaders = init.headers;
const safeHeaders = snapshotCrossRealmHeaders(suppliedHeaders);
const headers = buildHeaders([buildAzureAuthenticationHeaders(), safeHeaders]).values;
if (!hasIntrinsicHeadersIdentity(suppliedHeaders)) {
init.headers = headers;
Comment thread
HAYDEN-OAI marked this conversation as resolved.
}
if (headers.has('api-key')) {
init.redirect = 'manual';
}

Expand All @@ -196,9 +222,118 @@ 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 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}`]]);
}
}

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) {

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 unmodified Headers subclass identity

When prepareRequest installs an unmodified Headers subclass (for example, class TrackedHeaders extends Headers {}) whose identity carries WeakMap metadata for a custom fetch transport, this exact-prototype check rejects it and fetchWithAuth replaces it with a new Headers. The base revision forwarded that hook-supplied instance unchanged, and the remaining case after preserving exact native instances does not involve overridden header operations, so it can be validated safely without breaking the protected-hook identity contract.

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

Useful? React with 👍 / 👎.

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 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<unknown>) {
if (snapshots.length >= 1024 || !Array.isArray(row)) {
throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.');
}
return super.authHeaders(opts, security);
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<ClientOptions, 'defaultHeaders'>): void {
if (readEnv('OPENAI_CUSTOM_HEADERS')) {
options.defaultHeaders = buildAzureAuthenticationHeaders(options.defaultHeaders);
}
}

Expand Down
5 changes: 4 additions & 1 deletion 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,11 +120,13 @@ function createAzureWebSocket(
throw new Error('Azure OpenAI Realtime requires an API key');
}

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 });
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
72 changes: 72 additions & 0 deletions src/internal/azure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/** 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);
if (Array.isArray(value)) {
const { length } = value;
if (!Number.isSafeInteger(length) || length < 0 || length > 1024) {
throw new TypeError('Azure OpenAI credential contains an invalid HTTP header value.');
}
const snapshot: unknown[] = [];
for (let index = 0; index < length; index += 1) {
const entry = value[index];
if (entry === null || entry === undefined) {
snapshot.push(entry);
continue;
}
const credential = String(entry);
assertAzureCredentialHeaderValue(credential);
snapshot.push(credential);
}
safeHeaders.set(name, snapshot);
} else {
const credential = String(value);
assertAzureCredentialHeaderValue(credential);
safeHeaders.set(name, credential);
}
}
return Object.fromEntries(safeHeaders) as Headers;
}
Loading
Loading