Skip to content

Repeated response headers are handled inconsistently across HTTP/1.1 and HTTP/2 #5898

Open

Description

@koal44

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 rawHeaders to downstream consumers.
  • Remove Content-Encoding from Node's kSingleValueFields.
  • Apply Fetch's rules: reject duplicate Location fields during redirects; select the last usable Content-Type.
  • Allow HSTS consumers to process only the first Strict-Transport-Security field.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions