Skip to content

Commit ef0011d

Browse files
committed
fix: validate EOF for chunked h1 responses (#5273)
(cherry picked from commit 13f6af6) Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 3420499 commit ef0011d

2 files changed

Lines changed: 132 additions & 23 deletions

File tree

lib/dispatcher/client-h1.js

Lines changed: 68 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -279,29 +279,71 @@ class Parser {
279279

280280
const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr
281281

282-
if (ret === constants.ERROR.PAUSED_UPGRADE) {
283-
this.onUpgrade(data.slice(offset))
284-
} else if (ret === constants.ERROR.PAUSED) {
285-
this.paused = true
286-
socket.unshift(data.slice(offset))
287-
} else if (ret !== constants.ERROR.OK) {
288-
const ptr = llhttp.llhttp_get_error_reason(this.ptr)
289-
let message = ''
290-
/* istanbul ignore else: difficult to make a test case for */
291-
if (ptr) {
292-
const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
293-
message =
294-
'Response does not match the HTTP/1.1 protocol (' +
295-
Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
296-
')'
282+
if (ret !== constants.ERROR.OK) {
283+
const body = data.subarray(offset)
284+
285+
if (ret === constants.ERROR.PAUSED_UPGRADE) {
286+
this.onUpgrade(body)
287+
} else if (ret === constants.ERROR.PAUSED) {
288+
this.paused = true
289+
socket.unshift(body)
290+
} else {
291+
throw this.createError(ret, body)
297292
}
298-
throw new HTTPParserError(message, constants.ERROR[ret], data.slice(offset))
299293
}
300294
} catch (err) {
301295
util.destroy(socket, err)
302296
}
303297
}
304298

299+
finish () {
300+
assert(currentParser === null)
301+
assert(this.ptr != null)
302+
assert(!this.paused)
303+
304+
const { llhttp } = this
305+
306+
let ret
307+
308+
try {
309+
currentParser = this
310+
ret = llhttp.llhttp_finish(this.ptr)
311+
} finally {
312+
currentParser = null
313+
}
314+
315+
if (ret === constants.ERROR.OK) {
316+
return null
317+
}
318+
319+
if (ret === constants.ERROR.PAUSED || ret === constants.ERROR.PAUSED_UPGRADE) {
320+
this.paused = true
321+
return null
322+
}
323+
324+
return this.createError(ret, EMPTY_BUF)
325+
}
326+
327+
createError (ret, data) {
328+
const { llhttp, contentLength, bytesRead } = this
329+
330+
if (contentLength && bytesRead !== parseInt(contentLength, 10)) {
331+
return new ResponseContentLengthMismatchError()
332+
}
333+
334+
const ptr = llhttp.llhttp_get_error_reason(this.ptr)
335+
let message = ''
336+
if (ptr) {
337+
const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
338+
message =
339+
'Response does not match the HTTP/1.1 protocol (' +
340+
Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
341+
')'
342+
}
343+
344+
return new HTTPParserError(message, constants.ERROR[ret], data)
345+
}
346+
305347
destroy () {
306348
assert(this.ptr != null)
307349
assert(currentParser == null)
@@ -673,8 +715,11 @@ async function connectH1 (client, socket) {
673715
// On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded
674716
// to the user.
675717
if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) {
676-
// We treat all incoming data so for as a valid response.
677-
parser.onMessageComplete()
718+
const parserErr = parser.finish()
719+
if (parserErr) {
720+
this[kError] = parserErr
721+
this[kClient][kOnError](parserErr)
722+
}
678723
return
679724
}
680725

@@ -693,8 +738,10 @@ async function connectH1 (client, socket) {
693738
const parser = this[kParser]
694739

695740
if (parser.statusCode && !parser.shouldKeepAlive) {
696-
// We treat all incoming data so far as a valid response.
697-
parser.onMessageComplete()
741+
const parserErr = parser.finish()
742+
if (parserErr) {
743+
util.destroy(this, parserErr)
744+
}
698745
return
699746
}
700747

@@ -706,8 +753,7 @@ async function connectH1 (client, socket) {
706753

707754
if (parser) {
708755
if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) {
709-
// We treat all incoming data so far as a valid response.
710-
parser.onMessageComplete()
756+
this[kError] = parser.finish() || this[kError]
711757
}
712758

713759
this[kParser].destroy()

test/parser-issues.js

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,16 @@
33
const { tspl } = require('@matteo.collina/tspl')
44
const { test, after } = require('node:test')
55
const net = require('node:net')
6-
const { Client, errors } = require('..')
6+
const { Client, errors, fetch } = require('..')
7+
8+
const truncatedChunkedResponse = Buffer.from(
9+
'HTTP/1.1 200 OK\r\n' +
10+
'Transfer-Encoding: chunked\r\n' +
11+
'Connection: close\r\n' +
12+
'\r\n' +
13+
'3\r\n' +
14+
'hel\r\n'
15+
)
716

817
test('https://github.com/mcollina/undici/issues/268', async (t) => {
918
t = tspl(t, { plan: 2 })
@@ -123,3 +132,57 @@ test('split header value', async (t) => {
123132

124133
await t.completed
125134
})
135+
136+
test('truncated chunked responses terminated by EOF error the response body', async (t) => {
137+
t = tspl(t, { plan: 3 })
138+
139+
const server = net.createServer((socket) => {
140+
socket.end(truncatedChunkedResponse)
141+
})
142+
after(() => server.close())
143+
144+
await new Promise(resolve => server.listen(0, resolve))
145+
146+
const client = new Client(`http://localhost:${server.address().port}`)
147+
after(() => client.destroy())
148+
149+
client.request({
150+
method: 'GET',
151+
path: '/'
152+
}, (err, { body } = {}) => {
153+
t.ifError(err)
154+
body
155+
.on('end', () => {
156+
t.fail('expected the truncated chunked body to fail')
157+
})
158+
.on('error', (err) => {
159+
t.strictEqual(err.name, 'HTTPParserError')
160+
t.strictEqual(err.message, 'Response does not match the HTTP/1.1 protocol (Invalid EOF state)')
161+
})
162+
.resume()
163+
})
164+
165+
await t.completed
166+
})
167+
168+
test('fetch rejects truncated chunked responses terminated by EOF', async (t) => {
169+
t = tspl(t, { plan: 3 })
170+
171+
const server = net.createServer((socket) => {
172+
socket.end(truncatedChunkedResponse)
173+
})
174+
after(() => server.close())
175+
176+
await new Promise(resolve => server.listen(0, resolve))
177+
178+
const res = await fetch(`http://localhost:${server.address().port}`)
179+
t.strictEqual(res.status, 200)
180+
181+
try {
182+
await res.text()
183+
t.fail('expected fetch to reject the truncated chunked body')
184+
} catch (err) {
185+
t.strictEqual(err.name, 'TypeError')
186+
t.strictEqual(err.cause?.message, 'Response does not match the HTTP/1.1 protocol (Invalid EOF state)')
187+
}
188+
})

0 commit comments

Comments
 (0)