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
constCOMPRESSION_RE=/gzip|deflate|br|compress|base64/i
2727
constCLIENT_HINT_VERSION_RE=/;v="(\d+)\.[^"]*"/g
2828
constSKIP_RESPONSE_HEADERS=newSet(['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+
exportconstSKIP_REQUEST_HEADERS=newSet([
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
constheaders: 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+
constconnectionHeaderValue=originalHeaders.connection
161+
constconnectionNamedHeaders=connectionHeaderValue
162+
? newSet(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]ofObject.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+
importtype{Server}from'node:http'
2+
import{createServer,requestashttpRequest}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+
letproxyServer: Server
34+
letproxyPort: number
35+
letSKIP_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+
letlastForwardedHeaders: Record<string,string>={}
41+
letupstreamServer: Server
42+
letupstreamPort: number
43+
letrealFetch: typeofglobalThis.fetch
44+
45+
beforeAll(async()=>{
46+
upstreamServer=createServer((_req,res)=>{
47+
res.writeHead(200,{'content-type': 'application/json'})
48+
res.end('{}')
49+
})
50+
awaitnewPromise<void>(resolve=>upstreamServer.listen(0,resolve))
51+
upstreamPort=(upstreamServer.address()asany).port
52+
53+
realFetch=globalThis.fetch
54+
globalThis.fetch=async(input: any,init?: any)=>{
55+
constreqUrl=typeofinput==='string' ? input : input.url
56+
consturl=newURL(reqUrl)
57+
if(url.hostname==='upstream.test'){
58+
// Capture the headers the proxy intended to forward
59+
consthdrs=(init?.headers??{})asRecord<string,string>
60+
lastForwardedHeaders={ ...hdrs}
61+
constredirected=`http://127.0.0.1:${upstreamPort}${url.pathname}${url.search}`
62+
returnrealFetch(redirected,{ ...init,headers: {}})
63+
}
64+
returnrealFetch(input,init)
65+
}
66+
67+
constmod=awaitimport('../../packages/script/src/runtime/server/proxy-handler')
68+
SKIP_REQUEST_HEADERS=mod.SKIP_REQUEST_HEADERS
69+
70+
constapp=createApp()
71+
app.use(mod.default)
72+
proxyServer=createServer(toNodeListener(app))
73+
awaitnewPromise<void>(resolve=>proxyServer.listen(0,resolve))
74+
proxyPort=(proxyServer.address()asany).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(consthof[
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+
functionrawGet(headers: Record<string,string>){
106+
returnnewPromise<void>((resolve,reject)=>{
107+
constreq=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+
awaitrawGet({
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(consthof['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)