-
Notifications
You must be signed in to change notification settings - Fork 1.6k
fix(security): redact invalid Azure authentication credentials #2421
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5e2b22a
222e643
7ff52b6
eba9440
953b385
c006e3b
e4947bd
01a5284
63f427f
8ea03dc
1cecd41
7f1728c
2a85796
62f306e
c300207
59af3c0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
|
|
@@ -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, | ||
|
|
@@ -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; | ||
|
|
||
| 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( | ||
|
|
@@ -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; | ||
|
HAYDEN-OAI marked this conversation as resolved.
|
||
| } | ||
| if (headers.has('api-key')) { | ||
| init.redirect = 'manual'; | ||
| } | ||
|
|
||
|
|
@@ -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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When 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); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| 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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When direct
client.request()options exposeheadersthrough an accessor that returns different records on successive reads, this destructuring registers protection for the first record, butsuper.buildRequest()immediately reads the property again while making its shallow copy. For a truthy body, that second record is passed through the preliminarybuildHeaders([rawHeaders])without the marker; if it contains a credential such assecret\nsuffix, nativeHeaders.appendthrows a diagnostic containing the secret before the protected final merge. Fresh evidence beyond the covered getters inside a header record is that theFinalRequestOptions.headersproperty 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 👍 / 👎.