Skip to content

Commit 9ae98c9

Browse files
sugar-cat7usualomaautofix-ci[bot]
authored
fix(proxy): Correct hop-by-hop header handling per RFC 9110 (#4459)
* chore: fix hop-by-headers * test(proxy): fix test for hop-by-hop headers * Update src/helper/proxy/index.ts * reflect comments * ci: apply automated fixes * handling invalid header * throw error --------- Co-authored-by: Taku Amano <taku@taaas.jp> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 4b796cf commit 9ae98c9

2 files changed

Lines changed: 115 additions & 7 deletions

File tree

src/helper/proxy/index.test.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,24 +140,81 @@ describe('Proxy Middleware', () => {
140140

141141
it('remove hop-by-hop headers', async () => {
142142
const app = new Hono()
143-
app.get('/proxy/:path', (c) => proxy(`https://example.com/${c.req.param('path')}`))
143+
app.get('/proxy/:path', (c) => proxy(`https://example.com/${c.req.param('path')}`, c.req))
144+
145+
const res = await app.request('/proxy/hop-by-hop', {
146+
headers: {
147+
Host: 'example.com',
148+
Connection: 'keep-alive, custom-header',
149+
'Keep-Alive': 'timeout=5, max=1000',
150+
'Proxy-Authorization': 'Basic 123456',
151+
'Custom-Header': 'test',
152+
'Allowed-Custom-Header': 'test',
153+
},
154+
})
155+
const req = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]
156+
157+
expect(req.headers.get('Host')).toBe('example.com')
158+
expect(req.headers.get('Connection')).toBeNull()
159+
expect(req.headers.get('Keep-Alive')).toBeNull()
160+
expect(req.headers.get('Proxy-Authorization')).toBeNull()
161+
expect(req.headers.get('Custom-Header')).toBe('test')
162+
expect(req.headers.get('Allowed-Custom-Header')).toBe('test')
163+
164+
expect(res.headers.get('Transfer-Encoding')).toBeNull()
165+
})
166+
167+
it('remove hop-by-hop headers with strictConnectionProcessing', async () => {
168+
const app = new Hono()
169+
app.get('/proxy/:path', (c) =>
170+
proxy(`https://example.com/${c.req.param('path')}`, {
171+
...c.req,
172+
strictConnectionProcessing: true,
173+
})
174+
)
144175

145176
const res = await app.request('/proxy/hop-by-hop', {
146177
headers: {
147-
Connection: 'keep-alive',
178+
Host: 'example.com',
179+
Connection: 'keep-alive, custom-header',
148180
'Keep-Alive': 'timeout=5, max=1000',
149181
'Proxy-Authorization': 'Basic 123456',
182+
'Custom-Header': 'test',
183+
'Allowed-Custom-Header': 'test',
150184
},
151185
})
152186
const req = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]
153187

188+
expect(req.headers.get('Host')).toBe('example.com')
154189
expect(req.headers.get('Connection')).toBeNull()
155190
expect(req.headers.get('Keep-Alive')).toBeNull()
156191
expect(req.headers.get('Proxy-Authorization')).toBeNull()
192+
expect(req.headers.get('Custom-Header')).toBeNull()
193+
expect(req.headers.get('Allowed-Custom-Header')).toBe('test')
157194

158195
expect(res.headers.get('Transfer-Encoding')).toBeNull()
159196
})
160197

198+
it('invalid hop-by-hop headers with strictConnectionProcessing', async () => {
199+
const app = new Hono()
200+
app.get('/proxy/:path', (c) =>
201+
proxy(`https://example.com/${c.req.param('path')}`, {
202+
...c.req,
203+
strictConnectionProcessing: true,
204+
})
205+
)
206+
207+
const res = await app.request('/proxy/hop-by-hop', {
208+
headers: {
209+
Host: 'example.com',
210+
Connection: 'keep-alive, invalid-header invalid-header',
211+
'Keep-Alive': 'timeout=5, max=1000',
212+
},
213+
})
214+
215+
expect(res.status).toBe(400)
216+
})
217+
161218
it('specify hop-by-hop header by options', async () => {
162219
const app = new Hono()
163220
app.get('/proxy/:path', (c) =>

src/helper/proxy/index.ts

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Proxy Helper for Hono.
44
*/
55

6+
import { HTTPException } from '../../http-exception'
67
import type { RequestHeader } from '../../utils/headers'
78

89
// https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1
@@ -12,10 +13,14 @@ const hopByHopHeaders = [
1213
'proxy-authenticate',
1314
'proxy-authorization',
1415
'te',
15-
'trailers',
16+
'trailer',
1617
'transfer-encoding',
18+
'upgrade',
1719
]
1820

21+
// https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2
22+
const ALLOWED_TOKEN_PATTERN = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/
23+
1924
interface ProxyRequestInit extends Omit<RequestInit, 'headers'> {
2025
raw?: Request
2126
headers?:
@@ -24,20 +29,54 @@ interface ProxyRequestInit extends Omit<RequestInit, 'headers'> {
2429
| Record<RequestHeader, string | undefined>
2530
| Record<string, string | undefined>
2631
customFetch?: (request: Request) => Promise<Response>
32+
/**
33+
* Enable strict RFC 9110 compliance for Connection header processing.
34+
*
35+
* - `false` (default): Ignores Connection header to prevent potential
36+
* Hop-by-Hop Header Injection attacks. Recommended for untrusted clients.
37+
* - `true`: Processes Connection header per RFC 9110 and removes listed headers.
38+
* Only use in trusted environments.
39+
*
40+
* @default false
41+
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-7.6.1
42+
*/
43+
strictConnectionProcessing?: boolean
2744
}
2845

2946
interface ProxyFetch {
3047
(input: string | URL | Request, init?: ProxyRequestInit): Promise<Response>
3148
}
3249

3350
const buildRequestInitFromRequest = (
34-
request: Request | undefined
51+
request: Request | undefined,
52+
strictConnectionProcessing: boolean
3553
): RequestInit & { duplex?: 'half' } => {
3654
if (!request) {
3755
return {}
3856
}
3957

4058
const headers = new Headers(request.headers)
59+
60+
if (strictConnectionProcessing) {
61+
// https://datatracker.ietf.org/doc/html/rfc9110#section-7.6.1
62+
// Parse Connection header and remove listed headers (MUST per RFC 9110)
63+
const connectionValue = headers.get('connection')
64+
if (connectionValue) {
65+
const headerNames = connectionValue.split(',').map((h) => h.trim())
66+
// Validate header names per RFC 9110 Section 5.6.2 (token syntax)
67+
const invalidHeaders = headerNames.filter((h) => !ALLOWED_TOKEN_PATTERN.test(h))
68+
69+
if (invalidHeaders.length > 0) {
70+
throw new HTTPException(400, {
71+
message: `Invalid Connection header value: ${invalidHeaders.join(', ')}`,
72+
})
73+
}
74+
headerNames.forEach((headerName) => {
75+
headers.delete(headerName)
76+
})
77+
}
78+
}
79+
4180
hopByHopHeaders.forEach((header) => {
4281
headers.delete(header)
4382
})
@@ -108,14 +147,26 @@ const preprocessRequestInit = (requestInit: RequestInit): RequestInit => {
108147
* },
109148
* })
110149
* })
150+
*
151+
* // Strict RFC compliance mode (use only in trusted environments)
152+
* app.get('/internal-proxy/:path', (c) => {
153+
* return proxy(`http://${internalServer}/${c.req.param('path')}`, {
154+
* ...c.req,
155+
* strictConnectionProcessing: true,
156+
* })
157+
* })
111158
* ```
112159
*/
113160
export const proxy: ProxyFetch = async (input, proxyInit) => {
114-
const { raw, customFetch, ...requestInit } =
115-
proxyInit instanceof Request ? { raw: proxyInit } : proxyInit ?? {}
161+
const {
162+
raw,
163+
customFetch,
164+
strictConnectionProcessing = false,
165+
...requestInit
166+
} = proxyInit instanceof Request ? { raw: proxyInit } : proxyInit ?? {}
116167

117168
const req = new Request(input, {
118-
...buildRequestInitFromRequest(raw),
169+
...buildRequestInitFromRequest(raw, strictConnectionProcessing),
119170
...preprocessRequestInit(requestInit as RequestInit),
120171
})
121172
req.headers.delete('accept-encoding')

0 commit comments

Comments
 (0)