Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2f220cc
fix(security): bound SDK streams and enforce event identity
HAYDEN-OAI Aug 18, 2026
8283fd4
fix(security): validate retained streaming identities and event payloads
HAYDEN-OAI Aug 19, 2026
d0ed2b4
fix(security): stabilize streamed event routing and host backing storage
HAYDEN-OAI Aug 19, 2026
4f2c2f9
fix(security): guard cross-realm collections and inherited stream rou…
HAYDEN-OAI Aug 19, 2026
5498320
fix(security): bind streaming parser configuration and binary queues
HAYDEN-OAI Aug 19, 2026
2cd10b7
fix(security): bind streamed events and fail closed on opaque payloads
HAYDEN-OAI Aug 19, 2026
266cd10
fix(streaming): scope JSON budgets to parseable tool calls
HAYDEN-OAI Aug 19, 2026
4903a36
fix(security): bound retained event descriptors and capture tool deltas
HAYDEN-OAI Aug 19, 2026
057b4e6
Harden buffered event prototypes and stream routing identities
HAYDEN-OAI Aug 19, 2026
1ad4b05
Support cross-realm native Error stacks on Node 22
HAYDEN-OAI Aug 19, 2026
be87d1f
Harden cross-realm buffering and streaming parser boundaries
HAYDEN-OAI Aug 19, 2026
2c99abc
fix: harden streamed snapshot identity and retained storage
HAYDEN-OAI Aug 19, 2026
6bafad1
fix: constrain proxy queues and cumulative streaming work
HAYDEN-OAI Aug 19, 2026
acd97e7
fix: bind structured chat snapshot parser frames
HAYDEN-OAI Aug 19, 2026
de9c49c
fix: preserve validated streaming parser and event snapshots
HAYDEN-OAI Aug 19, 2026
3969716
fix: preserve buffered API errors with response headers
HAYDEN-OAI Aug 19, 2026
06d4b1c
fix: bind streamed tool parsing to actual wire contract
HAYDEN-OAI Aug 19, 2026
6f26d1a
fix(streaming): bind parsers to serialized owner and response format
HAYDEN-OAI Aug 19, 2026
929ded3
fix(streaming): bind validated tool collections and array budgets
HAYDEN-OAI Aug 19, 2026
b18e1a6
fix(streaming): bind parser schemas and preserve buffered dates
HAYDEN-OAI Aug 19, 2026
836c27c
fix: preserve streaming parser ownership and shared event accounting
HAYDEN-OAI Aug 20, 2026
dd41cae
merge: preserve hardened streaming contracts with main
HAYDEN-OAI Aug 20, 2026
a5a1cf1
fix: capture tool-call accessors once on older Node runtimes
HAYDEN-OAI Aug 20, 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
Prev Previous commit
Next Next commit
fix(security): stabilize streamed event routing and host backing storage
  • Loading branch information
HAYDEN-OAI committed Aug 19, 2026
commit d0ed2b4614e25cc903908e7d5f785f3301642a68
47 changes: 41 additions & 6 deletions src/internal/responses/response-accumulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,15 @@ function assertNever(_value: never): never {
throw new OpenAIError('Unhandled response stream event: unknown');
}

const responseEventRoutingFields = [
'item_id',
'output_index',
'content_index',
'annotation_index',
'command_index',
'summary_index',
] as const;

