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 }