From 32592a8a4cf7690701674c0de0ab384559993ee3 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 27 May 2026 15:30:30 +1000 Subject: [PATCH 1/2] fix(proxy): strip hop-by-hop request headers (#791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 7230 §6.1, a proxy must not forward connection-specific hop-by-hop headers upstream. The handler already filters them on the response side via SKIP_RESPONSE_HEADERS; mirror that with a SKIP_REQUEST_HEADERS set covering connection, keep-alive, proxy-authenticate, proxy-authorization, te, trailer, transfer-encoding, upgrade. Also strip any header listed by name in the incoming Connection header value. Forwarding these can corrupt the upstream exchange (mis-framed bodies from a stale transfer-encoding, broken keep-alive negotiation) and has been observed to break Sentry proxying and native webview signals. Closes #791 --- .../src/runtime/server/proxy-handler.ts | 26 ++++ test/unit/proxy-handler-hop-by-hop.test.ts | 139 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 test/unit/proxy-handler-hop-by-hop.test.ts diff --git a/packages/script/src/runtime/server/proxy-handler.ts b/packages/script/src/runtime/server/proxy-handler.ts index df268bc9e..a699feab5 100644 --- a/packages/script/src/runtime/server/proxy-handler.ts +++ b/packages/script/src/runtime/server/proxy-handler.ts @@ -26,6 +26,17 @@ interface ProxyConfig { const COMPRESSION_RE = /gzip|deflate|br|compress|base64/i const CLIENT_HINT_VERSION_RE = /;v="(\d+)\.[^"]*"/g const SKIP_RESPONSE_HEADERS = new Set(['set-cookie', 'transfer-encoding', 'content-encoding', 'content-length']) +// Hop-by-hop request headers per RFC 7230 §6.1 — must not be forwarded by a proxy +export const SKIP_REQUEST_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]) /** * Strip fingerprinting from URL query string. @@ -144,6 +155,13 @@ export default defineEventHandler(async (event) => { const headers: Record = {} + // Collect additional hop-by-hop headers named in the Connection header value (RFC 7230 §6.1). + // e.g. `Connection: keep-alive, X-Custom` → also strip `X-Custom`. + const connectionHeaderValue = originalHeaders.connection + const connectionNamedHeaders = connectionHeaderValue + ? new Set(connectionHeaderValue.split(',').map(h => h.trim().toLowerCase()).filter(Boolean)) + : null + // Process headers based on per-flag privacy for (const [key, value] of Object.entries(originalHeaders)) { if (!value) @@ -154,6 +172,14 @@ export default defineEventHandler(async (event) => { if (lowerKey === 'host') continue + // Hop-by-hop headers (RFC 7230 §6.1) — never forward + if (SKIP_REQUEST_HEADERS.has(lowerKey)) + continue + + // Headers listed in the Connection header are also hop-by-hop + if (connectionNamedHeaders?.has(lowerKey)) + continue + // SENSITIVE_HEADERS always stripped regardless of privacy flags if (SENSITIVE_HEADERS.includes(lowerKey)) continue diff --git a/test/unit/proxy-handler-hop-by-hop.test.ts b/test/unit/proxy-handler-hop-by-hop.test.ts new file mode 100644 index 000000000..e5c0bf142 --- /dev/null +++ b/test/unit/proxy-handler-hop-by-hop.test.ts @@ -0,0 +1,139 @@ +import type { Server } from 'node:http' +import { createServer, request as httpRequest } from 'node:http' +import { createApp, toNodeListener } from 'h3' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Tests for #791: proxy handler must strip hop-by-hop request headers per RFC 7230 §6.1. + * + * Hop-by-hop headers are connection-specific and must not be forwarded by a proxy: + * connection, keep-alive, proxy-authenticate, proxy-authorization, + * te, trailer, transfer-encoding, upgrade + * + * Additionally, any header named in the `Connection` header value must also be stripped. + */ + +vi.mock('nitropack/runtime', () => ({ + useRuntimeConfig: () => ({ + 'nuxt-scripts-proxy': { + proxyPrefix: '/_scripts/p', + domainPrivacy: { + 'upstream.test': false, + }, + privacy: false, + debug: false, + }, + }), + useNitroApp: () => ({ + hooks: { callHook: async () => {} }, + }), +})) + +describe('proxy handler - hop-by-hop request headers (#791)', () => { + let proxyServer: Server + let proxyPort: number + let SKIP_REQUEST_HEADERS: Set + + // Intercept the fetch call inside the handler so we can inspect the headers + // that would actually be forwarded upstream (before the Node http client + // re-adds its own connection/host headers). + let lastForwardedHeaders: Record = {} + let upstreamServer: Server + let upstreamPort: number + + beforeAll(async () => { + upstreamServer = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{}') + }) + await new Promise(resolve => upstreamServer.listen(0, resolve)) + upstreamPort = (upstreamServer.address() as any).port + + const realFetch = globalThis.fetch + globalThis.fetch = async (input: any, init?: any) => { + const reqUrl = typeof input === 'string' ? input : input.url + const url = new URL(reqUrl) + if (url.hostname === 'upstream.test') { + // Capture the headers the proxy intended to forward + const hdrs = (init?.headers ?? {}) as Record + lastForwardedHeaders = { ...hdrs } + const redirected = `http://127.0.0.1:${upstreamPort}${url.pathname}${url.search}` + return realFetch(redirected, { ...init, headers: {} }) + } + return realFetch(input, init) + } + + const mod = await import('../../packages/script/src/runtime/server/proxy-handler') + SKIP_REQUEST_HEADERS = mod.SKIP_REQUEST_HEADERS + + const app = createApp() + app.use(mod.default) + proxyServer = createServer(toNodeListener(app)) + await new Promise(resolve => proxyServer.listen(0, resolve)) + proxyPort = (proxyServer.address() as any).port + }) + + beforeEach(() => { + lastForwardedHeaders = {} + }) + + afterAll(() => { + upstreamServer?.close() + proxyServer?.close() + }) + + it('exports SKIP_REQUEST_HEADERS containing all RFC 7230 §6.1 hop-by-hop headers', () => { + expect(SKIP_REQUEST_HEADERS).toBeInstanceOf(Set) + for (const h of [ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + ]) { + expect(SKIP_REQUEST_HEADERS.has(h)).toBe(true) + } + }) + + // Use raw http.request because undici (global fetch) rejects forbidden hop-by-hop request headers. + function rawGet(headers: Record) { + return new Promise((resolve, reject) => { + const req = httpRequest({ + host: '127.0.0.1', + port: proxyPort, + path: '/_scripts/p/upstream.test/collect', + method: 'GET', + headers, + }, (res) => { + res.resume() + res.on('end', () => resolve()) + }) + req.on('error', reject) + req.end() + }) + } + + it('strips hop-by-hop request headers before forwarding upstream', async () => { + await rawGet({ + 'connection': 'keep-alive, X-Custom-Hop', + 'keep-alive': 'timeout=5', + 'proxy-authorization': 'Bearer secret', + 'te': 'trailers', + 'x-custom-hop': 'should-not-forward', + 'accept': 'application/json', + 'user-agent': 'test-agent', + }) + + for (const h of ['connection', 'keep-alive', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']) { + expect(lastForwardedHeaders[h], `hop-by-hop "${h}" should not be forwarded`).toBeUndefined() + } + // Header named in Connection header is stripped too + expect(lastForwardedHeaders['x-custom-hop']).toBeUndefined() + // Non-hop-by-hop headers pass through + expect(lastForwardedHeaders.accept).toBe('application/json') + expect(lastForwardedHeaders['user-agent']).toBe('test-agent') + }) +}) From c519a39a6a3ab82d2ab602493dcc74114934692d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 27 May 2026 17:01:25 +1000 Subject: [PATCH 2/2] test: restore globalThis.fetch in hop-by-hop suite teardown --- test/unit/proxy-handler-hop-by-hop.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/unit/proxy-handler-hop-by-hop.test.ts b/test/unit/proxy-handler-hop-by-hop.test.ts index e5c0bf142..7ddaf12b1 100644 --- a/test/unit/proxy-handler-hop-by-hop.test.ts +++ b/test/unit/proxy-handler-hop-by-hop.test.ts @@ -40,6 +40,7 @@ describe('proxy handler - hop-by-hop request headers (#791)', () => { let lastForwardedHeaders: Record = {} let upstreamServer: Server let upstreamPort: number + let realFetch: typeof globalThis.fetch beforeAll(async () => { upstreamServer = createServer((_req, res) => { @@ -49,7 +50,7 @@ describe('proxy handler - hop-by-hop request headers (#791)', () => { await new Promise(resolve => upstreamServer.listen(0, resolve)) upstreamPort = (upstreamServer.address() as any).port - const realFetch = globalThis.fetch + realFetch = globalThis.fetch globalThis.fetch = async (input: any, init?: any) => { const reqUrl = typeof input === 'string' ? input : input.url const url = new URL(reqUrl) @@ -78,6 +79,8 @@ describe('proxy handler - hop-by-hop request headers (#791)', () => { }) afterAll(() => { + if (realFetch) + globalThis.fetch = realFetch upstreamServer?.close() proxyServer?.close() })