From e05c561bd30dc451d584c7503a1ea6b76cb99d22 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:34:20 +0000 Subject: [PATCH 1/7] test: add failing regression tests for functional bugs (RED) Regression tests for bugs confirmed by an adversarial review panel: routing/url rewriting, query strings, timeouts, error hooks, route config mutation, default http/lambda response hooks, hostnames hook and the WebSocket proxy. The large chunked "Connection: close" smoke test now expects the response to be streamed instead of rejected with 502. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0134bwfxe5igRZeU6cwBSfzE --- test/default-hooks.test.js | 153 +++++++++++++++ test/hostnames-hook.test.js | 53 ++++++ test/raw-http.js | 41 ++++ test/routing.test.js | 364 ++++++++++++++++++++++++++++++++++++ test/smoke.test.js | 13 +- test/ws-proxy-bugs.test.js | 300 +++++++++++++++++++++++++++++ 6 files changed, 922 insertions(+), 2 deletions(-) create mode 100644 test/default-hooks.test.js create mode 100644 test/raw-http.js create mode 100644 test/routing.test.js create mode 100644 test/ws-proxy-bugs.test.js diff --git a/test/default-hooks.test.js b/test/default-hooks.test.js new file mode 100644 index 0000000..0c43cae --- /dev/null +++ b/test/default-hooks.test.js @@ -0,0 +1,153 @@ +'use strict' + +/* global describe, it, before, after */ +const expect = require('chai').expect +const request = require('supertest') +const http = require('http') +const fastGateway = require('../index') +const rawRequest = require('./raw-http') + +const UPSTREAM_PORT = 9551 +const GATEWAY_PORT = 9552 + +const binaryParser = (res, cb) => { + const chunks = [] + res.on('data', (chunk) => chunks.push(chunk)) + res.on('end', () => cb(null, Buffer.concat(chunks))) +} + +const lambdaRoute = (prefix, payload) => ({ + prefix, + proxyType: 'lambda', + target: 'a-lambda-function', + proxyConfig: { + lambdaProxy: (params, cb) => setImmediate(() => cb(null, { + StatusCode: 200, + Payload: JSON.stringify(payload) + })) + } +}) + +describe('default hooks', () => { + let gateway, upstream + const upstreamClosed = [] + const pending = [] + + before(async () => { + upstream = http.createServer((req, res) => { + // the gateway always appends '?' when forwarding req.query, ignore it + req.url = req.url.replace(/\?$/, '') + if (req.url === '/large-chunked') { + res.setHeader('content-type', 'application/octet-stream') + res.write(Buffer.alloc(2 * 1024 * 1024, 'x')) + res.end() + } else if (req.url === '/endless') { + res.on('close', () => upstreamClosed.push(req.url)) + const timer = setInterval(() => res.write('data: tick\n\n'), 20) + res.on('close', () => clearInterval(timer)) + pending.push(res) + } else if (req.url === '/gzip-aborted') { + res.writeHead(200, { 'content-type': 'application/json', 'content-encoding': 'gzip' }) + res.write(Buffer.from([0x1f, 0x8b])) + setTimeout(() => res.destroy(), 20) + } + }) + await new Promise((resolve) => upstream.listen(UPSTREAM_PORT, resolve)) + + gateway = fastGateway({ + routes: [ + { prefix: '/http', target: `http://127.0.0.1:${UPSTREAM_PORT}` }, + lambdaRoute('/lambda-no-status-code', { body: 'hello' }), + lambdaRoute('/lambda-object-body', { statusCode: 201, body: { name: 'fast-gateway' } }), + lambdaRoute('/lambda-base64', { + statusCode: 200, + headers: { 'content-type': 'image/png' }, + isBase64Encoded: true, + body: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff]).toString('base64') + }) + ] + }) + await gateway.start(GATEWAY_PORT) + }) + + after(async () => { + pending.forEach((res) => res.destroy()) + await gateway.close() + upstream.closeAllConnections() + await new Promise((resolve) => upstream.close(resolve)) + }) + + describe('http', () => { + it('(Connection: close) chunked response above the buffer limit is streamed', async () => { + await request(gateway) + .get('/http/large-chunked') + .set('Connection', 'close') + .buffer(true) + .parse(binaryParser) + .expect(200) + .then((response) => { + expect(response.body.length).to.equal(2 * 1024 * 1024) + }) + }) + + it('(Connection: close) upstream is released when the client goes away while buffering', async () => { + await new Promise((resolve) => { + const req = http.get({ + port: GATEWAY_PORT, + path: '/http/endless', + headers: { Connection: 'close' } + }) + req.on('error', () => {}) + setTimeout(() => { + req.destroy() + resolve() + }, 100) + }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + expect(upstreamClosed).to.deep.equal(['/endless']) + }) + + it('(Connection: close) failed upstream body does not leak upstream headers', async () => { + const response = await rawRequest(GATEWAY_PORT, '/http/gzip-aborted') + + expect(response.statusCode).to.equal(500) + expect(response.headers['content-encoding']).to.equal(undefined) + }) + }) + + describe('lambda', () => { + it('defaults the status code when the payload has none', async () => { + await request(gateway) + .get('/lambda-no-status-code/info') + .timeout(1000) + .expect(200) + .then((response) => { + expect(response.text).to.equal('hello') + }) + }) + + it('serializes non-string bodies', async () => { + await request(gateway) + .get('/lambda-object-body/info') + .timeout(1000) + .expect(201) + .then((response) => { + expect(response.text).to.equal('{"name":"fast-gateway"}') + }) + }) + + it('decodes base64 encoded bodies', async () => { + await request(gateway) + .get('/lambda-base64/info') + .timeout(1000) + .buffer(true) + .parse(binaryParser) + .expect(200) + .then((response) => { + expect(response.headers['content-type']).to.equal('image/png') + expect(response.body.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff]))).to.equal(true) + }) + }) + }) +}) diff --git a/test/hostnames-hook.test.js b/test/hostnames-hook.test.js index d091799..807be43 100644 --- a/test/hostnames-hook.test.js +++ b/test/hostnames-hook.test.js @@ -78,4 +78,57 @@ describe('hostnames-hook', () => { cb() }) }) + + it('is match - host header is case-insensitive', (cb) => { + const req = { + headers: { + host: 'NodeJS.ORG:443' + }, + url: '/about' + } + + hostnamesHook(req, null, () => { + expect(req.url).to.equal('/nodejs/about') + cb() + }) + }) + + it('is not match - host named like an object property', () => { + for (const host of ['constructor', '__proto__', 'toString']) { + const req = { + headers: { host }, + url: '/about' + } + + hostnamesHook(req, null, () => {}) + expect(req.url).to.equal('/about') + } + }) + + it('does not retain memory for each distinct matching host', () => { + const v8 = require('v8') + const vm = require('vm') + v8.setFlagsFromString('--expose-gc') + const gc = vm.runInNewContext('gc') + + const hook = require('./../lib/hostnames-hook')([ + { + prefix: '/users', + hostname: '*.company.tld' + } + ]) + const label = 'x'.repeat(1000) + + gc() + const heapBefore = process.memoryUsage().heapUsed + for (let i = 0; i < 20000; i++) { + hook({ headers: { host: `${label}${i}.company.tld` }, url: '/' }, null, () => {}) + } + gc() + const retained = process.memoryUsage().heapUsed - heapBefore + + // keep the hook (and anything it holds) reachable until after the measurement + hook({ headers: { host: 'api.company.tld' }, url: '/' }, null, () => {}) + expect(retained).to.be.below(5 * 1024 * 1024) + }) }) diff --git a/test/raw-http.js b/test/raw-http.js new file mode 100644 index 0000000..5a33221 --- /dev/null +++ b/test/raw-http.js @@ -0,0 +1,41 @@ +'use strict' + +const net = require('net') + +// sends a raw HTTP/1.1 request so the request target is not normalized by the client +module.exports = (port, path, { method = 'GET', headers = {}, timeout = 2000 } = {}) => + new Promise((resolve, reject) => { + const socket = net.connect(port, '127.0.0.1') + const chunks = [] + const lines = Object.entries({ Host: '127.0.0.1:' + port, Connection: 'close', ...headers }) + .map(([name, value]) => `${name}: ${value}`) + + const timer = setTimeout(() => { + socket.destroy() + reject(new Error('raw request timed out')) + }, timeout) + + socket.on('connect', () => socket.write(`${method} ${path} HTTP/1.1\r\n${lines.join('\r\n')}\r\n\r\n`)) + socket.on('data', (chunk) => chunks.push(chunk)) + socket.on('error', (err) => { + clearTimeout(timer) + reject(err) + }) + socket.on('close', () => { + clearTimeout(timer) + const data = Buffer.concat(chunks).toString() + const index = data.indexOf('\r\n\r\n') + const head = data.slice(0, index).split('\r\n') + const headers = {} + head.slice(1).forEach((line) => { + const separator = line.indexOf(':') + headers[line.slice(0, separator).trim().toLowerCase()] = line.slice(separator + 1).trim() + }) + + resolve({ + statusCode: parseInt(head[0].split(' ')[1], 10), + headers, + body: data.slice(index + 4) + }) + }) + }) diff --git a/test/routing.test.js b/test/routing.test.js new file mode 100644 index 0000000..80c1904 --- /dev/null +++ b/test/routing.test.js @@ -0,0 +1,364 @@ +'use strict' + +/* global describe, it, before, after */ +const expect = require('chai').expect +const request = require('supertest') +const http = require('http') +const fastGateway = require('../index') +const rawRequest = require('./raw-http') + +const UPSTREAM_PORT = 9501 +const target = `http://127.0.0.1:${UPSTREAM_PORT}` + +describe('routing', () => { + let upstream + const gateways = [] + const unhandled = [] + const onUnhandled = (reason) => unhandled.push(reason) + + const start = async (opts, port) => { + const gateway = fastGateway(opts) + await gateway.start(port) + gateways.push(gateway) + + return gateway + } + + before(async () => { + process.on('unhandledRejection', onUnhandled) + + upstream = http.createServer((req, res) => { + const reply = () => { + res.setHeader('content-type', 'application/json') + // the gateway always appends '?' when forwarding req.query, ignore it + res.end(JSON.stringify({ method: req.method, url: req.url.replace(/\?$/, '') })) + } + if (req.url.startsWith('/slow')) { + setTimeout(reply, 300) + } else { + reply() + } + }) + await new Promise((resolve) => upstream.listen(UPSTREAM_PORT, resolve)) + }) + + after(async () => { + process.removeListener('unhandledRejection', onUnhandled) + + for (const gateway of gateways) { + await gateway.close() + } + upstream.closeAllConnections() + await new Promise((resolve) => upstream.close(resolve)) + }) + + describe('error handling', () => { + let gateway + const onErrorCalls = [] + + before(async () => { + gateway = await start({ + routes: [ + { + prefix: '/on-error-responds', + target, + hooks: { + async onRequest (req) { + throw Object.assign(new Error('unauthorized'), { status: 401 }) + }, + onError (err, req, res) { + res.statusCode = err.status + res.end(JSON.stringify({ error: err.message })) + } + } + }, + { + prefix: '/async-proxy-handler', + target, + proxyHandler: async () => { + throw new Error('proxy handler failed') + }, + hooks: { + onError (err) { + onErrorCalls.push(err) + } + } + } + ] + }, 9502) + }) + + it('onError hook sending its own response does not trigger a second response', async () => { + unhandled.length = 0 + + await request(gateway) + .get('/on-error-responds/info') + .expect(401) + .then((response) => { + expect(JSON.parse(response.text)).to.deep.equal({ error: 'unauthorized' }) + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(unhandled.map((err) => err.code)).to.deep.equal([]) + }) + + it('rejecting async proxyHandler is routed to onError and answered with 500', async () => { + onErrorCalls.length = 0 + + await request(gateway) + .get('/async-proxy-handler/info') + .timeout(1000) + .expect(500) + + expect(onErrorCalls).to.have.lengthOf(1) + expect(onErrorCalls[0].message).to.equal('proxy handler failed') + }) + }) + + describe('url rewriting', () => { + let gateway + + before(async () => { + gateway = await start({ + routes: [ + { prefix: '/public', prefixRewrite: '/api/public', target }, + { prefix: '/users', target }, + { prefix: '/internal', prefixRewrite: '/v$&', target }, + { prefix: '/', target } + ] + }, 9503) + }) + + it('forwards regular paths with the prefix rewritten', async () => { + await request(gateway) + .get('/public/docs') + .expect(200) + .then((response) => { + expect(response.body.url).to.equal('/api/public/docs') + }) + }) + + for (const path of [ + '/public/../../admin/users', + '/public/%2e%2e/%2e%2e/admin/users', + '/public/.%2E/admin/users', + '/public/..\\..\\admin/users' + ]) { + it(`rejects dot-segment traversal: ${path}`, async () => { + const response = await rawRequest(9503, path) + expect(response.statusCode).to.equal(400) + }) + } + + it('strips the prefix case-insensitively, matching how routes are matched', async () => { + await request(gateway) + .get('/Users/42') + .expect(200) + .then((response) => { + expect(response.body.url).to.equal('/42') + }) + }) + + it('only strips the prefix from the beginning of the url', async () => { + await request(gateway) + .get('/USERS/a/users/b') + .expect(200) + .then((response) => { + expect(response.body.url).to.equal('/a/users/b') + }) + }) + + it('uses prefixRewrite literally (no replacement patterns)', async () => { + await request(gateway) + .get('/internal/info') + .expect(200) + .then((response) => { + expect(response.body.url).to.equal('/v$&/info') + }) + }) + + it('"/" prefix acts as a catch-all route', async () => { + await request(gateway) + .get('/foo/bar') + .expect(200) + .then((response) => { + expect(response.body.url).to.equal('/foo/bar') + }) + }) + }) + + describe('trailing slash prefix', () => { + let gateway + + before(async () => { + gateway = await start({ + routes: [{ prefix: '/svc/', prefixRewrite: '/api', target }] + }, 9504) + }) + + it('matches nested paths and rewrites them', async () => { + await request(gateway) + .get('/svc/foo') + .expect(200) + .then((response) => { + expect(response.body.url).to.equal('/api/foo') + }) + }) + }) + + describe('query string', () => { + let gateway + + before(async () => { + gateway = await start({ + routes: [ + { prefix: '/svc', target }, + { + prefix: '/set-query', + target, + hooks: { + onRequest (req) { + req.query.name = 'fast-gateway' + } + } + } + ] + }, 9505) + }) + + it('keeps repeated query parameters', async () => { + await request(gateway) + .get('/svc/items?a=1&a=2&b=3') + .expect(200) + .then((response) => { + expect(response.body.url).to.equal('/items?a=1&a=2&b=3') + }) + }) + + it('allows onRequest to set req.query when the request has no query string', async () => { + await request(gateway) + .get('/set-query/items') + .expect(200) + .then((response) => { + expect(response.body.url).to.equal('/items?name=fast-gateway') + }) + }) + }) + + describe('timeouts', () => { + let gateway + + before(async () => { + gateway = await start({ + timeout: 100, + routes: [ + { + prefix: '/request-opts', + target, + hooks: { + request: { family: 4 } + } + }, + { + prefix: '/disabled', + timeout: 0, + target + } + ] + }, 9506) + }) + + it('keeps the route timeout when hooks.request is set', async () => { + await request(gateway).get('/request-opts/slow').expect(504) + }) + + it('timeout: 0 disables the global timeout for a route', async () => { + await request(gateway).get('/disabled/slow').expect(200) + }) + }) + + describe('route configuration objects', () => { + it('shared hooks object between http and lambda routes', async () => { + const hooks = {} + const gateway = await start({ + routes: [ + { prefix: '/http', target, hooks }, + { + prefix: '/lambda', + proxyType: 'lambda', + target: 'a-lambda-function', + proxyConfig: { + lambdaProxy: (params, cb) => setImmediate(() => cb(null, { + StatusCode: 200, + Payload: JSON.stringify({ statusCode: 200, body: 'from lambda' }) + })) + }, + hooks + } + ] + }, 9507) + + await request(gateway) + .get('/http/info') + .expect(200) + .then((response) => { + expect(response.body.url).to.equal('/info') + }) + + await request(gateway) + .get('/lambda/info') + .timeout(1000) + .expect(200) + .then((response) => { + expect(response.text).to.equal('from lambda') + }) + + expect(hooks).to.deep.equal({}) + }) + + it('reusing routes for another gateway does not leak settings', async () => { + const routes = [{ prefix: '/svc', target }] + await start({ timeout: 100, routes }, 9508) + const gateway = await start({ routes }, 9509) + + await request(gateway).get('/svc/slow').expect(200) + }) + + it('supports the documented "http-legacy" proxy type', async () => { + const fastProxyLite = require('fast-proxy-lite') + const gateway = await start({ + proxyFactory: ({ proxyType, route }) => { + if (proxyType === 'http-legacy') { + return fastProxyLite({ base: route.target }).proxy + } + }, + routes: [{ prefix: '/legacy', proxyType: 'http-legacy', target }] + }, 9510) + + await request(gateway) + .get('/legacy/info') + .timeout(1000) + .expect(200) + .then((response) => { + expect(response.body.url).to.equal('/info') + }) + + expect(() => fastGateway({ + routes: [{ prefix: '/legacy', proxyType: 'http-legacy', target }] + })).to.not.throw(/Unsupported proxy type/) + }) + + it('services.json describes RegExp prefixes', async () => { + const gateway = await start({ + routes: [{ prefix: /\/regex\/.*/, target }] + }, 9511) + + await request(gateway) + .get('/services.json') + .expect(200) + .then((response) => { + expect(response.body).to.deep.equal([{ prefix: '/\\/regex\\/.*/' }]) + }) + }) + }) +}) diff --git a/test/smoke.test.js b/test/smoke.test.js index 6d36d3c..b99fc52 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -376,11 +376,20 @@ describe('API Gateway', () => { }) }) - it('large chunked rejected with 502 when Connection close (buffer limit)', async () => { + it('large chunked streamed when Connection close (above buffer limit)', async () => { await request(gateway) .get('/users/large-chunked') .set({ Connection: 'close' }) - .expect(502) + .buffer(true) + .parse((res, cb) => { + let length = 0 + res.on('data', (chunk) => { length += chunk.length }) + res.on('end', () => cb(null, length)) + }) + .expect(200) + .then((response) => { + expect(response.body).to.equal(2 * 1024 * 1024) + }) }) it('large chunked streamed normally when Connection keep-alive', async () => { diff --git a/test/ws-proxy-bugs.test.js b/test/ws-proxy-bugs.test.js new file mode 100644 index 0000000..a25591f --- /dev/null +++ b/test/ws-proxy-bugs.test.js @@ -0,0 +1,300 @@ +'use strict' + +/* global describe, it, before, after */ +const expect = require('chai').expect +const gateway = require('../index') +const WebSocket = require('faye-websocket') +const http = require('http') +const net = require('net') +const crypto = require('crypto') + +const GW_PORT = 9300 +const UPSTREAM_PORT = 9301 +const SHARED_GW_PORT = 9302 +const DEAD_PORT = 9399 + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +const waitFor = async (predicate, timeout = 1000) => { + const deadline = Date.now() + timeout + while (!predicate()) { + if (Date.now() > deadline) return false + await sleep(10) + } + return true +} + +const trackSockets = (server) => { + const sockets = new Set() + server.on('connection', (socket) => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + }) + return sockets +} + +const closeServer = async (server, sockets) => { + sockets.forEach((socket) => socket.destroy()) + await Promise.race([new Promise((resolve) => server.close(resolve)), sleep(500)]) +} + +const nextClose = (ws) => new Promise((resolve) => ws.on('close', resolve)) + +const nextMessage = (ws) => new Promise((resolve) => { + ws.on('message', (event) => resolve(JSON.parse(event.data))) +}) + +const rawUpgrade = (path, headers) => new Promise((resolve) => { + const socket = net.connect(GW_PORT, '127.0.0.1', () => { + socket.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${GW_PORT}\r\n${headers}\r\n`) + }) + socket.on('error', () => {}) + resolve(socket) +}) + +const wsUpgradeHeaders = (version = 13) => + 'Upgrade: websocket\r\nConnection: Upgrade\r\n' + + `Sec-WebSocket-Key: ${crypto.randomBytes(16).toString('base64')}\r\n` + + `Sec-WebSocket-Version: ${version}\r\n` + +const failingHook = (code, message) => () => { + const err = new Error(message) + err.closeEventCode = code + + throw err +} + +describe('ws-proxy regressions', function () { + this.timeout(5000) + + let gw, upstream, upstreamSockets, gwSockets + const state = { upgrades: 0, open: new Set(), closes: [] } + + before(async () => { + upstream = http.createServer() + upstreamSockets = trackSockets(upstream) + upstream.on('upgrade', (req, socket, body) => { + state.upgrades++ + state.open.add(socket) + socket.on('close', () => state.open.delete(socket)) + + const ws = new WebSocket(req, socket, body, ['graphql-ws', 'graphql-transport-ws']) + ws.on('message', (event) => { + if (event.data === 'kick') return ws.close(4001, 'kicked') + ws.send(JSON.stringify({ data: event.data, url: req.url, protocol: ws.protocol })) + }) + ws.on('close', (event) => state.closes.push({ code: event.code, reason: event.reason })) + }) + await new Promise((resolve) => upstream.listen(UPSTREAM_PORT, '127.0.0.1', resolve)) + + const target = `ws://127.0.0.1:${UPSTREAM_PORT}` + gw = gateway({ + routes: [ + { proxyType: 'websocket', prefix: '/echo', target }, + { proxyType: 'websocket', prefix: '/down', target: `ws://127.0.0.1:${DEAD_PORT}` }, + { + proxyType: 'websocket', + prefix: '/slow', + target, + hooks: { onOpen: () => sleep(150) } + }, + { + proxyType: 'websocket', + prefix: '/sync-auth', + target, + hooks: { onOpen: failingHook(4401, 'Unauthorized') } + }, + { + proxyType: 'websocket', + prefix: '/long-reason', + target, + hooks: { onOpen: failingHook(4401, 'Unauthorized: ' + 'x'.repeat(200)) } + }, + { + proxyType: 'websocket', + prefix: '/invalid-code', + target, + hooks: { onOpen: failingHook(1008, 'Policy violation') } + }, + { + proxyType: 'websocket', + prefix: '/reject-undefined', + target, + hooks: { onOpen: () => Promise.reject() } // eslint-disable-line prefer-promise-reject-errors + }, + { + proxyType: 'websocket', + prefix: '/graphql', + target, + subProtocols: ['graphql-ws', 'graphql-transport-ws'] + } + ] + }) + gwSockets = trackSockets(gw.getServer()) + await gw.start(GW_PORT) + }) + + after(async () => { + await closeServer(gw.getServer(), gwSockets) + await closeServer(upstream, upstreamSockets) + }) + + it('closes the client when the upstream is unreachable', async () => { + const ws = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/down`) + const closed = await Promise.race([nextClose(ws), sleep(1500)]) + + expect(closed, 'client socket left open').to.not.equal(undefined) + }) + + it('closes the upstream when the client connection is reset', async () => { + const before = state.upgrades + const socket = await rawUpgrade('/echo', wsUpgradeHeaders()) + expect(await waitFor(() => state.upgrades === before + 1 && state.open.size === 1)).to.equal(true) + + socket.resetAndDestroy() + + expect(await waitFor(() => state.open.size === 0), 'upstream connection leaked').to.equal(true) + }) + + it('propagates close codes and reasons in both directions', async () => { + const kicked = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/echo`) + kicked.on('open', () => kicked.send('kick')) + const event = await nextClose(kicked) + expect([event.code, event.reason]).to.deep.equal([4001, 'kicked']) + + const leaving = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/echo`) + await new Promise((resolve) => leaving.on('open', resolve)) + await waitFor(() => state.open.size === 1) + state.closes.length = 0 + leaving.close(4000, 'bye') + + expect(await waitFor(() => state.closes.length === 1)).to.equal(true) + expect(state.closes[0]).to.deep.equal({ code: 4000, reason: 'bye' }) + }) + + it('does not lose messages sent while an async onOpen hook is pending', async () => { + const ws = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/slow`) + ws.on('open', () => ws.send('first')) + const reply = await Promise.race([nextMessage(ws), sleep(1000)]) + ws.close() + + expect(reply, 'message lost').to.not.equal(undefined) + expect(reply.data).to.equal('first') + }) + + it('does not open an upstream connection when the client left during onOpen', async () => { + const before = state.upgrades + const ws = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/slow`) + ws.on('open', () => ws.close()) + await nextClose(ws) + await sleep(300) + + expect(state.upgrades).to.equal(before) + }) + + it('does not open an upstream connection for a rejected client handshake', async () => { + const before = state.upgrades + const socket = await rawUpgrade('/echo', wsUpgradeHeaders(99)) + let response = '' + socket.on('data', (chunk) => { response += chunk }) + await new Promise((resolve) => socket.on('close', resolve)) + await sleep(200) + + expect(response).to.match(/^HTTP\/1\.1 400/) + expect(state.upgrades).to.equal(before) + }) + + it('survives onOpen errors with invalid close codes or non-Error rejections', async () => { + const rejections = [] + const onRejection = (reason) => rejections.push(reason) + process.on('unhandledRejection', onRejection) + + try { + for (const path of ['/invalid-code', '/reject-undefined']) { + const ws = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}${path}`) + const event = await Promise.race([nextClose(ws), sleep(1000)]) + + expect(event, `${path}: client socket left open`).to.not.equal(undefined) + expect(event.code).to.equal(4500) + } + expect(rejections).to.have.lengthOf(0) + } finally { + process.removeListener('unhandledRejection', onRejection) + } + }) + + it('delivers the onOpen close code when the hook throws synchronously', async () => { + const ws = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/sync-auth`) + const event = await nextClose(ws) + expect([event.code, event.reason]).to.deep.equal([4401, 'Unauthorized']) + + const long = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/long-reason`) + const longEvent = await nextClose(long) + expect(longEvent.code).to.equal(4401) + expect(longEvent.reason.startsWith('Unauthorized: xxx')).to.equal(true) + }) + + it('does not hang non-WebSocket upgrade requests', async () => { + const socket = await rawUpgrade( + '/svc/hello', + 'Connection: Upgrade, HTTP2-Settings\r\nUpgrade: h2c\r\nHTTP2-Settings: AAMAAABkAAQAoAAAAAIAAAAA\r\n' + ) + const closed = await Promise.race([ + new Promise((resolve) => socket.on('close', () => resolve(true))), + sleep(1000) + ]) + socket.destroy() + + expect(closed, 'socket left hanging').to.equal(true) + }) + + it('leaves unmatched upgrades to other upgrade listeners on the same server', async () => { + const shared = gateway({ + enableServicesEndpoint: false, + routes: [{ proxyType: 'websocket', prefix: '/proxied', target: `ws://127.0.0.1:${UPSTREAM_PORT}` }] + }) + const server = shared.getServer() + const sockets = trackSockets(server) + server.on('upgrade', (req, socket, body) => { + if (req.url === '/local') { + const ws = new WebSocket(req, socket, body) + ws.on('message', (event) => ws.send(JSON.stringify({ data: 'local:' + event.data }))) + } + }) + await shared.start(SHARED_GW_PORT) + + try { + for (const [path, expected] of [['/local', 'local:ping'], ['/proxied', 'ping']]) { + const ws = new WebSocket.Client(`ws://127.0.0.1:${SHARED_GW_PORT}${path}`) + ws.on('open', () => ws.send('ping')) + const reply = await Promise.race([nextMessage(ws), nextClose(ws), sleep(1000)]) + ws.close() + + expect(reply && reply.data, path).to.equal(expected) + } + } finally { + await closeServer(server, sockets) + } + }) + + it('offers upstream only the subprotocol negotiated with the client', async () => { + const ws = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/graphql`, ['graphql-transport-ws']) + ws.on('open', () => ws.send('init')) + const reply = await nextMessage(ws) + ws.close() + + expect(ws.protocol).to.equal('graphql-transport-ws') + expect(reply.protocol).to.equal('graphql-transport-ws') + }) + + it('forwards the query string unchanged', async () => { + for (const path of ['/echo', '/echo?q=a%20b&path=a/b&sig=x~y']) { + const ws = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}${path}`) + ws.on('open', () => ws.send('x')) + const reply = await nextMessage(ws) + ws.close() + + expect(reply.url).to.equal(path) + } + }) +}) From de8ce47e7e7ea978f2fdb54ec313ba29df69449a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:34:20 +0000 Subject: [PATCH 2/7] fix: resolve functional bugs found by adversarial review (GREEN) Routing (index.js): - onError hook sending a response no longer triggers a second response (ERR_HTTP_HEADERS_SENT crash) - async proxyHandler rejections reach onError/next instead of crashing - reject "."/".." path segments (incl. %2e and backslash forms) with 400, they let requests escape the route prefix upstream - repeated query parameters are forwarded as-is instead of "a=1%2C2" - req.query is writable in onRequest when there is no query string - prefix is stripped case-insensitively, only at the start of the url and prefixRewrite is used literally - prefix: '/' (or a trailing slash prefix) works as a catch-all - hooks.request no longer drops the route timeout, timeout: 0 disables it - route and hooks configs are not mutated (shared/reused configs broke) - accept the documented "http-legacy" proxy type - services.json prints RegExp prefixes Default hooks: - lambda: default status code, serialize non-string bodies, decode isBase64Encoded bodies (missing statusCode crashed the process) - http (Connection: close): stream responses above the 1MB buffer limit instead of failing with 502, release the upstream when the client goes away, drop upstream headers on error responses Hostnames hook: case-insensitive matching, no unbounded per-host cache, no prototype key collisions. WebSocket proxy: explicit relay propagating close codes and closing the peer on errors, queue messages during async onOpen, don't connect upstream for clients that already left, sanitize onOpen close codes, don't hang non-WebSocket upgrades or end sockets owned by other upgrade listeners, forward only the negotiated subprotocol and the original query string. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0134bwfxe5igRZeU6cwBSfzE --- index.js | 120 +++++++++++++++++++++++++++++---------- lib/default-hooks.js | 123 +++++++++++++++++++++++++++------------- lib/hostnames-hook.js | 32 +++++------ lib/ws-proxy.js | 129 +++++++++++++++++++++++++++++++----------- 4 files changed, 285 insertions(+), 119 deletions(-) diff --git a/index.js b/index.js index 4617366..888f611 100644 --- a/index.js +++ b/index.js @@ -10,8 +10,10 @@ const DEFAULT_METHODS = require('restana/libs/methods').BASE.filter( (method) => method !== 'all' ) const NOOP = (req, res) => {} -const PROXY_TYPES = ['http', 'lambda'] +const PROXY_TYPES = ['http', 'http-legacy', 'lambda'] const registerWebSocketRoutes = require('./lib/ws-proxy') +// "." and ".." path segments, also percent-encoded or backslash separated +const DOT_SEGMENT_REGEX = /(?:^|[/\\])(?:\.|%2e){1,2}(?:[/\\]|$)/i const gateway = (opts) => { let proxyFactory @@ -43,7 +45,8 @@ const gateway = (opts) => { // registering services.json const services = opts.routes.map((route) => ({ - prefix: route.prefix, + prefix: + route.prefix instanceof RegExp ? route.prefix.toString() : route.prefix, docs: route.docs })) if (opts.enableServicesEndpoint) { @@ -74,7 +77,10 @@ const gateway = (opts) => { // processing non-websocket routes opts.routes .filter((route) => route.proxyType !== 'websocket') - .forEach((route) => { + .forEach((routeConfig) => { + // route configs can be shared between routes or gateways, don't mutate them + const route = Object.assign({}, routeConfig) + if (undefined === route.prefixRewrite) { route.prefixRewrite = '' } @@ -90,14 +96,18 @@ const gateway = (opts) => { // retrieve default hooks for proxy const hooksForDefaultType = isDefaultProxyType - ? require('./lib/default-hooks')[proxyType] + ? require('./lib/default-hooks')[ + proxyType === 'http-legacy' ? 'http' : proxyType + ] : {} const { onRequestNoOp = NOOP, onResponse = NOOP } = hooksForDefaultType // populating required NOOPS - route.hooks = route.hooks || {} - route.hooks.onRequest = route.hooks.onRequest || onRequestNoOp - route.hooks.onResponse = route.hooks.onResponse || onResponse + const hooks = route.hooks || {} + route.hooks = Object.assign({}, hooks, { + onRequest: hooks.onRequest || onRequestNoOp, + onResponse: hooks.onResponse || onResponse + }) // populating route middlewares route.middlewares = route.middlewares || [] @@ -112,21 +122,29 @@ const gateway = (opts) => { // route proxy handler function const proxyHandler = route.proxyHandler || defaultProxyHandler - // populating timeout config - route.timeout = route.timeout || opts.timeout + // populating timeout config, 0 disables the timeout + route.timeout = route.timeout ?? opts.timeout + + // avoid a double slash between prefix and pathRegex, e.g. prefix: '/' + let prefix = route.prefix + if ( + typeof prefix === 'string' && + prefix.endsWith('/') && + route.pathRegex.startsWith('/') + ) { + prefix = prefix.slice(0, -1) + } // registering route handlers const methods = route.methods || DEFAULT_METHODS const args = [ // path - route.prefix instanceof RegExp - ? route.prefix - : route.prefix + route.pathRegex, + prefix instanceof RegExp ? prefix : prefix + route.pathRegex, // route middlewares ...route.middlewares, // route handler - handler(route, proxy, proxyHandler) + handler(route, prefix, proxy, proxyHandler) ] methods.forEach((method) => { @@ -140,37 +158,76 @@ const gateway = (opts) => { return router } -const handler = (route, proxy, proxyHandler) => async (req, res, next) => { - const { - urlRewrite, - prefix, - prefixRewrite, - hooks, - timeout, - disableQsOverwrite - } = route +const hasDotSegment = (url) => { + const queryIndex = url.indexOf('?') + + return DOT_SEGMENT_REGEX.test( + queryIndex === -1 ? url : url.slice(0, queryIndex) + ) +} + +// routes are matched case-insensitively, so the prefix is stripped the same way +const stripPrefix = (url, prefix, prefixRewrite) => + url.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase() + ? prefixRewrite + url.slice(prefix.length) + : url + +// URLSearchParams(object) would join repeated parameters (arrays) with commas +const toQueryString = (query) => { + for (const name in query) { + if (Array.isArray(query[name])) { + const params = new URLSearchParams() + for (const key in query) { + [].concat(query[key]).forEach((value) => params.append(key, value)) + } + + return params + } + } + + return query +} + +const handler = (route, prefix, proxy, proxyHandler) => async (req, res, next) => { + const { urlRewrite, prefixRewrite, hooks, timeout, disableQsOverwrite } = + route const { onRequest } = hooks try { + // the upstream would resolve dot segments, escaping the route prefix + if (hasDotSegment(req.url)) { + res.statusCode = 400 + res.end('Bad Request') + + return + } + if (typeof urlRewrite === 'function') { req.url = urlRewrite(req) } else if (typeof prefix === 'string') { - req.url = req.url.replace(prefix, prefixRewrite) + req.url = stripPrefix(req.url, prefix, prefixRewrite) + } + + // restana shares a frozen empty req.query when there is no query string + if (req.query && Object.isFrozen(req.query)) { + req.query = Object.create(null) } const shouldAbortProxy = await onRequest(req, res) if (!shouldAbortProxy) { const proxyOpts = Object.assign( { - request: { - timeout: req.timeout || timeout - }, - queryString: disableQsOverwrite ? null : req.query + queryString: disableQsOverwrite ? null : toQueryString(req.query) }, - route.hooks + hooks, + { + request: Object.assign({}, hooks.request, { + timeout: req.timeout ?? hooks.request?.timeout ?? timeout + }) + } ) - proxyHandler(req, res, req.url, proxy, proxyOpts) + await proxyHandler(req, res, req.url, proxy, proxyOpts) } } catch (err) { const { onError } = hooks @@ -182,7 +239,10 @@ const handler = (route, proxy, proxyHandler) => async (req, res, next) => { } } - return next(err) + // the onError hook may have already sent a response + if (!res.headersSent) { + return next(err) + } } } diff --git a/lib/default-hooks.js b/lib/default-hooks.js index d3c45d3..d0f1980 100644 --- a/lib/default-hooks.js +++ b/lib/default-hooks.js @@ -4,9 +4,68 @@ const pump = require('pump') const TRANSFER_ENCODING_HEADER_NAME = 'transfer-encoding' // Maximum size in bytes for buffered chunked responses (Connection: close) -// Prevents memory exhaustion from unbounded upstream response bodies +// Larger upstream response bodies are streamed instead, preventing memory exhaustion const MAX_BUFFER_SIZE = 1 * 1024 * 1024 // 1MB +const NOOP = () => {} + +const bufferResponse = (res, stream) => + new Promise((resolve) => { + const chunks = [] + let totalSize = 0 + + const cleanup = () => { + stream.removeListener('data', onData) + stream.removeListener('end', onEnd) + stream.removeListener('error', onError) + res.removeListener('close', onClose) + } + + const onData = (chunk) => { + totalSize += chunk.length + chunks.push(chunk) + if (totalSize > MAX_BUFFER_SIZE) { + // too large to be buffered, flush what we have and stream the rest + stream.pause() + cleanup() + chunks.forEach((chunk) => res.write(chunk)) + pump(stream, res, () => resolve()) + } + } + + const onEnd = () => { + cleanup() + const resBuffer = Buffer.concat(chunks) + + // add content-length header and send the merged response buffer + res.setHeader('content-length', '' + Buffer.byteLength(resBuffer)) + res.end(resBuffer) + resolve() + } + + const onError = (err) => { + cleanup() + // upstream headers (e.g. content-encoding) don't describe the error body + Object.keys(stream.headers).forEach((name) => res.removeHeader(name)) + res.statusCode = 500 + res.end(err.message) + resolve() + } + + const onClose = () => { + // the client went away before the response was sent, release the upstream + cleanup() + stream.on('error', NOOP) + stream.destroy() + resolve() + } + + stream.on('data', onData) + stream.on('end', onEnd) + stream.on('error', onError) + res.on('close', onClose) + }) + module.exports = { websocket: { onOpenNoOp (ws, searchParams) {} @@ -14,10 +73,20 @@ module.exports = { lambda: { onRequestNoOp (req, res) {}, onResponse (req, res, response) { - const { statusCode, body } = JSON.parse(response.Payload) + const { + statusCode = response.StatusCode || 200, + body, + isBase64Encoded + } = JSON.parse(response.Payload) || {} res.statusCode = statusCode - res.end(body) + if (body === undefined || body === null) { + res.end() + } else if (isBase64Encoded) { + res.end(Buffer.from(body, 'base64')) + } else { + res.end(typeof body === 'string' ? body : JSON.stringify(body)) + } } }, http: { @@ -28,43 +97,19 @@ module.exports = { : false if (req.headers.connection === 'close' && chunked) { - try { - // remove transfer-encoding header - const transferEncoding = stream.headers[ - TRANSFER_ENCODING_HEADER_NAME - ].replace(/(,( )?)?chunked/, '') - if (transferEncoding) { - // header format includes many encodings, example: gzip, chunked - res.setHeader(TRANSFER_ENCODING_HEADER_NAME, transferEncoding) - } else { - res.removeHeader(TRANSFER_ENCODING_HEADER_NAME) - } - - if (!stream.headers['content-length']) { - // Collect chunks with size limit to prevent OOM - const chunks = [] - let totalSize = 0 - - for await (const chunk of stream) { - totalSize += chunk.length - if (totalSize > MAX_BUFFER_SIZE) { - stream.destroy() - res.statusCode = 502 - res.end('Response body exceeds maximum allowed size') - return - } - chunks.push(chunk) - } - - const resBuffer = Buffer.concat(chunks) + // remove transfer-encoding header + const transferEncoding = stream.headers[ + TRANSFER_ENCODING_HEADER_NAME + ].replace(/(,( )?)?chunked/, '') + if (transferEncoding) { + // header format includes many encodings, example: gzip, chunked + res.setHeader(TRANSFER_ENCODING_HEADER_NAME, transferEncoding) + } else { + res.removeHeader(TRANSFER_ENCODING_HEADER_NAME) + } - // add content-length header and send the merged response buffer - res.setHeader('content-length', '' + Buffer.byteLength(resBuffer)) - res.end(resBuffer) - } - } catch (err) { - res.statusCode = 500 - res.end(err.message) + if (!stream.headers['content-length']) { + return bufferResponse(res, stream) } } else { res.statusCode = stream.statusCode diff --git a/lib/hostnames-hook.js b/lib/hostnames-hook.js index 15b5030..eaeb303 100644 --- a/lib/hostnames-hook.js +++ b/lib/hostnames-hook.js @@ -5,33 +5,29 @@ try { micromatch = require('micromatch') } catch (e) { micromatch = { - isMatch (value, pattern) { - return value === pattern + matcher (pattern) { + pattern = pattern.toLowerCase() + + return (value) => value === pattern } } } module.exports = (hostname2prefix) => { - const matches = {} + // matchers are compiled once, caching results per host would grow without bound + const matchers = hostname2prefix.map((e) => ({ + isMatch: micromatch.matcher(e.hostname, { nocase: true }), + prefix: e.prefix + })) return (req, res, cb) => { if (req.headers.host) { - const hostHeader = req.headers.host.split(':')[0] - let prefix = matches[hostHeader] - - if (!prefix) { - for (const e of hostname2prefix) { - if (micromatch.isMatch(hostHeader, e.hostname)) { - prefix = e.prefix - matches[hostHeader] = prefix - - break - } - } - } + // host names are case-insensitive + const hostHeader = req.headers.host.split(':')[0].toLowerCase() + const match = matchers.find((e) => e.isMatch(hostHeader)) - if (prefix) { - req.url = prefix + req.url + if (match && match.prefix) { + req.url = match.prefix + req.url } } diff --git a/lib/ws-proxy.js b/lib/ws-proxy.js index c441577..97abc77 100644 --- a/lib/ws-proxy.js +++ b/lib/ws-proxy.js @@ -3,6 +3,50 @@ const micromatch = require('micromatch') const { onOpenNoOp } = require('./default-hooks').websocket +const CONNECTING = 0 +const OPEN = 1 +const MAX_CLOSE_REASON_BYTES = 123 + +// faye-websocket only accepts 1000 or 3000-4999 as close codes +const toCloseCode = (code, fallback) => + Number.isInteger(code) && (code === 1000 || (code >= 3000 && code <= 4999)) + ? code + : fallback + +const toCloseReason = (reason) => { + reason = typeof reason === 'string' ? reason : '' + while (Buffer.byteLength(reason) > MAX_CLOSE_REASON_BYTES) { + reason = reason.slice(0, -1) + } + + return reason +} + +const close = (ws, code, reason) => { + if (ws.readyState === CONNECTING || ws.readyState === OPEN) { + ws.close(code, toCloseReason(reason)) + } +} + +// resolves once the opening handshake has either completed or failed +const handshake = (ws) => + new Promise((resolve) => { + if (ws.readyState !== CONNECTING) return resolve() + ws.once('open', resolve) + ws.once('close', resolve) + }) + +const relay = (from, to) => { + from.on('message', (event) => { + if (to.send(event.data) === false) from.pause() + }) + to.on('drain', () => from.resume()) + from.on('close', (event) => { + const code = toCloseCode(event.code, 1000) + close(to, code, code === event.code ? event.reason : '') + }) +} + module.exports = (config) => { const WebSocket = require('faye-websocket') @@ -12,38 +56,59 @@ module.exports = (config) => { route._isMatch = micromatch.matcher(route.prefix) }) - server.on('upgrade', async (req, socket, body) => { - if (WebSocket.isWebSocket(req)) { - const url = new URL('http://fw' + req.url) - const prefix = url.pathname || '/' - - const route = routes.find((route) => route._isMatch(prefix)) - if (route) { - const subProtocols = route.subProtocols || [] - route.hooks = route.hooks || {} - const onOpen = route.hooks.onOpen || onOpenNoOp - - const client = new WebSocket(req, socket, body, subProtocols) - - try { - await onOpen(client, url.searchParams) - - const target = - route.target + url.pathname + '?' + url.searchParams.toString() - const remote = new WebSocket.Client( - target, - subProtocols, - route.proxyConfig - ) - - client.pipe(remote) - remote.pipe(client) - } catch (err) { - client.close(err.closeEventCode || 4500, err.message) - } - } else { - socket.end() - } + const onUnhandled = (socket) => { + // other 'upgrade' listeners on this server may still handle the socket + if (server.listenerCount('upgrade') === 1) socket.end() + } + + const onUpgrade = async (req, socket, body) => { + if (!WebSocket.isWebSocket(req)) return onUnhandled(socket) + + const url = new URL('http://fw' + req.url) + const prefix = url.pathname || '/' + + const route = routes.find((route) => route._isMatch(prefix)) + if (!route) return onUnhandled(socket) + + const subProtocols = route.subProtocols || [] + route.hooks = route.hooks || {} + const onOpen = route.hooks.onOpen || onOpenNoOp + + const client = new WebSocket(req, socket, body, subProtocols) + + // queue messages received while the onOpen hook is pending + const pending = [] + const queue = (event) => pending.push(event.data) + client.on('message', queue) + + try { + await onOpen(client, url.searchParams) + await handshake(client) + if (client.readyState !== OPEN) return + + const target = route.target + url.pathname + url.search + const remote = new WebSocket.Client( + target, + client.protocol ? [client.protocol] : [], + route.proxyConfig + ) + + client.removeListener('message', queue) + pending.forEach((data) => remote.send(data)) + + relay(client, remote) + relay(remote, client) + } catch (err) { + await handshake(client) + close( + client, + toCloseCode(err && err.closeEventCode, 4500), + err && err.message + ) } + } + + server.on('upgrade', (req, socket, body) => { + onUpgrade(req, socket, body).catch(() => socket.destroy()) }) } From 1375b82e953dfd3f7fe00812ba8875177dad7757 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 20:24:14 +0000 Subject: [PATCH 3/7] fix: address adversarial review findings - keep req.query as a plain object for non "http" proxy types, fast-proxy (http-legacy) stringified a URLSearchParams into an empty query - avoid a double slash when prefixRewrite ends with "/" - destroy a partially sent response when the handler fails without an onError hook instead of leaving the client hanging - also reject dot segments followed by ";" path parameters ("/..;/") - bound the messages queued while an async WebSocket onOpen hook is pending (1MB, closes the client with 4413) - don't write hooks or matchers into WebSocket route configs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0134bwfxe5igRZeU6cwBSfzE --- index.js | 43 ++++++++++++++++++++------- lib/ws-proxy.js | 31 +++++++++++++++----- test/routing.test.js | 60 +++++++++++++++++++++++++++++++++++++- test/ws-proxy-bugs.test.js | 34 +++++++++++++++++++++ 4 files changed, 149 insertions(+), 19 deletions(-) diff --git a/index.js b/index.js index 888f611..0579eda 100644 --- a/index.js +++ b/index.js @@ -13,7 +13,8 @@ const NOOP = (req, res) => {} const PROXY_TYPES = ['http', 'http-legacy', 'lambda'] const registerWebSocketRoutes = require('./lib/ws-proxy') // "." and ".." path segments, also percent-encoded or backslash separated -const DOT_SEGMENT_REGEX = /(?:^|[/\\])(?:\.|%2e){1,2}(?:[/\\]|$)/i +// (a ";" path parameter is ignored by some upstreams, e.g. "/..;/") +const DOT_SEGMENT_REGEX = /(?:^|[/\\])(?:\.|%2e){1,2}(?:;[^/\\]*)?(?:[/\\]|$)/i const gateway = (opts) => { let proxyFactory @@ -167,13 +168,24 @@ const hasDotSegment = (url) => { } // routes are matched case-insensitively, so the prefix is stripped the same way -const stripPrefix = (url, prefix, prefixRewrite) => - url.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase() - ? prefixRewrite + url.slice(prefix.length) - : url +const stripPrefix = (url, prefix, prefixRewrite) => { + if (url.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) { + return url + } + + // avoid a double slash when prefixRewrite ends with "/", e.g. '/api/' + const rest = url.slice(prefix.length) + + return prefixRewrite.endsWith('/') && rest.startsWith('/') + ? prefixRewrite + rest.slice(1) + : prefixRewrite + rest +} + +// URLSearchParams(object) would join repeated parameters (arrays) with commas, +// only fast-proxy-lite ('http') stringifies queryString using URLSearchParams +const toQueryString = (query, proxyType) => { + if (proxyType !== 'http') return query -// URLSearchParams(object) would join repeated parameters (arrays) with commas -const toQueryString = (query) => { for (const name in query) { if (Array.isArray(query[name])) { const params = new URLSearchParams() @@ -189,8 +201,14 @@ const toQueryString = (query) => { } const handler = (route, prefix, proxy, proxyHandler) => async (req, res, next) => { - const { urlRewrite, prefixRewrite, hooks, timeout, disableQsOverwrite } = - route + const { + urlRewrite, + prefixRewrite, + hooks, + timeout, + disableQsOverwrite, + proxyType = 'http' + } = route const { onRequest } = hooks try { @@ -217,7 +235,7 @@ const handler = (route, prefix, proxy, proxyHandler) => async (req, res, next) = if (!shouldAbortProxy) { const proxyOpts = Object.assign( { - queryString: disableQsOverwrite ? null : toQueryString(req.query) + queryString: disableQsOverwrite ? null : toQueryString(req.query, proxyType) }, hooks, { @@ -243,6 +261,11 @@ const handler = (route, prefix, proxy, proxyHandler) => async (req, res, next) = if (!res.headersSent) { return next(err) } + + // a partially sent response can't be turned into an error response + if (typeof onError !== 'function' && !res.writableEnded) { + res.destroy(err) + } } } diff --git a/lib/ws-proxy.js b/lib/ws-proxy.js index 97abc77..1f7d11d 100644 --- a/lib/ws-proxy.js +++ b/lib/ws-proxy.js @@ -6,6 +6,8 @@ const { onOpenNoOp } = require('./default-hooks').websocket const CONNECTING = 0 const OPEN = 1 const MAX_CLOSE_REASON_BYTES = 123 +// maximum size of the messages queued while the onOpen hook is pending +const MAX_PENDING_BYTES = 1 * 1024 * 1024 // 1MB // faye-websocket only accepts 1000 or 3000-4999 as close codes const toCloseCode = (code, fallback) => @@ -52,9 +54,10 @@ module.exports = (config) => { const { routes, server } = config - routes.forEach((route) => { - route._isMatch = micromatch.matcher(route.prefix) - }) + const matchers = routes.map((route) => ({ + route, + isMatch: micromatch.matcher(route.prefix) + })) const onUnhandled = (socket) => { // other 'upgrade' listeners on this server may still handle the socket @@ -67,18 +70,30 @@ module.exports = (config) => { const url = new URL('http://fw' + req.url) const prefix = url.pathname || '/' - const route = routes.find((route) => route._isMatch(prefix)) - if (!route) return onUnhandled(socket) + const match = matchers.find((matcher) => matcher.isMatch(prefix)) + if (!match) return onUnhandled(socket) + + const { route } = match const subProtocols = route.subProtocols || [] - route.hooks = route.hooks || {} - const onOpen = route.hooks.onOpen || onOpenNoOp + const onOpen = (route.hooks && route.hooks.onOpen) || onOpenNoOp const client = new WebSocket(req, socket, body, subProtocols) // queue messages received while the onOpen hook is pending const pending = [] - const queue = (event) => pending.push(event.data) + let pendingBytes = 0 + const queue = (event) => { + pendingBytes += Buffer.byteLength(event.data) + if (pendingBytes > MAX_PENDING_BYTES) { + client.removeListener('message', queue) + pending.length = 0 + close(client, 4413, 'Too much data before the connection was ready') + + return + } + pending.push(event.data) + } client.on('message', queue) try { diff --git a/test/routing.test.js b/test/routing.test.js index 80c1904..a516126 100644 --- a/test/routing.test.js +++ b/test/routing.test.js @@ -72,6 +72,16 @@ describe('routing', () => { } } }, + { + prefix: '/partial-response', + target, + proxyHandler: (req, res) => { + res.writeHead(200) + res.write('partial') + + throw new Error('failed after the response started') + } + }, { prefix: '/async-proxy-handler', target, @@ -113,6 +123,13 @@ describe('routing', () => { expect(onErrorCalls).to.have.lengthOf(1) expect(onErrorCalls[0].message).to.equal('proxy handler failed') }) + + it('terminates a partially sent response when the handler fails', async () => { + // resolves once the gateway closes the connection, rejects if it hangs + const response = await rawRequest(9502, '/partial-response/info', { timeout: 1000 }) + + expect(response.body).to.not.match(/0\r\n\r\n$/) + }) }) describe('url rewriting', () => { @@ -124,6 +141,7 @@ describe('routing', () => { { prefix: '/public', prefixRewrite: '/api/public', target }, { prefix: '/users', target }, { prefix: '/internal', prefixRewrite: '/v$&', target }, + { prefix: '/slash', prefixRewrite: '/v2/', target }, { prefix: '/', target } ] }, 9503) @@ -142,7 +160,8 @@ describe('routing', () => { '/public/../../admin/users', '/public/%2e%2e/%2e%2e/admin/users', '/public/.%2E/admin/users', - '/public/..\\..\\admin/users' + '/public/..\\..\\admin/users', + '/public/..;/..;/admin/users' ]) { it(`rejects dot-segment traversal: ${path}`, async () => { const response = await rawRequest(9503, path) @@ -177,6 +196,15 @@ describe('routing', () => { }) }) + it('prefixRewrite ending with "/" does not produce a double slash', async () => { + await request(gateway) + .get('/slash/users') + .expect(200) + .then((response) => { + expect(response.body.url).to.equal('/v2/users') + }) + }) + it('"/" prefix acts as a catch-all route', async () => { await request(gateway) .get('/foo/bar') @@ -348,6 +376,36 @@ describe('routing', () => { })).to.not.throw(/Unsupported proxy type/) }) + it('passes req.query unchanged to non "http" proxy types', async () => { + const gateway = await start({ + proxyFactory: () => () => {}, + routes: [ + { + prefix: '/legacy', + proxyType: 'http-legacy', + target, + proxyHandler: (req, res, url, proxy, proxyOpts) => { + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ + isURLSearchParams: proxyOpts.queryString instanceof URLSearchParams, + queryString: proxyOpts.queryString + })) + } + } + ] + }, 9512) + + await request(gateway) + .get('/legacy/items?a=1&a=2&b=3') + .expect(200) + .then((response) => { + expect(response.body).to.deep.equal({ + isURLSearchParams: false, + queryString: { a: ['1', '2'], b: '3' } + }) + }) + }) + it('services.json describes RegExp prefixes', async () => { const gateway = await start({ routes: [{ prefix: /\/regex\/.*/, target }] diff --git a/test/ws-proxy-bugs.test.js b/test/ws-proxy-bugs.test.js index a25591f..b0f6097 100644 --- a/test/ws-proxy-bugs.test.js +++ b/test/ws-proxy-bugs.test.js @@ -11,6 +11,7 @@ const crypto = require('crypto') const GW_PORT = 9300 const UPSTREAM_PORT = 9301 const SHARED_GW_PORT = 9302 +const CONFIG_GW_PORT = 9303 const DEAD_PORT = 9399 const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -182,6 +183,39 @@ describe('ws-proxy regressions', function () { expect(reply.data).to.equal('first') }) + it('bounds the messages queued while an async onOpen hook is pending', async () => { + const before = state.upgrades + const ws = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/slow`) + ws.on('open', () => { + for (let i = 0; i < 3; i++) ws.send(Buffer.alloc(512 * 1024)) + }) + const closed = await Promise.race([nextClose(ws), sleep(1000)]) + + expect(closed, 'client socket left open').to.not.equal(undefined) + expect(closed.code).to.equal(4413) + await sleep(200) + expect(state.upgrades).to.equal(before) + }) + + it('does not mutate websocket route configs', async () => { + const route = { proxyType: 'websocket', prefix: '/echo', target: `ws://127.0.0.1:${UPSTREAM_PORT}` } + const configGw = gateway({ routes: [route] }) + const configGwSockets = trackSockets(configGw.getServer()) + await configGw.start(CONFIG_GW_PORT) + + const ws = new WebSocket.Client(`ws://127.0.0.1:${CONFIG_GW_PORT}/echo`) + await new Promise((resolve) => ws.on('open', resolve)) + ws.close() + await nextClose(ws) + await closeServer(configGw.getServer(), configGwSockets) + + expect(route).to.deep.equal({ + proxyType: 'websocket', + prefix: '/echo', + target: `ws://127.0.0.1:${UPSTREAM_PORT}` + }) + }) + it('does not open an upstream connection when the client left during onOpen', async () => { const before = state.upgrades const ws = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/slow`) From 0fe2ce9399cf50416b44dffee76ff2e43ccaae54 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 08:10:03 +0000 Subject: [PATCH 4/7] fix: address second adversarial review round - destroy a partially sent response when onError doesn't respond either, instead of leaving the client hanging - reject dot segments separated by encoded slashes/backslashes (%2f, %5c) - don't append "?" to upstream urls when there is no query string - keep proxyOpts.queryString a plain object; repeated parameters are appended individually through an iterator of [name, value] pairs, which also works for fast-proxy (http-legacy) and custom proxy types - move the http-legacy default hooks to lib/default-hooks.js - don't crash on a non-string pathRegex with a trailing slash prefix - WebSocket: report upstream closes that can't be relayed (e.g. 1006, 1011) as abnormal closes to the client instead of 1000 - WebSocket: truncate close reasons once, on a UTF-8 character boundary - WebSocket: end upgrades no listener handled within 1s when the server has other 'upgrade' listeners (same approach as engine.io) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0134bwfxe5igRZeU6cwBSfzE --- index.js | 64 ++++++++++++++------------- lib/default-hooks.js | 3 ++ lib/ws-proxy.js | 45 +++++++++++++------ test/routing.test.js | 88 +++++++++++++++++++++++++++++++++++++- test/ws-proxy-bugs.test.js | 51 ++++++++++++++++++++++ 5 files changed, 207 insertions(+), 44 deletions(-) diff --git a/index.js b/index.js index 0579eda..996fd1c 100644 --- a/index.js +++ b/index.js @@ -12,9 +12,11 @@ const DEFAULT_METHODS = require('restana/libs/methods').BASE.filter( const NOOP = (req, res) => {} const PROXY_TYPES = ['http', 'http-legacy', 'lambda'] const registerWebSocketRoutes = require('./lib/ws-proxy') -// "." and ".." path segments, also percent-encoded or backslash separated +// "." and ".." path segments, also percent-encoded or separated by a backslash +// or an encoded (back)slash, some upstreams decode those before normalizing // (a ";" path parameter is ignored by some upstreams, e.g. "/..;/") -const DOT_SEGMENT_REGEX = /(?:^|[/\\])(?:\.|%2e){1,2}(?:;[^/\\]*)?(?:[/\\]|$)/i +const DOT_SEGMENT_REGEX = + /(?:^|[/\\]|%2f|%5c)(?:\.|%2e){1,2}(?:;[^/\\]*)?(?:[/\\]|%2f|%5c|$)/i const gateway = (opts) => { let proxyFactory @@ -97,9 +99,7 @@ const gateway = (opts) => { // retrieve default hooks for proxy const hooksForDefaultType = isDefaultProxyType - ? require('./lib/default-hooks')[ - proxyType === 'http-legacy' ? 'http' : proxyType - ] + ? require('./lib/default-hooks')[proxyType] : {} const { onRequestNoOp = NOOP, onResponse = NOOP } = hooksForDefaultType @@ -131,6 +131,7 @@ const gateway = (opts) => { if ( typeof prefix === 'string' && prefix.endsWith('/') && + typeof route.pathRegex === 'string' && route.pathRegex.startsWith('/') ) { prefix = prefix.slice(0, -1) @@ -181,23 +182,28 @@ const stripPrefix = (url, prefix, prefixRewrite) => { : prefixRewrite + rest } -// URLSearchParams(object) would join repeated parameters (arrays) with commas, -// only fast-proxy-lite ('http') stringifies queryString using URLSearchParams -const toQueryString = (query, proxyType) => { - if (proxyType !== 'http') return query +// URLSearchParams(object) joins repeated parameters (arrays) with commas, +// iterating [name, value] pairs makes it append each value instead +function * queryPairs () { + for (const name in this) { + for (const value of [].concat(this[name])) yield [name, value] + } +} - for (const name in query) { - if (Array.isArray(query[name])) { - const params = new URLSearchParams() - for (const key in query) { - [].concat(query[key]).forEach((value) => params.append(key, value)) - } +const toQueryString = (query, url) => { + if (!query) return query - return params - } - } + const values = Object.values(query) + // nothing to forward, avoids a trailing "?" in the upstream url + if (values.length === 0 && !url.includes('?')) return null + if (!values.some(Array.isArray)) return query - return query + // still a plain object for hooks and custom proxy handlers + return Object.defineProperty( + Object.assign(Object.create(null), query), + Symbol.iterator, + { value: queryPairs } + ) } const handler = (route, prefix, proxy, proxyHandler) => async (req, res, next) => { @@ -206,8 +212,7 @@ const handler = (route, prefix, proxy, proxyHandler) => async (req, res, next) = prefixRewrite, hooks, timeout, - disableQsOverwrite, - proxyType = 'http' + disableQsOverwrite } = route const { onRequest } = hooks @@ -235,7 +240,9 @@ const handler = (route, prefix, proxy, proxyHandler) => async (req, res, next) = if (!shouldAbortProxy) { const proxyOpts = Object.assign( { - queryString: disableQsOverwrite ? null : toQueryString(req.query, proxyType) + queryString: disableQsOverwrite + ? null + : toQueryString(req.query, req.url) }, hooks, { @@ -248,6 +255,7 @@ const handler = (route, prefix, proxy, proxyHandler) => async (req, res, next) = await proxyHandler(req, res, req.url, proxy, proxyOpts) } } catch (err) { + const headersSent = res.headersSent const { onError } = hooks if (typeof onError === 'function') { try { @@ -257,15 +265,13 @@ const handler = (route, prefix, proxy, proxyHandler) => async (req, res, next) = } } - // the onError hook may have already sent a response - if (!res.headersSent) { + if (headersSent) { + // a partially sent response can't be turned into an error response + if (!res.writableEnded) res.destroy() + } else if (!res.headersSent) { + // otherwise the onError hook already sent a response return next(err) } - - // a partially sent response can't be turned into an error response - if (typeof onError !== 'function' && !res.writableEnded) { - res.destroy(err) - } } } diff --git a/lib/default-hooks.js b/lib/default-hooks.js index d0f1980..76d8229 100644 --- a/lib/default-hooks.js +++ b/lib/default-hooks.js @@ -118,3 +118,6 @@ module.exports = { } } } + +// fast-proxy (http-legacy) responses are handled as fast-proxy-lite ones +module.exports['http-legacy'] = module.exports.http diff --git a/lib/ws-proxy.js b/lib/ws-proxy.js index 1f7d11d..1f22c25 100644 --- a/lib/ws-proxy.js +++ b/lib/ws-proxy.js @@ -8,20 +8,31 @@ const OPEN = 1 const MAX_CLOSE_REASON_BYTES = 123 // maximum size of the messages queued while the onOpen hook is pending const MAX_PENDING_BYTES = 1 * 1024 * 1024 // 1MB +// time other 'upgrade' listeners get to handle a socket before it is ended +const UNHANDLED_UPGRADE_TIMEOUT = 1000 -// faye-websocket only accepts 1000 or 3000-4999 as close codes -const toCloseCode = (code, fallback) => - Number.isInteger(code) && (code === 1000 || (code >= 3000 && code <= 4999)) +// faye-websocket only accepts 1000 or 3000-4999 as close codes, +// 1005 (no status code) is a normal close +const toCloseCode = (code, fallback) => { + if (code === 1005) return 1000 + + return Number.isInteger(code) && + (code === 1000 || (code >= 3000 && code <= 4999)) ? code : fallback +} const toCloseReason = (reason) => { - reason = typeof reason === 'string' ? reason : '' - while (Buffer.byteLength(reason) > MAX_CLOSE_REASON_BYTES) { - reason = reason.slice(0, -1) - } + if (typeof reason !== 'string') return '' + + const buffer = Buffer.from(reason) + if (buffer.length <= MAX_CLOSE_REASON_BYTES) return reason - return reason + // don't cut a multi-byte UTF-8 character in half + let end = MAX_CLOSE_REASON_BYTES + while (end > 0 && (buffer[end] & 0xc0) === 0x80) end-- + + return buffer.toString('utf8', 0, end) } const close = (ws, code, reason) => { @@ -38,13 +49,16 @@ const handshake = (ws) => ws.once('close', resolve) }) -const relay = (from, to) => { +// onAbnormalClose is called when the close code can't be relayed +const relay = (from, to, onAbnormalClose) => { from.on('message', (event) => { if (to.send(event.data) === false) from.pause() }) to.on('drain', () => from.resume()) from.on('close', (event) => { - const code = toCloseCode(event.code, 1000) + const code = toCloseCode(event.code) + if (code === undefined) return onAbnormalClose() + close(to, code, code === event.code ? event.reason : '') }) } @@ -60,8 +74,12 @@ module.exports = (config) => { })) const onUnhandled = (socket) => { + if (server.listenerCount('upgrade') === 1) return socket.end() + // other 'upgrade' listeners on this server may still handle the socket - if (server.listenerCount('upgrade') === 1) socket.end() + setTimeout(() => { + if (socket.writable && socket.bytesWritten === 0) socket.end() + }, UNHANDLED_UPGRADE_TIMEOUT).unref() } const onUpgrade = async (req, socket, body) => { @@ -111,8 +129,9 @@ module.exports = (config) => { client.removeListener('message', queue) pending.forEach((data) => remote.send(data)) - relay(client, remote) - relay(remote, client) + relay(client, remote, () => close(remote, 1000)) + // drop the client connection, so it sees an abnormal close (1006) + relay(remote, client, () => socket.destroy()) } catch (err) { await handshake(client) close( diff --git a/test/routing.test.js b/test/routing.test.js index a516126..e05b03d 100644 --- a/test/routing.test.js +++ b/test/routing.test.js @@ -31,7 +31,11 @@ describe('routing', () => { const reply = () => { res.setHeader('content-type', 'application/json') // the gateway always appends '?' when forwarding req.query, ignore it - res.end(JSON.stringify({ method: req.method, url: req.url.replace(/\?$/, '') })) + res.end(JSON.stringify({ + method: req.method, + url: req.url.replace(/\?$/, ''), + rawUrl: req.url + })) } if (req.url.startsWith('/slow')) { setTimeout(reply, 300) @@ -82,6 +86,21 @@ describe('routing', () => { throw new Error('failed after the response started') } }, + { + prefix: '/partial-response-on-error', + target, + proxyHandler: (req, res) => { + res.writeHead(200) + res.write('partial') + + throw new Error('failed after the response started') + }, + hooks: { + onError (err) { + onErrorCalls.push(err) + } + } + }, { prefix: '/async-proxy-handler', target, @@ -130,6 +149,14 @@ describe('routing', () => { expect(response.body).to.not.match(/0\r\n\r\n$/) }) + + it('terminates a partially sent response when onError does not respond', async () => { + onErrorCalls.length = 0 + const response = await rawRequest(9502, '/partial-response-on-error/info', { timeout: 1000 }) + + expect(response.body).to.not.match(/0\r\n\r\n$/) + expect(onErrorCalls).to.have.lengthOf(1) + }) }) describe('url rewriting', () => { @@ -161,7 +188,9 @@ describe('routing', () => { '/public/%2e%2e/%2e%2e/admin/users', '/public/.%2E/admin/users', '/public/..\\..\\admin/users', - '/public/..;/..;/admin/users' + '/public/..;/..;/admin/users', + '/public/..%2f..%2fadmin/users', + '/public/%2e%2e%5C%2e%2e%5Cadmin/users' ]) { it(`rejects dot-segment traversal: ${path}`, async () => { const response = await rawRequest(9503, path) @@ -241,6 +270,25 @@ describe('routing', () => { gateway = await start({ routes: [ { prefix: '/svc', target }, + { + prefix: '/strip-token', + target, + hooks: { + onRequest (req) { + delete req.query.token + } + } + }, + { + prefix: '/inspect', + target, + proxyHandler: (req, res, url, proxy, proxyOpts) => { + res.setHeader('x-page', proxyOpts.queryString.page) + res.setHeader('x-query', JSON.stringify({ ...proxyOpts.queryString })) + + return proxy(req, res, url, proxyOpts) + } + }, { prefix: '/set-query', target, @@ -263,6 +311,35 @@ describe('routing', () => { }) }) + it('does not append "?" when there is no query string', async () => { + await request(gateway) + .get('/svc/items') + .expect(200) + .then((response) => { + expect(response.body.rawUrl).to.equal('/items') + }) + }) + + it('does not forward query parameters removed in onRequest', async () => { + await request(gateway) + .get('/strip-token/items?token=secret') + .expect(200) + .then((response) => { + expect(response.body.rawUrl).to.not.include('token') + }) + }) + + it('proxyOpts.queryString stays a plain object with repeated parameters', async () => { + await request(gateway) + .get('/inspect/items?tag=a&tag=b&page=2') + .expect(200) + .then((response) => { + expect(response.headers['x-page']).to.equal('2') + expect(JSON.parse(response.headers['x-query'])).to.deep.equal({ tag: ['a', 'b'], page: '2' }) + expect(response.body.url).to.equal('/items?tag=a&tag=b&page=2') + }) + }) + it('allows onRequest to set req.query when the request has no query string', async () => { await request(gateway) .get('/set-query/items') @@ -406,6 +483,13 @@ describe('routing', () => { }) }) + it('does not crash on a non-string pathRegex', () => { + expect(() => fastGateway({ + pathRegex: undefined, + routes: [{ prefix: '/api/', target }] + })).to.not.throw() + }) + it('services.json describes RegExp prefixes', async () => { const gateway = await start({ routes: [{ prefix: /\/regex\/.*/, target }] diff --git a/test/ws-proxy-bugs.test.js b/test/ws-proxy-bugs.test.js index b0f6097..42fc159 100644 --- a/test/ws-proxy-bugs.test.js +++ b/test/ws-proxy-bugs.test.js @@ -12,6 +12,7 @@ const GW_PORT = 9300 const UPSTREAM_PORT = 9301 const SHARED_GW_PORT = 9302 const CONFIG_GW_PORT = 9303 +const MULTI_LISTENER_GW_PORT = 9304 const DEAD_PORT = 9399 const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -82,6 +83,7 @@ describe('ws-proxy regressions', function () { const ws = new WebSocket(req, socket, body, ['graphql-ws', 'graphql-transport-ws']) ws.on('message', (event) => { if (event.data === 'kick') return ws.close(4001, 'kicked') + if (event.data === 'crash') return socket.destroy() ws.send(JSON.stringify({ data: event.data, url: req.url, protocol: ws.protocol })) }) ws.on('close', (event) => state.closes.push({ code: event.code, reason: event.reason })) @@ -111,6 +113,12 @@ describe('ws-proxy regressions', function () { target, hooks: { onOpen: failingHook(4401, 'Unauthorized: ' + 'x'.repeat(200)) } }, + { + proxyType: 'websocket', + prefix: '/emoji-reason', + target, + hooks: { onOpen: failingHook(4401, 'a'.repeat(120) + '\u{1F600}') } + }, { proxyType: 'websocket', prefix: '/invalid-code', @@ -173,6 +181,49 @@ describe('ws-proxy regressions', function () { expect(state.closes[0]).to.deep.equal({ code: 4000, reason: 'bye' }) }) + it('reports an abnormal upstream close to the client as abnormal', async () => { + const ws = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/echo`) + ws.on('open', () => ws.send('crash')) + const event = await nextClose(ws) + + expect(event.code).to.equal(1006) + }) + + it('truncates long close reasons on a character boundary', async () => { + const ws = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/emoji-reason`) + const event = await nextClose(ws) + + expect(event.code).to.equal(4401) + expect(event.reason).to.equal('a'.repeat(120)) + }) + + it('ends upgrades no listener handles when the server has other upgrade listeners', async () => { + const multi = gateway({ + enableServicesEndpoint: false, + routes: [{ proxyType: 'websocket', prefix: '/proxied', target: `ws://127.0.0.1:${UPSTREAM_PORT}` }] + }) + const server = multi.getServer() + const sockets = trackSockets(server) + server.on('upgrade', () => {}) // e.g. another library ignoring paths it doesn't own + await multi.start(MULTI_LISTENER_GW_PORT) + + try { + const socket = net.connect(MULTI_LISTENER_GW_PORT, '127.0.0.1', () => { + socket.write(`GET /unknown HTTP/1.1\r\nHost: 127.0.0.1:${MULTI_LISTENER_GW_PORT}\r\n${wsUpgradeHeaders()}\r\n`) + }) + socket.on('error', () => {}) + const closed = await Promise.race([ + new Promise((resolve) => socket.on('close', () => resolve(true))), + sleep(2000) + ]) + socket.destroy() + + expect(closed, 'unhandled upgrade socket left open').to.equal(true) + } finally { + await closeServer(server, sockets) + } + }) + it('does not lose messages sent while an async onOpen hook is pending', async () => { const ws = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/slow`) ws.on('open', () => ws.send('first')) From 2095014ad0981e3b7fb6348ea7aabba966d7a6e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:52:23 +0000 Subject: [PATCH 5/7] test: raise coverage to 100% and enforce a 99% threshold - cover proxy factory fallbacks, custom and unsupported proxy types, http-legacy (with a fast-proxy stand-in), websocket routes on servers without getServer(), unsupported methods, non literal prefixes, servers without req.query, onError completing partial responses, lambda payloads without status code/body and hostnames without host header - fix: a "Connection: close" buffered response for an upstream using "Transfer-Encoding: gzip, chunked" was sent with both transfer-encoding and content-length (invalid framing) - remove unreachable code: micromatch fallback in the hostnames hook, 1005 close code mapping (websocket-driver reports 1000), pause/drain relay handling (faye send() only returns false once closing), empty pathname fallback and the error listener added before destroy() - enforce 99% statements/branches/functions/lines coverage with nyc Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0134bwfxe5igRZeU6cwBSfzE --- lib/default-hooks.js | 28 ++----- lib/hostnames-hook.js | 13 +-- lib/ws-proxy.js | 20 ++--- package.json | 7 ++ test/default-hooks.test.js | 61 +++++++++++++- test/hostnames-hook.test.js | 12 +++ test/routing.test.js | 155 ++++++++++++++++++++++++++++++++++++ test/ws-proxy-bugs.test.js | 19 +++++ 8 files changed, 266 insertions(+), 49 deletions(-) diff --git a/lib/default-hooks.js b/lib/default-hooks.js index 76d8229..f776ba2 100644 --- a/lib/default-hooks.js +++ b/lib/default-hooks.js @@ -7,8 +7,6 @@ const TRANSFER_ENCODING_HEADER_NAME = 'transfer-encoding' // Larger upstream response bodies are streamed instead, preventing memory exhaustion const MAX_BUFFER_SIZE = 1 * 1024 * 1024 // 1MB -const NOOP = () => {} - const bufferResponse = (res, stream) => new Promise((resolve) => { const chunks = [] @@ -55,7 +53,6 @@ const bufferResponse = (res, stream) => const onClose = () => { // the client went away before the response was sent, release the upstream cleanup() - stream.on('error', NOOP) stream.destroy() resolve() } @@ -97,24 +94,15 @@ module.exports = { : false if (req.headers.connection === 'close' && chunked) { - // remove transfer-encoding header - const transferEncoding = stream.headers[ - TRANSFER_ENCODING_HEADER_NAME - ].replace(/(,( )?)?chunked/, '') - if (transferEncoding) { - // header format includes many encodings, example: gzip, chunked - res.setHeader(TRANSFER_ENCODING_HEADER_NAME, transferEncoding) - } else { - res.removeHeader(TRANSFER_ENCODING_HEADER_NAME) - } - - if (!stream.headers['content-length']) { - return bufferResponse(res, stream) - } - } else { - res.statusCode = stream.statusCode - pump(stream, res) + // the buffered response is sent with a content-length instead, + // a transfer-encoding can't be combined with it + res.removeHeader(TRANSFER_ENCODING_HEADER_NAME) + + return bufferResponse(res, stream) } + + res.statusCode = stream.statusCode + pump(stream, res) } } } diff --git a/lib/hostnames-hook.js b/lib/hostnames-hook.js index eaeb303..5375cc9 100644 --- a/lib/hostnames-hook.js +++ b/lib/hostnames-hook.js @@ -1,17 +1,6 @@ 'use strict' -let micromatch -try { - micromatch = require('micromatch') -} catch (e) { - micromatch = { - matcher (pattern) { - pattern = pattern.toLowerCase() - - return (value) => value === pattern - } - } -} +const micromatch = require('micromatch') module.exports = (hostname2prefix) => { // matchers are compiled once, caching results per host would grow without bound diff --git a/lib/ws-proxy.js b/lib/ws-proxy.js index 1f22c25..42d4652 100644 --- a/lib/ws-proxy.js +++ b/lib/ws-proxy.js @@ -11,16 +11,11 @@ const MAX_PENDING_BYTES = 1 * 1024 * 1024 // 1MB // time other 'upgrade' listeners get to handle a socket before it is ended const UNHANDLED_UPGRADE_TIMEOUT = 1000 -// faye-websocket only accepts 1000 or 3000-4999 as close codes, -// 1005 (no status code) is a normal close -const toCloseCode = (code, fallback) => { - if (code === 1005) return 1000 - - return Number.isInteger(code) && - (code === 1000 || (code >= 3000 && code <= 4999)) +// faye-websocket only accepts 1000 or 3000-4999 as close codes +const toCloseCode = (code, fallback) => + Number.isInteger(code) && (code === 1000 || (code >= 3000 && code <= 4999)) ? code : fallback -} const toCloseReason = (reason) => { if (typeof reason !== 'string') return '' @@ -51,15 +46,12 @@ const handshake = (ws) => // onAbnormalClose is called when the close code can't be relayed const relay = (from, to, onAbnormalClose) => { - from.on('message', (event) => { - if (to.send(event.data) === false) from.pause() - }) - to.on('drain', () => from.resume()) + from.on('message', (event) => to.send(event.data)) from.on('close', (event) => { const code = toCloseCode(event.code) if (code === undefined) return onAbnormalClose() - close(to, code, code === event.code ? event.reason : '') + close(to, code, event.reason) }) } @@ -86,7 +78,7 @@ module.exports = (config) => { if (!WebSocket.isWebSocket(req)) return onUnhandled(socket) const url = new URL('http://fw' + req.url) - const prefix = url.pathname || '/' + const prefix = url.pathname const match = matchers.find((matcher) => matcher.isMatch(prefix)) if (!match) return onUnhandled(socket) diff --git a/package.json b/package.json index 8ed4cc4..7a27b6c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,13 @@ "lint": "npx standard", "ws-bench": "npx artillery run benchmark/websocket/artillery-perf1.yml" }, + "nyc": { + "check-coverage": true, + "branches": 99, + "functions": 99, + "lines": 99, + "statements": 99 + }, "repository": { "type": "git", "url": "git+https://github.com/jkyberneees/fast-gateway.git" diff --git a/test/default-hooks.test.js b/test/default-hooks.test.js index 0c43cae..4bdd603 100644 --- a/test/default-hooks.test.js +++ b/test/default-hooks.test.js @@ -4,11 +4,14 @@ const expect = require('chai').expect const request = require('supertest') const http = require('http') +const net = require('net') const fastGateway = require('../index') +const defaultHooks = require('../lib/default-hooks') const rawRequest = require('./raw-http') const UPSTREAM_PORT = 9551 const GATEWAY_PORT = 9552 +const RAW_UPSTREAM_PORT = 9553 const binaryParser = (res, cb) => { const chunks = [] @@ -16,20 +19,20 @@ const binaryParser = (res, cb) => { res.on('end', () => cb(null, Buffer.concat(chunks))) } -const lambdaRoute = (prefix, payload) => ({ +const lambdaRoute = (prefix, payload, response = { StatusCode: 200 }) => ({ prefix, proxyType: 'lambda', target: 'a-lambda-function', proxyConfig: { lambdaProxy: (params, cb) => setImmediate(() => cb(null, { - StatusCode: 200, + ...response, Payload: JSON.stringify(payload) })) } }) describe('default hooks', () => { - let gateway, upstream + let gateway, upstream, rawUpstream const upstreamClosed = [] const pending = [] @@ -54,9 +57,20 @@ describe('default hooks', () => { }) await new Promise((resolve) => upstream.listen(UPSTREAM_PORT, resolve)) + // writes a response node's http server can't produce + rawUpstream = net.createServer((socket) => { + socket.once('data', () => { + socket.end('HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip, chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n') + }) + }) + await new Promise((resolve) => rawUpstream.listen(RAW_UPSTREAM_PORT, resolve)) + gateway = fastGateway({ routes: [ { prefix: '/http', target: `http://127.0.0.1:${UPSTREAM_PORT}` }, + { prefix: '/raw', target: `http://127.0.0.1:${RAW_UPSTREAM_PORT}` }, + lambdaRoute('/lambda-no-status', { body: 'no status' }, {}), + lambdaRoute('/lambda-no-body', { statusCode: 204 }), lambdaRoute('/lambda-no-status-code', { body: 'hello' }), lambdaRoute('/lambda-object-body', { statusCode: 201, body: { name: 'fast-gateway' } }), lambdaRoute('/lambda-base64', { @@ -75,6 +89,7 @@ describe('default hooks', () => { await gateway.close() upstream.closeAllConnections() await new Promise((resolve) => upstream.close(resolve)) + await new Promise((resolve) => rawUpstream.close(resolve)) }) describe('http', () => { @@ -108,6 +123,15 @@ describe('default hooks', () => { expect(upstreamClosed).to.deep.equal(['/endless']) }) + it('(Connection: close) buffered response does not mix transfer-encoding and content-length', async () => { + const response = await rawRequest(GATEWAY_PORT, '/raw/gzip-chunked') + + expect(response.statusCode).to.equal(200) + expect(response.headers['transfer-encoding']).to.equal(undefined) + expect(response.headers['content-length']).to.equal('5') + expect(response.body).to.equal('hello') + }) + it('(Connection: close) failed upstream body does not leak upstream headers', async () => { const response = await rawRequest(GATEWAY_PORT, '/http/gzip-aborted') @@ -127,6 +151,37 @@ describe('default hooks', () => { }) }) + it('defaults to 200 when neither the payload nor the invocation has a status code', async () => { + await request(gateway) + .get('/lambda-no-status/info') + .timeout(1000) + .expect(200) + .then((response) => { + expect(response.text).to.equal('no status') + }) + }) + + it('sends an empty body when the payload has none', async () => { + await request(gateway) + .get('/lambda-no-body/info') + .timeout(1000) + .expect(204) + }) + + it('handles a null payload', () => { + const res = { + end (body) { + this.ended = true + this.body = body + } + } + defaultHooks.lambda.onResponse({}, res, { StatusCode: 200, Payload: 'null' }) + + expect(res.statusCode).to.equal(200) + expect(res.ended).to.equal(true) + expect(res.body).to.equal(undefined) + }) + it('serializes non-string bodies', async () => { await request(gateway) .get('/lambda-object-body/info') diff --git a/test/hostnames-hook.test.js b/test/hostnames-hook.test.js index 807be43..fe03efc 100644 --- a/test/hostnames-hook.test.js +++ b/test/hostnames-hook.test.js @@ -79,6 +79,18 @@ describe('hostnames-hook', () => { }) }) + it('is not match - missing host header', (cb) => { + const req = { + headers: {}, + url: '/about' + } + + hostnamesHook(req, null, () => { + expect(req.url).to.equal('/about') + cb() + }) + }) + it('is match - host header is case-insensitive', (cb) => { const req = { headers: { diff --git a/test/routing.test.js b/test/routing.test.js index e05b03d..0dcb776 100644 --- a/test/routing.test.js +++ b/test/routing.test.js @@ -86,6 +86,21 @@ describe('routing', () => { throw new Error('failed after the response started') } }, + { + prefix: '/partial-response-completed', + target, + proxyHandler: (req, res) => { + res.writeHead(200) + res.write('partial') + + throw new Error('failed after the response started') + }, + hooks: { + onError (_err, req, res) { + res.end(' completed by onError') + } + } + }, { prefix: '/partial-response-on-error', target, @@ -150,6 +165,14 @@ describe('routing', () => { expect(response.body).to.not.match(/0\r\n\r\n$/) }) + it('keeps a partially sent response that onError completed', async () => { + const response = await rawRequest(9502, '/partial-response-completed/info', { timeout: 1000 }) + + expect(response.statusCode).to.equal(200) + expect(response.body).to.include('completed by onError') + expect(response.body).to.match(/0\r\n\r\n$/) + }) + it('terminates a partially sent response when onError does not respond', async () => { onErrorCalls.length = 0 const response = await rawRequest(9502, '/partial-response-on-error/info', { timeout: 1000 }) @@ -169,6 +192,7 @@ describe('routing', () => { { prefix: '/users', target }, { prefix: '/internal', prefixRewrite: '/v$&', target }, { prefix: '/slash', prefixRewrite: '/v2/', target }, + { prefix: '/tenants/:tid', target }, { prefix: '/', target } ] }, 9503) @@ -234,6 +258,15 @@ describe('routing', () => { }) }) + it('forwards urls unchanged when the prefix is not literal', async () => { + await request(gateway) + .get('/tenants/42/items') + .expect(200) + .then((response) => { + expect(response.body.url).to.equal('/tenants/42/items') + }) + }) + it('"/" prefix acts as a catch-all route', async () => { await request(gateway) .get('/foo/bar') @@ -270,6 +303,16 @@ describe('routing', () => { gateway = await start({ routes: [ { prefix: '/svc', target }, + { + prefix: '/no-query-parser', + target, + middlewares: [ + (req, res, next) => { + req.query = undefined + next() + } + ] + }, { prefix: '/strip-token', target, @@ -311,6 +354,15 @@ describe('routing', () => { }) }) + it('forwards the raw query string when req.query is not available', async () => { + await request(gateway) + .get('/no-query-parser/items?a=1&a=2') + .expect(200) + .then((response) => { + expect(response.body.rawUrl).to.equal('/items?a=1&a=2') + }) + }) + it('does not append "?" when there is no query string', async () => { await request(gateway) .get('/svc/items') @@ -490,6 +542,109 @@ describe('routing', () => { })).to.not.throw() }) + it('creates "http-legacy" proxies using fast-proxy', async () => { + // fast-proxy is an optional dependency, stand in for it with fast-proxy-lite + const Module = require('module') + const resolveFilename = Module._resolveFilename + const fastProxyLite = require('fast-proxy-lite') + const configs = [] + Module._resolveFilename = function (request, ...args) { + return request === 'fast-proxy' + ? 'fast-proxy' + : resolveFilename.call(this, request, ...args) + } + require.cache['fast-proxy'] = { + id: 'fast-proxy', + filename: 'fast-proxy', + loaded: true, + exports: (config) => { + configs.push(config) + + return fastProxyLite(config) + } + } + + try { + const gateway = await start({ + routes: [ + { + prefix: '/legacy', + proxyType: 'http-legacy', + target, + proxyConfig: { cacheURLs: 0 } + } + ] + }, 9513) + + await request(gateway) + .get('/legacy/info?a=1&a=2') + .expect(200) + .then((response) => { + expect(response.body.url).to.equal('/info?a=1&a=2') + }) + expect(configs).to.deep.equal([{ base: target, cacheURLs: 0 }]) + } finally { + Module._resolveFilename = resolveFilename + delete require.cache['fast-proxy'] + } + }) + + it('uses the default proxy factory when proxyFactory returns undefined', async () => { + const gateway = await start({ + proxyFactory: () => undefined, + routes: [{ prefix: '/svc', target }] + }, 9514) + + await request(gateway) + .get('/svc/info') + .expect(200) + .then((response) => { + expect(response.body.url).to.equal('/info') + }) + }) + + it('supports custom proxy types created by proxyFactory', async () => { + const gateway = await start({ + proxyFactory: ({ proxyType }) => { + if (proxyType === 'custom') { + return (req, res, url) => res.end('custom proxy: ' + url) + } + }, + routes: [{ prefix: '/custom', proxyType: 'custom', target }] + }, 9515) + + await request(gateway) + .get('/custom/items') + .expect(200) + .then((response) => { + expect(response.text).to.equal('custom proxy: /items') + }) + }) + + it('rejects unsupported proxy types', () => { + const routes = [{ prefix: '/svc', proxyType: 'unknown', target }] + + expect(() => fastGateway({ routes })).to.throw( + 'Unsupported proxy type, expecting one of http,http-legacy,lambda' + ) + expect(() => fastGateway({ proxyFactory: () => undefined, routes })).to.throw( + 'Unsupported proxy type: unknown!' + ) + }) + + it('requires getServer() on custom servers for websocket routes', () => { + expect(() => fastGateway({ + server: require('express')(), + routes: [{ proxyType: 'websocket', prefix: '/ws', target: 'ws://127.0.0.1:1' }] + })).to.throw(/Unable to retrieve the HTTP server instance/) + }) + + it('ignores methods the server does not support', () => { + expect(() => fastGateway({ + routes: [{ prefix: '/svc', methods: ['TRACE'], target }] + })).to.not.throw() + }) + it('services.json describes RegExp prefixes', async () => { const gateway = await start({ routes: [{ prefix: /\/regex\/.*/, target }] diff --git a/test/ws-proxy-bugs.test.js b/test/ws-proxy-bugs.test.js index 42fc159..1de5b09 100644 --- a/test/ws-proxy-bugs.test.js +++ b/test/ws-proxy-bugs.test.js @@ -113,6 +113,16 @@ describe('ws-proxy regressions', function () { target, hooks: { onOpen: failingHook(4401, 'Unauthorized: ' + 'x'.repeat(200)) } }, + { + proxyType: 'websocket', + prefix: '/broken-hooks', + target, + hooks: { + get onOpen () { + throw new Error('broken route config') + } + } + }, { proxyType: 'websocket', prefix: '/emoji-reason', @@ -189,6 +199,15 @@ describe('ws-proxy regressions', function () { expect(event.code).to.equal(1006) }) + it('drops the connection on unexpected errors instead of crashing', async () => { + const before = state.upgrades + const ws = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/broken-hooks`) + const event = await nextClose(ws) + + expect(event.code).to.equal(1006) + expect(state.upgrades).to.equal(before) + }) + it('truncates long close reasons on a character boundary', async () => { const ws = new WebSocket.Client(`ws://127.0.0.1:${GW_PORT}/emoji-reason`) const event = await nextClose(ws) From 54fb1b19f5004dcce878b61ae4a56212693cfede Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:52:47 +0000 Subject: [PATCH 6/7] chore: bump version to 6.0.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0134bwfxe5igRZeU6cwBSfzE --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9bc4c9d..9d03597 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fast-gateway", - "version": "4.2.0", + "version": "6.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fast-gateway", - "version": "4.2.0", + "version": "6.0.0", "license": "MIT", "dependencies": { "fast-proxy-lite": "^1.1.2", diff --git a/package.json b/package.json index 7a27b6c..8f486ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fast-gateway", - "version": "5.0.1", + "version": "6.0.0", "description": "A Node.js API Gateway for the masses!", "main": "index.js", "types": "index.d.ts", From ea6397cf71987050df8d79b92669816cf7d0cf66 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 24 Oct 2026 17:47:24 +0200 Subject: [PATCH 7/7] feat!: upgrade to restana 7 and update dependencies - restana ^7.0.0: routes are matched case-sensitively by default (restana: { caseSensitive: false } restores the previous behavior); inline the HTTP methods list, restana/libs/methods is no longer exported - mocha ^12.0.3, @types/node ^26.6.5, lockfile refreshed - require Node.js >= 24 (engines field, CI on 24.x) - docs: v6.x breaking changes, Node.js requirement, pinned docsify assets Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yaml | 4 +- README.md | 1 + docs/README.md | 39 +- docs/index.html | 8 +- index.js | 8 +- package-lock.json | 1084 ++++++++++++++++------------------ package.json | 9 +- test/routing.test.js | 36 +- 8 files changed, 598 insertions(+), 591 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 94208ae..e7ed905 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -6,10 +6,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - - name: Setup Environment (Using NodeJS 22.x) + - name: Setup Environment (Using NodeJS 24.x) uses: actions/setup-node@v1 with: - node-version: 22.x + node-version: 24.x - name: Install dependencies run: npm install diff --git a/README.md b/README.md index 175b5cc..2b47582 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Read more online: ```js npm i fast-gateway ``` +> Requires Node.js 24 or newer. Upgrading from v5.x? See the [breaking changes](https://fgw.21no.de/#/?id=v6x). ## Usage ### Gateway diff --git a/docs/README.md b/docs/README.md index b78cbc4..d0753f6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,8 @@ Read more online: npm i fast-gateway ``` +> Requires Node.js 24 or newer. Upgrading from v5.x? See the v6.x Breaking Changes below. + # Usage Next we describe two examples proxying HTTP and Lambda downstream services. @@ -120,6 +122,7 @@ module.exports.handler = serverless(service) server, // Optional restana library configuration (https://www.npmjs.com/package/restana#configuration) // Please note that if "server" is provided, this settings are ignored. + // Routes are matched case-sensitively, use { caseSensitive: false } to match them case-insensitively. restana: {}, // Optional global middlewares in the format: (req, res, next) => next() // Default value: [] @@ -175,7 +178,7 @@ module.exports.handler = serverless(service) endpoint: '/api-docs', type: 'swagger' }, - // Optional "prefix rewrite" before request is forwarded. Default value: '' + // Optional "prefix rewrite" before request is forwarded, used literally. Default value: '' prefixRewrite: '', // Optional "url rewrite" hook. If defined, the prefixRewrite setting is ignored. urlRewrite: (req) => req.url, @@ -541,6 +544,40 @@ You can support the maintenance of this project: # Breaking Changes +## v6.x + +**Runtime** + +- Node.js 24 or newer is required. +- [restana 7](https://github.com/BackendStack21/restana/blob/master/MIGRATION.md) is the default HTTP framework. Its trie router matches paths **case-sensitively**: `GET /Users` no longer matches a route with `prefix: '/users'`. Set `restana: { caseSensitive: false }` to keep the previous behavior. +- Errors handled by restana's default `errorHandler` respond with the standard status text, e.g. `{"code":404,"message":"Not Found"}` instead of `"Internal Server Error"`. + +**Routing** + +- Requests whose path contains `.` or `..` segments get a `400` response. This includes encoded (`%2e`, `%2f`, `%5c`), backslash and `;` parameter forms such as `/..;/`. +- The prefix is only stripped from the start of the URL. +- `prefixRewrite` is used literally, so `$&` and other replacement patterns are no longer expanded. +- `prefix: '/'` works as a catch-all route, and prefixes ending in `/` match nested paths. A `prefixRewrite` ending in `/` no longer produces `//`. + +**Proxying** + +- Chunked `Connection: close` responses over 1MB are streamed instead of rejected with `502`. +- A route's `timeout: 0` disables the global timeout. `hooks.request` is merged with the timeout instead of replacing it, and `req.timeout` takes precedence over `hooks.request.timeout`. +- Repeated query parameters (`?a=1&a=2`) are forwarded as-is. No trailing `?` is appended when there is no query string, and `proxyOpts.queryString` is then `null`. +- The routes and hooks passed to `gateway()` are no longer modified, so they can be shared between routes and gateways. +- If `onError` sends a response, `next(err)` is skipped. A response already partly sent when the error happened is destroyed. + +**Hostnames hook** + +- Hostnames are matched case-insensitively. Matchers are compiled when the hook is created, so later changes to the `hostname2prefix` array are not picked up. + +**WebSockets** + +- Close codes and reasons are relayed in both directions. When the upstream closes with a code that can't be relayed (1001, 1006, 1011), the client connection is dropped. +- Only the subprotocol agreed with the client is offered upstream, and the query string is forwarded unchanged. +- Messages sent while an async `onOpen` hook runs are queued and forwarded. Past 1MB of queued data, the client is closed with code `4413`. +- Upgrade requests that aren't WebSocket, or match no route, are ended. + ## v3.x - The `fast-proxy-lite` module is used by default to support `http` proxy type 🔥. This means, no `undici` or `http2` are supported by default. diff --git a/docs/index.html b/docs/index.html index 023142b..5333a61 100644 --- a/docs/index.html +++ b/docs/index.html @@ -7,7 +7,7 @@ - +