Skip to content

Commit da57b6e

Browse files
authored
fix(proxy): strip hop-by-hop request headers (RFC 7230) (#804)
1 parent 8b4e999 commit da57b6e

2 files changed

Lines changed: 168 additions & 0 deletions

File tree

packages/script/src/runtime/server/proxy-handler.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ interface ProxyConfig {
2626
const COMPRESSION_RE = /gzip|deflate|br|compress|base64/i
2727
const CLIENT_HINT_VERSION_RE = /;v="(\d+)\.[^"]*"/g
2828
const SKIP_RESPONSE_HEADERS = new Set(['set-cookie', 'transfer-encoding', 'content-encoding', 'content-length'])
29+
// Hop-by-hop request headers per RFC 7230 §6.1 — must not be forwarded by a proxy
30+
export const SKIP_REQUEST_HEADERS = new Set([
31+
'connection',
32+
'keep-alive',
33+
'proxy-authenticate',
34+
'proxy-authorization',
35+
'te',
36+
'trailer',
37+
'transfer-encoding',
38+
'upgrade',
39+
])
2940

3041
/**
3142
* Strip fingerprinting from URL query string.
@@ -144,6 +155,13 @@ export default defineEventHandler(async (event) => {
144155

145156
const headers: Record<string, string> = {}
146157

158+
// Collect additional hop-by-hop headers named in the Connection header value (RFC 7230 §6.1).
159+
// e.g. `Connection: keep-alive, X-Custom` → also strip `X-Custom`.
160+
const connectionHeaderValue = originalHeaders.connection
161+
const connectionNamedHeaders = connectionHeaderValue
162+
? new Set(connectionHeaderValue.split(',').map(h => h.trim().toLowerCase()).filter(Boolean))
163+
: null
164+
147165
// Process headers based on per-flag privacy
148166
for (const [key, value] of Object.entries(originalHeaders)) {
149167
if (!value)
@@ -154,6 +172,14 @@ export default defineEventHandler(async (event) => {
154172
if (lowerKey === 'host')
155173
continue
156174

175+
// Hop-by-hop headers (RFC 7230 §6.1) — never forward
176+
if (SKIP_REQUEST_HEADERS.has(lowerKey))
177+
continue
178+
179+
// Headers listed in the Connection header are also hop-by-hop
180+
if (connectionNamedHeaders?.has(lowerKey))
181+
continue
182+
157183
// SENSITIVE_HEADERS always stripped regardless of privacy flags
158184
if (SENSITIVE_HEADERS.includes(lowerKey))
159185
continue
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import type { Server } from 'node:http'
2+
import { createServer, request as httpRequest } from 'node:http'
3+
import { createApp, toNodeListener } from 'h3'
4+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
/**
7+
* Tests for #791: proxy handler must strip hop-by-hop request headers per RFC 7230 §6.1.
8+
*
9+
* Hop-by-hop headers are connection-specific and must not be forwarded by a proxy:
10+
* connection, keep-alive, proxy-authenticate, proxy-authorization,
11+
* te, trailer, transfer-encoding, upgrade
12+
*
13+
* Additionally, any header named in the `Connection` header value must also be stripped.
14+
*/
15+
16+
vi.mock('nitropack/runtime', () => ({
17+
useRuntimeConfig: () => ({
18+
'nuxt-scripts-proxy': {
19+
proxyPrefix: '/_scripts/p',
20+
domainPrivacy: {
21+
'upstream.test': false,
22+
},
23+
privacy: false,
24+
debug: false,
25+
},
26+
}),
27+
useNitroApp: () => ({
28+
hooks: { callHook: async () => {} },
29+
}),
30+
}))
31+
32+
describe('proxy handler - hop-by-hop request headers (#791)', () => {
33+
let proxyServer: Server
34+
let proxyPort: number
35+
let SKIP_REQUEST_HEADERS: Set<string>
36+
37+
// Intercept the fetch call inside the handler so we can inspect the headers
38+
// that would actually be forwarded upstream (before the Node http client
39+
// re-adds its own connection/host headers).
40+
let lastForwardedHeaders: Record<string, string> = {}
41+
let upstreamServer: Server
42+
let upstreamPort: number
43+
let realFetch: typeof globalThis.fetch
44+
45+
beforeAll(async () => {
46+
upstreamServer = createServer((_req, res) => {
47+
res.writeHead(200, { 'content-type': 'application/json' })
48+
res.end('{}')
49+
})
50+
await new Promise<void>(resolve => upstreamServer.listen(0, resolve))
51+
upstreamPort = (upstreamServer.address() as any).port
52+
53+
realFetch = globalThis.fetch
54+
globalThis.fetch = async (input: any, init?: any) => {
55+
const reqUrl = typeof input === 'string' ? input : input.url
56+
const url = new URL(reqUrl)
57+
if (url.hostname === 'upstream.test') {
58+
// Capture the headers the proxy intended to forward
59+
const hdrs = (init?.headers ?? {}) as Record<string, string>
60+
lastForwardedHeaders = { ...hdrs }
61+
const redirected = `http://127.0.0.1:${upstreamPort}${url.pathname}${url.search}`
62+
return realFetch(redirected, { ...init, headers: {} })
63+
}
64+
return realFetch(input, init)
65+
}
66+
67+
const mod = await import('../../packages/script/src/runtime/server/proxy-handler')
68+
SKIP_REQUEST_HEADERS = mod.SKIP_REQUEST_HEADERS
69+
70+
const app = createApp()
71+
app.use(mod.default)
72+
proxyServer = createServer(toNodeListener(app))
73+
await new Promise<void>(resolve => proxyServer.listen(0, resolve))
74+
proxyPort = (proxyServer.address() as any).port
75+
})
76+
77+
beforeEach(() => {
78+
lastForwardedHeaders = {}
79+
})
80+
81+
afterAll(() => {
82+
if (realFetch)
83+
globalThis.fetch = realFetch
84+
upstreamServer?.close()
85+
proxyServer?.close()
86+
})
87+
88+
it('exports SKIP_REQUEST_HEADERS containing all RFC 7230 §6.1 hop-by-hop headers', () => {
89+
expect(SKIP_REQUEST_HEADERS).toBeInstanceOf(Set)
90+
for (const h of [
91+
'connection',
92+
'keep-alive',
93+
'proxy-authenticate',
94+
'proxy-authorization',
95+
'te',
96+
'trailer',
97+
'transfer-encoding',
98+
'upgrade',
99+
]) {
100+
expect(SKIP_REQUEST_HEADERS.has(h)).toBe(true)
101+
}
102+
})
103+
104+
// Use raw http.request because undici (global fetch) rejects forbidden hop-by-hop request headers.
105+
function rawGet(headers: Record<string, string>) {
106+
return new Promise<void>((resolve, reject) => {
107+
const req = httpRequest({
108+
host: '127.0.0.1',
109+
port: proxyPort,
110+
path: '/_scripts/p/upstream.test/collect',
111+
method: 'GET',
112+
headers,
113+
}, (res) => {
114+
res.resume()
115+
res.on('end', () => resolve())
116+
})
117+
req.on('error', reject)
118+
req.end()
119+
})
120+
}
121+
122+
it('strips hop-by-hop request headers before forwarding upstream', async () => {
123+
await rawGet({
124+
'connection': 'keep-alive, X-Custom-Hop',
125+
'keep-alive': 'timeout=5',
126+
'proxy-authorization': 'Bearer secret',
127+
'te': 'trailers',
128+
'x-custom-hop': 'should-not-forward',
129+
'accept': 'application/json',
130+
'user-agent': 'test-agent',
131+
})
132+
133+
for (const h of ['connection', 'keep-alive', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']) {
134+
expect(lastForwardedHeaders[h], `hop-by-hop "${h}" should not be forwarded`).toBeUndefined()
135+
}
136+
// Header named in Connection header is stripped too
137+
expect(lastForwardedHeaders['x-custom-hop']).toBeUndefined()
138+
// Non-hop-by-hop headers pass through
139+
expect(lastForwardedHeaders.accept).toBe('application/json')
140+
expect(lastForwardedHeaders['user-agent']).toBe('test-agent')
141+
})
142+
})

0 commit comments

Comments
 (0)