Skip to content

Commit 1eeaa2c

Browse files
committed
fix: normalize header names before appending
1 parent fbf80f3 commit 1eeaa2c

2 files changed

Lines changed: 79 additions & 3 deletions

File tree

src/internal/headers.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type HeadersLike =
1212
| NullableHeaders;
1313

1414
const brand_privateNullableHeaders = /* @__PURE__ */ Symbol('brand.privateNullableHeaders');
15+
const httpTokenHeaderName = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
1516

1617
/**
1718
* @internal
@@ -74,16 +75,19 @@ export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => {
7475
for (const headers of newHeaders) {
7576
const seenHeaders = new Set<string>();
7677
for (const [name, value] of iterateHeaders(headers)) {
78+
if (!httpTokenHeaderName.test(name)) {
79+
throw new TypeError(`Header name must be a valid HTTP token ["${name}"]`);
80+
}
7781
const lowerName = name.toLowerCase();
7882
if (!seenHeaders.has(lowerName)) {
79-
targetHeaders.delete(name);
83+
targetHeaders.delete(lowerName);
8084
seenHeaders.add(lowerName);
8185
}
8286
if (value === null) {
83-
targetHeaders.delete(name);
87+
targetHeaders.delete(lowerName);
8488
nullHeaders.add(lowerName);
8589
} else {
86-
targetHeaders.append(name, value);
90+
targetHeaders.append(lowerName, value);
8791
nullHeaders.delete(lowerName);
8892
}
8993
}

tests/buildHeaders.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,76 @@ describe('buildHeaders', () => {
8585
expect(inspectNullableHeaders(buildHeaders(input))).toEqual(expected);
8686
});
8787
}
88+
89+
test('normalizes OpenAI headers with locale-invariant ASCII casing', () => {
90+
expect('OpenAI-Organization'.toLocaleLowerCase('tr-TR')).toBe('openaı-organization');
91+
expect('OpenAI-Project'.toLocaleLowerCase('tr-TR')).toBe('openaı-project');
92+
93+
const result = buildHeaders([
94+
{
95+
'OpenAI-Organization': 'org_test',
96+
'OpenAI-Project': 'proj_test',
97+
},
98+
]);
99+
100+
const keys = [...result.values.keys()];
101+
expect(keys).toEqual(['openai-organization', 'openai-project']);
102+
expect(keys.every((key) => /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(key))).toBe(true);
103+
});
104+
105+
test('rejects header names that lowercase into valid ASCII tokens', () => {
106+
expect('cooKie'.toLowerCase()).toBe('cookie');
107+
expect(() => buildHeaders([{ cooKie: 'value' }])).toThrow(
108+
'Header name must be a valid HTTP token ["cooKie"]',
109+
);
110+
});
111+
112+
test('passes normalized OpenAI header names to Headers implementations', () => {
113+
const OriginalHeaders = globalThis.Headers;
114+
115+
class LocaleSensitiveHeaders {
116+
#values = new Map<string, string>();
117+
118+
append(name: string, value: string) {
119+
this.#values.set(normalizeHeaderName(name), value);
120+
}
121+
122+
delete(name: string) {
123+
this.#values.delete(normalizeHeaderName(name));
124+
}
125+
126+
entries() {
127+
return this.#values.entries();
128+
}
129+
130+
keys() {
131+
return this.#values.keys();
132+
}
133+
}
134+
135+
const normalizeHeaderName = (name: string) => {
136+
const normalized = name.toLocaleLowerCase('tr-TR');
137+
if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(normalized)) {
138+
throw new TypeError(`Header name must be a valid HTTP token ["${normalized}"]`);
139+
}
140+
return normalized;
141+
};
142+
143+
globalThis.Headers = LocaleSensitiveHeaders as unknown as typeof Headers;
144+
try {
145+
const result = buildHeaders([
146+
{
147+
'OpenAI-Organization': 'org_test',
148+
'OpenAI-Project': 'proj_test',
149+
},
150+
]);
151+
152+
expect([...result.values.entries()]).toEqual([
153+
['openai-organization', 'org_test'],
154+
['openai-project', 'proj_test'],
155+
]);
156+
} finally {
157+
globalThis.Headers = OriginalHeaders;
158+
}
159+
});
88160
});

0 commit comments

Comments
 (0)