Skip to content

Commit a403d10

Browse files
author
molty3000
committed
fix: add MAX_BUFFER_SIZE guard for chunked responses and bump fast-proxy-lite to 1.1.3
- Add 1MB buffer limit in default onResponse hook to prevent OOM from unbounded chunked upstream responses when Connection: close - Bump fast-proxy-lite ^1.1.2 -> ^1.1.3 for SSRF fix (absolute URL blocking) - Add regression tests for buffer overflow protection - Remove unused stream-to-array import
1 parent c336e44 commit a403d10

3 files changed

Lines changed: 44 additions & 6 deletions

File tree

lib/default-hooks.js

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
'use strict'
22

33
const pump = require('pump')
4-
const toArray = require('stream-to-array')
54
const TRANSFER_ENCODING_HEADER_NAME = 'transfer-encoding'
65

6+
// Maximum size in bytes for buffered chunked responses (Connection: close)
7+
// Prevents memory exhaustion from unbounded upstream response bodies
8+
const MAX_BUFFER_SIZE = 1 * 1024 * 1024 // 1MB
9+
710
module.exports = {
811
websocket: {
912
onOpenNoOp (ws, searchParams) {}
@@ -38,8 +41,22 @@ module.exports = {
3841
}
3942

4043
if (!stream.headers['content-length']) {
41-
// pack all pieces into 1 buffer to calculate content length
42-
const resBuffer = Buffer.concat(await toArray(stream))
44+
// Collect chunks with size limit to prevent OOM
45+
const chunks = []
46+
let totalSize = 0
47+
48+
for await (const chunk of stream) {
49+
totalSize += chunk.length
50+
if (totalSize > MAX_BUFFER_SIZE) {
51+
stream.destroy()
52+
res.statusCode = 502
53+
res.end('Response body exceeds maximum allowed size')
54+
return
55+
}
56+
chunks.push(chunk)
57+
}
58+
59+
const resBuffer = Buffer.concat(chunks)
4360

4461
// add content-length header and send the merged response buffer
4562
res.setHeader('content-length', '' + Buffer.byteLength(resBuffer))

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
},
2929
"homepage": "https://github.com/jkyberneees/fast-gateway#readme",
3030
"dependencies": {
31-
"fast-proxy-lite": "^1.1.2",
31+
"fast-proxy-lite": "^1.1.3",
3232
"http-cache-middleware": "^1.4.1",
3333
"micromatch": "^4.0.8",
3434
"restana": "^6.0.0",
@@ -42,8 +42,8 @@
4242
"LICENSE"
4343
],
4444
"devDependencies": {
45-
"@types/node": "^25.7.0",
4645
"@types/express": "^5.0.6",
46+
"@types/node": "^25.7.0",
4747
"artillery": "^2.0.31",
4848
"aws-sdk": "^2.1693.0",
4949
"chai": "^6.2.2",
@@ -64,4 +64,4 @@
6464
"response-time": "^2.3.4",
6565
"supertest": "^7.2.2"
6666
}
67-
}
67+
}

test/smoke.test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ describe('API Gateway', () => {
3131
res.write('1')
3232
res.end()
3333
})
34+
remote.get('/large-chunked', (req, res) => {
35+
// Sends a 2MB chunked response to test buffer overflow protection
36+
const buf = Buffer.alloc(2 * 1024 * 1024, 'x')
37+
res.writeHead(200, { 'content-type': 'application/octet-stream' })
38+
res.write(buf)
39+
res.end()
40+
})
3441
remote.get('/cache', (req, res) => {
3542
res.setHeader('x-cache-timeout', '1 second')
3643
res.send({
@@ -369,6 +376,20 @@ describe('API Gateway', () => {
369376
})
370377
})
371378

379+
it('large chunked rejected with 502 when Connection close (buffer limit)', async () => {
380+
await request(gateway)
381+
.get('/users/large-chunked')
382+
.set({ Connection: 'close' })
383+
.expect(502)
384+
})
385+
386+
it('large chunked streamed normally when Connection keep-alive', async () => {
387+
await request(gateway)
388+
.get('/users/large-chunked')
389+
.set('Connection', 'keep-alive')
390+
.expect(200)
391+
})
392+
372393
it('(Should overwrite query string using req.query) GET /qs - 200', async () => {
373394
await request(gateway)
374395
.get('/qs?name=nodejs&category=js')

0 commit comments

Comments
 (0)