Repository navigation
Repeated response headers are handled inconsistently across HTTP/1.1 and HTTP/2 #5898
Copy link
Copy link
Open
Description
I noticed some issues with repeated response headers. Node's HTTP/2 toHeaderObject() treats Content-Encoding as a single-value field, although it is list-valued, so subsequent values are discarded.
More generally, combining or discarding repeated fields can hide information consumers need. Yes, RFC 7230 §3.2.2 permits combining fields when it preserves their meaning, but different fields require different handling. For example, HSTS processes only the first field, while Fetch’s MIME extraction selects the last usable type.
This made me curious about Undici's handling, where I found some edge case inconsistencies demonstrated in this unit test:
import assert from 'node:assert/strict';
import { createServer as createHTTP1Server } from 'node:http';
import { createServer as createHTTP2Server } from 'node:http2';
import { test } from 'node:test';
import { brotliCompressSync, gzipSync } from 'node:zlib';
import { Client, fetch } from 'undici';
const plain = 'some plain text';
const compressed = brotliCompressSync(gzipSync(plain));
const readText = r => r.text();
const readBlobType = async r => (await r.blob()).type;
const onError = () => 'network error';
const cases = [
{ path: '/combined', headers: { 'content-encoding': 'gzip, br' },
status: 200, body: compressed, read: readText, expected: plain },
{ path: '/split', headers: { 'content-encoding': ['gzip', 'br'] },
status: 200, body: compressed, read: readText, expected: plain },
{ path: '/type', headers: { 'content-type': ['text/plain', 'text/html'] },
status: 200, body: plain, read: readBlobType, expected: 'text/html' },
{ path: '/location', headers: { location: ['/A', '/B'] },
status: 302, body: plain, read: readText, expected: 'network error' },
];
test('HTTP/1.1 vs HTTP/2 response headers', async (t) => {
const http1Server = createHTTP1Server();
const http2Server = createHTTP2Server({ strictSingleValueFields: false });
const [http1Origin, http2Origin] = await Promise.all([http1Server, http2Server].map(server => {
server.on('request', (request, response) => {
const testCase = cases.find(c => c.path === request.url);
const { status, headers, body } = testCase
?? { status: 200, headers: {}, body: request.url }; // Echo redirects.
response.writeHead(status, headers);
response.end(body);
});
return new Promise(resolve => server.listen(0, '127.0.0.1', () => {
resolve(`http://127.0.0.1:${server.address().port}`);
}));
}));
const http1Client = new Client(http1Origin, { allowH2: false });
const http2Client = new Client(http2Origin, { h2Options: { useH2c: true } });
t.after(async () => {
await Promise.all([http1Client.destroy(), http2Client.destroy()]);
for (const server of [http1Server, http2Server]) server.close();
});
const results = [];
for (const { path, read, expected } of cases) {
const http1 = await fetch(http1Origin + path, { dispatcher: http1Client }).then(read).catch(onError);
const http2 = await fetch(http2Origin + path, { dispatcher: http2Client }).then(read).catch(onError);
results.push({ path, expected, http1, http2 });
}
console.log({ node: process.version });
console.table(results);
assert.ok(results.every(({ expected, http1, http2 }) => http1 === expected && http2 === expected));
});> node --test ./undici-test.mjs
{ node: 'v24.19.0' }
┌─────────┬─────────────┬───────────────────┬───────────────────┬───────────────────┐
│ (index) │ path │ expected │ http1 │ http2 │
├─────────┼─────────────┼───────────────────┼───────────────────┼───────────────────┤
│ 0 │ '/combined' │ 'some plain text' │ 'some plain text' │ 'some plain text' │
│ 1 │ '/split' │ 'some plain text' │ 'some plain text' │ 'network error' │
│ 2 │ '/type' │ 'text/html' │ 'text/html' │ 'text/plain' │
│ 3 │ '/location' │ 'network error' │ '/A,%20/B' │ '/A' │
└─────────┴─────────────┴───────────────────┴───────────────────┴───────────────────┘
Suggested changes:
- Forward HTTP/2
rawHeadersto downstream consumers. - Remove
Content-Encodingfrom Node'skSingleValueFields. - Apply Fetch's rules: reject duplicate
Locationfields during redirects; select the last usableContent-Type. - Allow HSTS consumers to process only the first
Strict-Transport-Securityfield.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity