From e09b37c6a8e110201f50a63625483d73ccd1b2ae Mon Sep 17 00:00:00 2001 From: divdavem Date: Thu, 28 Nov 2024 10:00:47 +0100 Subject: [PATCH] fix: ERR_HTTP2_HEADER_SINGLE_VALUE error (#494) --- packages/app/server/response/impl.ts | 12 +++++-- packages/e2e/use-cases.js | 48 ++++++++++++++++++++++++++++ packages/lib/headers.ts | 44 +++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/packages/app/server/response/impl.ts b/packages/app/server/response/impl.ts index 8bc82545..208a17e6 100644 --- a/packages/app/server/response/impl.ts +++ b/packages/app/server/response/impl.ts @@ -4,7 +4,7 @@ import { ServerResponse, IncomingHttpHeaders } from 'http'; // ---------------------------------------------------------------------- common -import { headersContainer } from '../../../lib/headers'; +import { headersContainer, singleValueHeaders } from '../../../lib/headers'; import { stringifyPretty } from '../../../lib/json'; import { UserProperty } from '../../../lib/user-property'; import { logError } from '../../logger'; @@ -104,13 +104,19 @@ export class Response implements IResponse { code = CONF.defaultStatusCode; } const { message } = status; + const http2 = response instanceof Http2ServerResponse; Object.entries(headers) .map(([key, value]) => ({ key, value })) .filter((header) => header.value != null) .forEach((header) => { try { - response.setHeader(header.key, header.value!); + const { key, value } = header; + if (http2 && Array.isArray(value) && singleValueHeaders.has(key.toLowerCase())) { + response.setHeader(key, value[0]); + } else { + response.setHeader(key, value!); + } } catch (exception) { logError({ message: `${CONF.messages.setHeaderError}\n${JSON.stringify(header)}`, @@ -118,7 +124,7 @@ export class Response implements IResponse { }); } }); - if (response instanceof Http2ServerResponse) { + if (http2) { response.writeHead(code); } else { response.writeHead(code, message); diff --git a/packages/e2e/use-cases.js b/packages/e2e/use-cases.js index 0ef89e89..1f44d2b1 100644 --- a/packages/e2e/use-cases.js +++ b/packages/e2e/use-cases.js @@ -1397,6 +1397,54 @@ const useCases = [ }, }, + { + name: 'http2-header-single-value', + iterations: 1, + + async nodeRequest({ proxyPort, iteration }) { + const http2 = require('http2'); + const connection = http2.connect(`https://localhost:${proxyPort}`, { + rejectUnauthorized: false, + }); + const request = connection.request({ + [http2.constants.HTTP2_HEADER_METHOD]: 'GET', + [http2.constants.HTTP2_HEADER_PATH]: '/', + }); + request.end(); + const response = await new Promise((resolve) => request.on('response', resolve)); + let data = ''; + request.on('data', (chunk) => (data += chunk.toString('utf8'))); + await new Promise((resolve) => request.on('end', resolve)); + await new Promise((resolve) => connection.close(resolve)); + return { + proxyPort, + data, + headers: response, + }; + }, + + serve: async ({ req, response }) => { + response.set('Content-Type', 'text/plain'); + response.set('X-Content-Type-Options', ['nosniff', 'nosniff']); + response.set('Access-Control-Allow-Origin', '*'); + response.status = 200; + response.body = 'ok'; + }, + + proxy: async ({ mock }) => { + mock.setMode('remote'); + }, + + defineAssertions: ({ it, getData, expect }) => { + it('checks response headers', () => { + const { data } = getData(0); + expect(data.client.data).to.equal('ok'); + console.log(data.client.headers); + expect(data.client.headers['x-content-type-options']).to.equal('nosniff'); + }); + }, + }, + { name: 'http2-client', description: 'http2 client test', diff --git a/packages/lib/headers.ts b/packages/lib/headers.ts index 854cf880..33fc7421 100644 --- a/packages/lib/headers.ts +++ b/packages/lib/headers.ts @@ -1,5 +1,49 @@ import { IncomingHttpHeaders } from 'http'; +// copied from https://github.com/nodejs/node/blob/db8ff56629e74e8c997947b8d3960db64c1ce4f9/lib/internal/http2/util.js#L113C1-L154 +export const singleValueHeaders = new Set([ + ':status', + ':method', + ':authority', + ':scheme', + ':path', + ':protocol', + 'access-control-allow-credentials', + 'access-control-max-age', + 'access-control-request-method', + 'age', + 'authorization', + 'content-encoding', + 'content-language', + 'content-length', + 'content-location', + 'content-md5', + 'content-range', + 'content-type', + 'date', + 'dnt', + 'etag', + 'expires', + 'from', + 'host', + 'if-match', + 'if-modified-since', + 'if-none-match', + 'if-range', + 'if-unmodified-since', + 'last-modified', + 'location', + 'max-forwards', + 'proxy-authorization', + 'range', + 'referer', + 'retry-after', + 'tk', + 'upgrade-insecure-requests', + 'user-agent', + 'x-content-type-options', +]); + /** * A map from strings to strings or array of strings *