function sanitizeResponseEvent(event: ResponseAccumulatorEvent): ResponseAccumulatorEvent {
let descriptor: PropertyDescriptor | undefined;
try {
Expand All @@ -510,9 +519,35 @@ function sanitizeResponseEvent(event: ResponseAccumulatorEvent): ResponseAccumul
return assertNever(event as never);
}

const stableValues = new Map<PropertyKey, unknown>([['type', type]]);
const itemScoped =
type === 'response.output_item.added' ||
type === 'response.output_item.done' ||
type === 'response.content_part.added' ||
type === 'response.content_part.done' ||
hasOwn(expectedOutputItemTypes, type);
Comment thread
HAYDEN-OAI marked this conversation as resolved.

if (itemScoped) {
try {
for (const field of responseEventRoutingFields) {
if (Object.getOwnPropertyDescriptor(event, field)) {
stableValues.set(field, Reflect.get(event, field, event));
Comment thread
HAYDEN-OAI marked this conversation as resolved.
Outdated
}
}

if (type === 'response.output_item.done') {
stableValues.set('item', structuredClone(Reflect.get(event, 'item', event)));
} else if (type === 'response.content_part.added' || type === 'response.content_part.done') {
stableValues.set('part', structuredClone(Reflect.get(event, 'part', event)));
}
} catch {
return assertNever(event as never);
}
}

return new Proxy(event, {
get(target, property) {
return property === 'type' ? type : Reflect.get(target, property, target);
return stableValues.has(property) ? stableValues.get(property) : Reflect.get(target, property, target);
Comment thread
HAYDEN-OAI marked this conversation as resolved.
},
});
}
Expand Down Expand Up @@ -547,7 +582,7 @@ function accumulateOutputItemEvent(
case 'response.output_item.done': {
const output = getOutput(snapshot, event.output_index);
const previousText = getOutputText(context, output);
const replacement = structuredClone(event.item);
const replacement = event.item;
if (output.type === 'message' || replacement.type === 'message') {
ensureCanonicalOutputText(context, snapshot);
}
Expand Down Expand Up @@ -577,7 +612,7 @@ function accumulateContentPartAddedEvent(
const { part } = event;
if (type === 'message' && part.type !== 'reasoning_text') {
validateArrayAppend(output.content, event.content_index, 'content');
const content = structuredClone(part);
const content = part;
if (content.type === 'output_text') {
ensureCanonicalOutputText(context, snapshot);
}
Expand All @@ -592,7 +627,7 @@ function accumulateContentPartAddedEvent(
if (!output.content) {
output.content = content;
}
content.push(structuredClone(part));
content.push(part);
}
return true;
}
Expand All @@ -614,7 +649,7 @@ function accumulateContentPartDoneEvent(
if (output.type === 'message' && part.type !== 'reasoning_text') {
const content = getContent(output.content, event.content_index);
const previousText = content.type === 'output_text' ? content.text : '';
const replacement = structuredClone(part);
const replacement = part;
if (content.type === 'output_text' || replacement.type === 'output_text') {
ensureCanonicalOutputText(context, snapshot);
}
Expand All @@ -628,7 +663,7 @@ function accumulateContentPartDoneEvent(
throw new OpenAIError(`missing content at index ${event.content_index}`);
}
getContent(content, event.content_index);
content[event.content_index] = structuredClone(part);
content[event.content_index] = part;
}
return true;
}
Expand Down
109 changes: 87 additions & 22 deletions src/lib/EventStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ const MAX_BUFFERED_ITERATOR_BYTES = 8 * 1024 * 1024;
// Structured JSON may nest 128 levels before stream-event wrappers are added.
const MAX_BUFFERED_EVENT_DEPTH = 256;

const typedArrayBufferGetter = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(Uint8Array.prototype) as object,
'buffer',
)?.get;
const dataViewBufferGetter = Object.getOwnPropertyDescriptor(DataView.prototype, 'buffer')?.get;
const arrayBufferByteLengthGetter = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get;
const sharedArrayBufferByteLengthGetter =
typeof SharedArrayBuffer === 'function'
? Object.getOwnPropertyDescriptor(SharedArrayBuffer.prototype, 'byteLength')?.get
: undefined;
const blobSizeGetter =
typeof Blob === 'function' ? Object.getOwnPropertyDescriptor(Blob.prototype, 'size')?.get : undefined;
const retainedStorageBrands = new Set(['ArrayBuffer', 'SharedArrayBuffer', 'Blob', 'File']);

type RetainedStorage = {
bytes: number;
kind: 'typed-array' | 'data-view' | 'buffer' | 'blob';
};

type EventQueue<Value> = {
readonly length: number;
enqueue: (value: Value) => void;
Expand Down Expand Up @@ -49,39 +68,85 @@ function createEventQueue<Value>(): EventQueue<Value> {
};
}

function getRetainedStorageBrand(current: object): string | undefined {
let prototype = Object.getPrototypeOf(current) as object | null;

for (let depth = 0; prototype !== null && depth < MAX_BUFFERED_EVENT_DEPTH; depth += 1) {
const descriptor = Object.getOwnPropertyDescriptor(prototype, Symbol.toStringTag);
if (
descriptor &&
'value' in descriptor &&
typeof descriptor.value === 'string' &&
retainedStorageBrands.has(descriptor.value)
) {
return descriptor.value;
}
prototype = Object.getPrototypeOf(prototype) as object | null;
}

return undefined;
}

function estimateRetainedBufferBytes(
current: object,
visit: (value: unknown, depth: number) => void,
depth: number,
): number | undefined {
): RetainedStorage | undefined {
if (ArrayBuffer.isView(current)) {
const prototype =
current instanceof DataView ? DataView.prototype : Object.getPrototypeOf(Uint8Array.prototype);
const buffer = Object.getOwnPropertyDescriptor(prototype, 'buffer')?.get?.call(current) as unknown;
let buffer: unknown;
let kind: RetainedStorage['kind'] = 'typed-array';

try {
buffer = typedArrayBufferGetter?.call(current) as unknown;
} catch {
kind = 'data-view';
buffer = dataViewBufferGetter?.call(current) as unknown;
}

if (typeof buffer !== 'object' || buffer === null) {
return Number.POSITIVE_INFINITY;
return { bytes: Number.POSITIVE_INFINITY, kind };
}

// Even a one-byte view retains its complete backing allocation.
// Even a one-byte cross-realm view retains its complete backing allocation.
visit(buffer, depth + 1);
return 0;
return { bytes: 0, kind };
}

if (current instanceof ArrayBuffer) {
const byteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get?.call(
current,
) as unknown;
return typeof byteLength === 'number' ? byteLength : Number.POSITIVE_INFINITY;
const brand = getRetainedStorageBrand(current);
if (!brand) {
return undefined;
}

if (typeof SharedArrayBuffer === 'function' && current instanceof SharedArrayBuffer) {
const byteLength = Object.getOwnPropertyDescriptor(SharedArrayBuffer.prototype, 'byteLength')?.get?.call(
current,
) as unknown;
return typeof byteLength === 'number' ? byteLength : Number.POSITIVE_INFINITY;
let getter: (() => unknown) | undefined;
let kind: RetainedStorage['kind'] = 'buffer';
switch (brand) {
case 'ArrayBuffer': {
getter = arrayBufferByteLengthGetter;
break;
}
case 'SharedArrayBuffer': {
getter = sharedArrayBufferByteLengthGetter;
break;
}
case 'Blob':
case 'File': {
getter = blobSizeGetter;
kind = 'blob';
break;
Comment thread
HAYDEN-OAI marked this conversation as resolved.
Outdated
}
default: {
return undefined;
}
}

return undefined;
const bytes = getter?.call(current);
return {
bytes:
typeof bytes === 'number' && Number.isSafeInteger(bytes) && bytes >= 0
? bytes
: Number.POSITIVE_INFINITY,
kind,
};
}

function visitHiddenEventValues(
Expand Down Expand Up @@ -138,9 +203,9 @@ function estimateBufferedEventBytes(value: unknown, remainingBytes: number): num
visited.add(current);
bytes += 16;

const bufferBytes = estimateRetainedBufferBytes(current, visit, depth);
if (bufferBytes !== undefined) {
bytes += bufferBytes;
const retainedStorage = estimateRetainedBufferBytes(current, visit, depth);
Comment thread
HAYDEN-OAI marked this conversation as resolved.
Outdated
if (retainedStorage !== undefined) {
bytes += retainedStorage.bytes;
if (bytes > remainingBytes) {
return;
}
Expand All @@ -157,7 +222,7 @@ function estimateBufferedEventBytes(value: unknown, remainingBytes: number): num
}

const ownKeys =
bufferBytes !== undefined && ArrayBuffer.isView(current) && !(current instanceof DataView)
retainedStorage?.kind === 'typed-array'
? Object.getOwnPropertySymbols(current)
: Reflect.ownKeys(current);

Expand Down
99 changes: 99 additions & 0 deletions tests/lib/EventStream.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { runInNewContext } from 'node:vm';
import { vi } from 'vitest';
import { APIUserAbortError, OpenAIError } from 'openai/error';
import { EventStream } from 'openai/lib/EventStream';
Expand Down Expand Up @@ -298,6 +299,104 @@ describe('EventStream iterator buffer limits', () => {
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined });
});

test.each([
{ name: 'Blob', create: () => new Blob([new Uint8Array(9 * 1024 * 1024)]) },
{ name: 'File', create: () => new File([new Uint8Array(9 * 1024 * 1024)], 'large.bin') },
])(
'rejects oversized $name backing storage without invoking an overridden size getter',
async ({ create }) => {
const stream = new TestStream();
const iterator = stream.events('payload');
const payload = create();
const readSize = vi.fn(() => 1);
Object.defineProperty(payload, 'size', { get: readSize });

stream.emitPayload(payload);

expect(stream.controller.signal.aborted).toBe(true);
await expect(iterator.next()).rejects.toThrow(/iterator buffer limit/iu);
expect(readSize).not.toHaveBeenCalled();
},
);

test('delivers a small Blob without invoking an own size accessor', async () => {
const stream = new TestStream();
const iterator = stream.events('payload');
const payload = new Blob(['small']);
const readSize = vi.fn(() => {
throw new Error('untrusted size accessor');
});
Object.defineProperty(payload, 'size', { get: readSize });

stream.emitPayload(payload);

await expect(iterator.next()).resolves.toEqual({ done: false, value: [payload] });
expect(readSize).not.toHaveBeenCalled();
stream.end();
});

test('fails closed on a spoofed Blob receiver without invoking its size accessor', async () => {
const stream = new TestStream();
const iterator = stream.events('payload');
const readSize = vi.fn(() => 1);
const payload = Object.create(Blob.prototype);
Object.defineProperty(payload, 'size', { get: readSize });

stream.emitPayload(payload);

expect(stream.controller.signal.aborted).toBe(true);
await expect(iterator.next()).rejects.toThrow(/iterator buffer limit/iu);
expect(readSize).not.toHaveBeenCalled();
});

test.each([
{ name: 'DataView', expression: 'new DataView(new ArrayBuffer(16))' },
{ name: 'ArrayBuffer', expression: 'new ArrayBuffer(16)' },
{ name: 'SharedArrayBuffer-backed DataView', expression: 'new DataView(new SharedArrayBuffer(16))' },
])('accepts a small cross-realm $name without reading spoofable accessors', async ({ expression }) => {
const stream = new TestStream();
const iterator = stream.events('payload');
const payload: unknown = runInNewContext(expression);

stream.emitPayload(payload);

await expect(iterator.next()).resolves.toEqual({ done: false, value: [payload] });
expect(stream.controller.signal.aborted).toBe(false);
stream.end();
});

test.each([
{ name: 'ArrayBuffer', expression: 'new ArrayBuffer(9 * 1024 * 1024)' },
{
name: 'ArrayBuffer-backed DataView',
expression: 'new DataView(new ArrayBuffer(9 * 1024 * 1024), 0, 1)',
},
{
name: 'SharedArrayBuffer-backed DataView',
expression: 'new DataView(new SharedArrayBuffer(9 * 1024 * 1024), 0, 1)',
},
])('charges the complete cross-realm $name backing store', async ({ expression }) => {
const stream = new TestStream();
const iterator = stream.events('payload');

stream.emitPayload(runInNewContext(expression));

expect(stream.controller.signal.aborted).toBe(true);
await expect(iterator.next()).rejects.toThrow(/iterator buffer limit/iu);
});

test('charges non-enumerable data retained by a cross-realm DataView', async () => {
const stream = new TestStream();
const iterator = stream.events('payload');
const payload = runInNewContext('new DataView(new ArrayBuffer(16))') as DataView;
Object.defineProperty(payload, 'hidden', { value: 'x'.repeat(5 * 1024 * 1024) });

stream.emitPayload(payload);

expect(stream.controller.signal.aborted).toBe(true);
await expect(iterator.next()).rejects.toThrow(/iterator buffer limit/iu);
});

test.each([
{ name: 'Map keys', create: (value: string) => new Map([[value, 'small']]) },
{ name: 'Map values', create: (value: string) => new Map([['small', value]]) },
Expand Down
Loading
Loading