From 425fe4c5cf4b7aa9d099ffb5b6b7ff24a94cd5fe Mon Sep 17 00:00:00 2001 From: Lasha Japaridze Date: Tue, 7 Jul 2026 02:02:56 +0100 Subject: [PATCH] fix: sanitize invalid characters in proxied response headers An upstream reply can contain header values with characters that are invalid in an outgoing HTTP header (control characters, or code points above 0xFF). Copying such a value onto the downstream reply throws 'Invalid character in header content' and fails the entire request. Strip those characters from response header values (strings and arrays such as set-cookie) in rewriteHeaders, so proxying degrades gracefully instead of crashing. Closes #343 --- index.js | 7 ++++++- test/utils.js | 29 ++++++++++++++++++++++++++++- utils.js | 25 ++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index b36b6e7..c5f0947 100644 --- a/index.js +++ b/index.js @@ -3,7 +3,7 @@ const { setTimeout: wait } = require('node:timers/promises') const From = require('@fastify/reply-from') const { ServerResponse } = require('node:http') const WebSocket = require('ws') -const { convertUrlToWebSocket } = require('./utils') +const { convertUrlToWebSocket, sanitizeHeaders } = require('./utils') const fp = require('fastify-plugin') const qs = require('fast-querystring') const { validateOptions } = require('./src/options') @@ -558,6 +558,11 @@ async function fastifyHttpProxy (fastify, opts) { if (oldRewriteHeaders) { headers = oldRewriteHeaders(headers, req) } + + // Sanitize header values so an upstream reply with an invalid header + // does not crash the whole request. + headers = sanitizeHeaders(headers) + return headers } diff --git a/test/utils.js b/test/utils.js index 4c25c00..a703795 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,7 +1,7 @@ 'use strict' const { test } = require('node:test') -const { convertUrlToWebSocket } = require('../utils') +const { convertUrlToWebSocket, sanitizeHeaderValue, sanitizeHeaders } = require('../utils') test('convertUrlToWebSocket', function (t) { const testData = [ @@ -18,3 +18,30 @@ test('convertUrlToWebSocket', function (t) { t.assert.strictEqual(convertUrlToWebSocket(input), expected) } }) + +test('sanitizeHeaderValue', function (t) { + const testData = [ + { input: 'application/json', expected: 'application/json' }, + { input: 'a\tb', expected: 'a\tb' }, + { input: 'café', expected: 'café' }, + { input: 'a\x01b', expected: 'ab' }, + { input: 'hi😀there', expected: 'hithere' }, + { input: '€9', expected: '9' } + ] + t.plan(testData.length) + for (const { input, expected } of testData) { + t.assert.strictEqual(sanitizeHeaderValue(input), expected) + } +}) + +test('sanitizeHeaders', function (t) { + t.plan(3) + // string values are sanitized; array values (e.g. set-cookie) map element-wise; + // non-string values (numbers, non-string array items) are left untouched. + t.assert.deepStrictEqual( + sanitizeHeaders({ 'content-type': 'text/html😀', 'set-cookie': ['a=1\x01', 'b=2'] }), + { 'content-type': 'text/html', 'set-cookie': ['a=1', 'b=2'] } + ) + t.assert.deepStrictEqual(sanitizeHeaders({ 'content-length': 42 }), { 'content-length': 42 }) + t.assert.deepStrictEqual(sanitizeHeaders({ mixed: ['x\x01', 7] }), { mixed: ['x', 7] }) +}) diff --git a/utils.js b/utils.js index a9c069d..4857eca 100644 --- a/utils.js +++ b/utils.js @@ -4,6 +4,29 @@ function convertUrlToWebSocket (urlString) { return urlString.replace(/^(http)(s)?:\/\//, 'ws$2://') } +// Strip characters that are not valid in an HTTP header value, so proxying a +// reply with an invalid upstream header does not throw. +// Allowed: HTAB (0x09), SP (0x20)-~ (0x7E), and obs-text (0x80-0xFF). +function sanitizeHeaderValue (value) { + return value.replace(/[^\t\x20-\x7E\x80-\xFF]/g, '') +} + +// Sanitize every header value in place. Values may be strings or arrays of +// strings (e.g. set-cookie); anything else is left untouched. +function sanitizeHeaders (headers) { + for (const key in headers) { + const value = headers[key] + if (typeof value === 'string') { + headers[key] = sanitizeHeaderValue(value) + } else if (Array.isArray(value)) { + headers[key] = value.map((v) => (typeof v === 'string' ? sanitizeHeaderValue(v) : v)) + } + } + return headers +} + module.exports = { - convertUrlToWebSocket + convertUrlToWebSocket, + sanitizeHeaderValue, + sanitizeHeaders }