Skip to content

Commit 062a104

Browse files
committed
fix: case insensitive headers
1 parent 001e3da commit 062a104

3 files changed

Lines changed: 60 additions & 21 deletions

File tree

src/headers.ts

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -57,65 +57,69 @@ export interface MhtmlHeaders extends Iterable<[string, string]> {
5757
}
5858

5959
export class Headers implements MhtmlHeaders {
60-
#raw = new Map<string, string[]>();
60+
// keyed by lowercased name; the original name casing (first seen) is
61+
// preserved for iteration while lookups are case-insensitive, per RFC 5322
62+
#raw = new Map<string, { key: string; values: string[] }>();
6163

6264
[Symbol.iterator](): Iterator<[string, string]> {
6365
return this.entries();
6466
}
6567

6668
append(key: string, value: string): void {
67-
const list = this.#raw.get(key);
68-
if (list === undefined) {
69-
this.#raw.set(key, [value]);
69+
const entry = this.#raw.get(key.toLowerCase());
70+
if (entry === undefined) {
71+
this.#raw.set(key.toLowerCase(), { key, values: [value] });
7072
} else {
71-
list.push(value);
73+
entry.values.push(value);
7274
}
7375
}
7476

7577
*entries(delim: string = ", "): IterableIterator<[string, string]> {
76-
for (const [key, values] of this.#raw.entries()) {
78+
for (const { key, values } of this.#raw.values()) {
7779
yield [key, values.join(delim)];
7880
}
7981
}
8082

8183
*entriesAll(): IterableIterator<[string, string]> {
82-
for (const [key, values] of this.#raw.entries()) {
84+
for (const { key, values } of this.#raw.values()) {
8385
for (const val of values) {
8486
yield [key, val];
8587
}
8688
}
8789
}
8890

8991
get(key: string, delim: string = ", "): string | null {
90-
const vals = this.#raw.get(key);
91-
if (vals === undefined) {
92+
const entry = this.#raw.get(key.toLowerCase());
93+
if (entry === undefined) {
9294
return null;
9395
} else {
94-
return vals.join(delim);
96+
return entry.values.join(delim);
9597
}
9698
}
9799

98100
getAll(key: string): string[] {
99-
return this.#raw.get(key) ?? [];
101+
return this.#raw.get(key.toLowerCase())?.values ?? [];
100102
}
101103

102104
has(key: string): boolean {
103-
return this.#raw.has(key);
105+
return this.#raw.has(key.toLowerCase());
104106
}
105107

106-
keys(): Iterable<string> {
107-
return this.#raw.keys();
108+
*keys(): IterableIterator<string> {
109+
for (const { key } of this.#raw.values()) {
110+
yield key;
111+
}
108112
}
109113

110114
*values(delim: string = ", "): IterableIterator<string> {
111-
for (const vals of this.#raw.values()) {
112-
yield vals.join(delim);
115+
for (const { values } of this.#raw.values()) {
116+
yield values.join(delim);
113117
}
114118
}
115119

116120
*valuesAll(): IterableIterator<string> {
117-
for (const vals of this.#raw.values()) {
118-
yield* vals;
121+
for (const { values } of this.#raw.values()) {
122+
yield* values;
119123
}
120124
}
121125
}

src/index.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,35 @@ Subject: =?iso-8859-1?Q?=A1Hola,\xffse=F1or!?=
155155
);
156156
});
157157

158+
test("matches header names and encodings case-insensitively", async () => {
159+
const content = `mime-version: 1.0
160+
content-type: MULTIPART/mixed; BOUNDARY=frontier
161+
162+
--frontier
163+
content-type: application/octet-stream
164+
content-transfer-encoding: Base64
165+
166+
aGVsbG8gd29ybGQ=
167+
--frontier--
168+
`;
169+
const files = [];
170+
for await (const file of parseMhtml(stringToStream(content))) {
171+
files.push(file);
172+
}
173+
const headers = files.map(({ headers }) => Object.fromEntries(headers));
174+
expect(headers).toEqual([
175+
{
176+
"mime-version": "1.0",
177+
"content-type": "MULTIPART/mixed; BOUNDARY=frontier",
178+
},
179+
{
180+
"content-type": "application/octet-stream",
181+
"content-transfer-encoding": "Base64",
182+
},
183+
]);
184+
expect(decoder.decode(files[1]!.content)).toStrictEqual("hello world");
185+
});
186+
158187
test("accepts headers without a space after the colon", async () => {
159188
const content = `MIME-Version:1.0
160189
Content-Type:multipart/mixed; boundary=frontier

src/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,12 @@ function getBoundary(headers: MhtmlHeaders): [Uint8Array, Uint8Array] {
161161
let bound: string | undefined;
162162
let multipart = false;
163163
for (const field of contentType.split(/;\s*/)) {
164-
if (field.startsWith("multipart/")) {
164+
// the media type and the boundary parameter name are case-insensitive, but
165+
// the boundary value itself is not, so match on a lowercased copy
166+
const lower = field.toLowerCase();
167+
if (lower.startsWith("multipart/")) {
165168
multipart = true;
166-
} else if (field.startsWith("boundary=")) {
169+
} else if (lower.startsWith("boundary=")) {
167170
bound = field.slice(9);
168171
// TODO handling of quoted fields is not great
169172
if (bound.startsWith('"') && bound.endsWith('"')) {
@@ -216,7 +219,10 @@ export async function* parseMhtml(
216219
const headers = await parseHeaders(lines);
217220
const [boundary, terminus] = bound ?? (bound = getBoundary(headers));
218221

219-
const encoding = headers.get("Content-Transfer-Encoding") ?? "7bit";
222+
// Content-Transfer-Encoding token values are case-insensitive (RFC 2045)
223+
const encoding = (
224+
headers.get("Content-Transfer-Encoding") ?? "7bit"
225+
).toLowerCase();
220226
const decode = decoders.get(encoding);
221227
if (decode === undefined) {
222228
throw new Error(`unhandled encoding type: ${encoding}`);

0 commit comments

Comments
 (0)