Skip to content

Commit db05b96

Browse files
authored
Merge commit from fork
1 parent 614b834 commit db05b96

2 files changed

Lines changed: 99 additions & 41 deletions

File tree

src/middleware/body-limit/index.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,4 +198,76 @@ describe('Body Limit Middleware', () => {
198198
expect(await res.text()).toBe(content)
199199
})
200200
})
201+
202+
describe('chunked body bypass scenarios', () => {
203+
const handler = vi.fn()
204+
205+
const buildOversizedRequestInit = (): RequestInit & { duplex: 'half' } => ({
206+
method: 'POST',
207+
headers: { 'Transfer-Encoding': 'chunked' },
208+
body: new ReadableStream({
209+
start(controller) {
210+
controller.enqueue(new TextEncoder().encode('1234'))
211+
controller.enqueue(new TextEncoder().encode('5678901234567890'))
212+
controller.close()
213+
},
214+
}),
215+
duplex: 'half',
216+
})
217+
218+
beforeEach(() => {
219+
handler.mockReset()
220+
app = new Hono()
221+
app.use('*', bodyLimit({ maxSize: 8 }))
222+
app.post('/no-read', (c) => {
223+
handler()
224+
return c.text('NO-READ-OK')
225+
})
226+
app.post('/partial-read', async (c) => {
227+
handler()
228+
const reader = c.req.raw.body?.getReader()
229+
const chunk = reader ? await reader.read() : { value: undefined }
230+
return c.text(new TextDecoder().decode(chunk.value))
231+
})
232+
app.post('/swallow-error', async (c) => {
233+
handler()
234+
const body = c.req.raw.body
235+
if (!body) {
236+
return c.text('no body')
237+
}
238+
const reader = body.getReader()
239+
let seen = ''
240+
try {
241+
for (;;) {
242+
const { done, value } = await reader.read()
243+
if (done) {
244+
break
245+
}
246+
seen += new TextDecoder().decode(value)
247+
}
248+
} catch {
249+
// intentionally swallow read errors
250+
}
251+
return c.text(`processed:${seen}`)
252+
})
253+
})
254+
255+
it('should reject when handler does not read the body', async () => {
256+
const res = await app.request('/no-read', buildOversizedRequestInit())
257+
expect(res.status).toBe(413)
258+
expect(handler).not.toHaveBeenCalled()
259+
})
260+
261+
it('should reject when handler reads only the first chunk', async () => {
262+
const res = await app.request('/partial-read', buildOversizedRequestInit())
263+
expect(res.status).toBe(413)
264+
expect(handler).not.toHaveBeenCalled()
265+
})
266+
267+
it('should reject when handler swallows body read errors', async () => {
268+
const res = await app.request('/swallow-error', buildOversizedRequestInit())
269+
expect(res.status).toBe(413)
270+
expect(handler).not.toHaveBeenCalled()
271+
})
272+
})
201273
})

src/middleware/body-limit/index.ts

Lines changed: 27 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,6 @@ type BodyLimitOptions = {
1515
onError?: OnError
1616
}
1717

18-
class BodyLimitError extends Error {
19-
constructor(message: string) {
20-
super(message)
21-
this.name = 'BodyLimitError'
22-
}
23-
}
24-
2518
/**
2619
* Body Limit Middleware for Hono.
2720
*
@@ -74,52 +67,45 @@ export const bodyLimit = (options: BodyLimitOptions): MiddlewareHandler => {
7467
const hasTransferEncoding = c.req.raw.headers.has('transfer-encoding')
7568
const hasContentLength = c.req.raw.headers.has('content-length')
7669

77-
// RFC 7230: If both Transfer-Encoding and Content-Length are present,
78-
// Transfer-Encoding takes precedence and Content-Length should be ignored
79-
if (hasTransferEncoding && hasContentLength) {
80-
// Both headers present - follow RFC 7230 and ignore Content-Length
81-
// This might indicate request smuggling attempt
82-
}
83-
8470
if (hasContentLength && !hasTransferEncoding) {
8571
// Only Content-Length present - we can trust it
8672
const contentLength = parseInt(c.req.raw.headers.get('content-length') || '0', 10)
8773
return contentLength > maxSize ? onError(c) : next()
8874
}
8975

90-
// Transfer-Encoding present (chunked) or no length headers
91-
76+
// Transfer-Encoding present (chunked) or no length headers.
77+
// Per RFC 7230, when both are present Transfer-Encoding takes precedence
78+
// and Content-Length is ignored. Read the body up-front so the size check
79+
// is final before the handler runs, regardless of how (or whether) the
80+
// handler reads the body.
9281
let size = 0
82+
const chunks: Uint8Array[] = []
9383
const rawReader = c.req.raw.body.getReader()
94-
const reader = new ReadableStream({
95-
async start(controller) {
96-
try {
97-
for (;;) {
98-
const { done, value } = await rawReader.read()
99-
if (done) {
100-
break
101-
}
102-
size += value.length
103-
if (size > maxSize) {
104-
controller.error(new BodyLimitError(ERROR_MESSAGE))
105-
break
106-
}
84+
for (;;) {
85+
const { done, value } = await rawReader.read()
86+
if (done) {
87+
break
88+
}
89+
size += value.length
90+
if (size > maxSize) {
91+
return onError(c)
92+
}
93+
chunks.push(value)
94+
}
10795

108-
controller.enqueue(value)
96+
const requestInit: RequestInit & { duplex: 'half' } = {
97+
body: new ReadableStream({
98+
start(controller) {
99+
for (const chunk of chunks) {
100+
controller.enqueue(chunk)
109101
}
110-
} finally {
111102
controller.close()
112-
}
113-
},
114-
})
115-
116-
const requestInit: RequestInit & { duplex: 'half' } = { body: reader, duplex: 'half' }
103+
},
104+
}),
105+
duplex: 'half',
106+
}
117107
c.req.raw = new Request(c.req.raw, requestInit as RequestInit)
118108

119-
await next()
120-
121-
if (c.error instanceof BodyLimitError) {
122-
c.res = await onError(c)
123-
}
109+
return next()
124110
}
125111
}

0 commit comments

Comments
 (0)