Skip to content

Commit 1b5a531

Browse files
committed
fix(retry): reject partial content length mismatch
Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 466e99d commit 1b5a531

3 files changed

Lines changed: 84 additions & 7 deletions

File tree

lib/handler/retry-handler.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,26 @@ function calculateRetryAfterHeader (retryAfter) {
1515
return isNaN(retryTime) ? 0 : retryTime - Date.now()
1616
}
1717

18+
function validatePartialResponseContentLength (headers, range, statusCode, retryCount) {
19+
const contentLength = headers['content-length']
20+
if (contentLength == null) {
21+
return
22+
}
23+
24+
if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) {
25+
return
26+
}
27+
28+
const length = Number(contentLength)
29+
const expectedLength = range.end - range.start + 1
30+
if (!Number.isFinite(length) || length !== expectedLength) {
31+
throw new RequestRetryError('Content-Length mismatch', statusCode, {
32+
headers,
33+
data: { count: retryCount }
34+
})
35+
}
36+
}
37+
1838
class RetryHandler {
1939
constructor (opts, { dispatch, handler }) {
2040
const { retryOptions, ...dispatchOpts } = opts
@@ -229,6 +249,8 @@ class RetryHandler {
229249
})
230250
}
231251

252+
validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount)
253+
232254
const { start, size, end = size ? size - 1 : null } = contentRange
233255

234256
assert(this.start === start, 'content-range mismatch')
@@ -253,6 +275,8 @@ class RetryHandler {
253275
return
254276
}
255277

278+
validatePartialResponseContentLength(headers, range, statusCode, this.retryCount)
279+
256280
const { start, size, end = size ? size - 1 : null } = range
257281
assert(
258282
start != null && Number.isFinite(start),

test/interceptors/retry.js

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,59 @@ test('Should handle 206 partial content', async t => {
392392
t.strictEqual(counter, 1)
393393
})
394394

395+
test('Should reject initial 206 partial content with mismatched content-length', async t => {
396+
t = tspl(t, { plan: 3 })
397+
398+
let x = 0
399+
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
400+
if (x === 0) {
401+
t.strictEqual(req.headers.range, 'bytes=0-99')
402+
res.statusCode = 206
403+
res.setHeader('content-range', 'bytes 0-99/300')
404+
res.setHeader('content-length', '300')
405+
res.end('1'.repeat(99))
406+
res.socket?.destroy()
407+
} else if (x === 1) {
408+
res.statusCode = 206
409+
res.setHeader('content-range', 'bytes 99-99/300')
410+
res.setHeader('content-length', '1')
411+
res.end('1')
412+
}
413+
x++
414+
})
415+
416+
server.listen(0)
417+
418+
await once(server, 'listening')
419+
420+
const client = new Client(
421+
`http://localhost:${server.address().port}`
422+
).compose(retry())
423+
424+
after(async () => {
425+
await client.close()
426+
server.close()
427+
428+
await once(server, 'close')
429+
})
430+
431+
await t.rejects(async () => {
432+
const response = await client.request({
433+
method: 'GET',
434+
path: '/',
435+
headers: {
436+
range: 'bytes=0-99'
437+
}
438+
})
439+
await response.body.text()
440+
}, {
441+
name: 'RequestRetryError',
442+
code: 'UND_ERR_REQ_RETRY',
443+
message: 'Content-Length mismatch'
444+
})
445+
t.strictEqual(x, 1)
446+
})
447+
395448
test('Should handle 206 partial content - bad-etag', async t => {
396449
t = tspl(t, { plan: 5 })
397450

@@ -407,7 +460,7 @@ test('Should handle 206 partial content - bad-etag', async t => {
407460
}, 1e2)
408461
} else if (x === 1) {
409462
t.deepStrictEqual(req.headers.range, 'bytes=3-')
410-
res.setHeader('content-range', 'bytes 3-6/6')
463+
res.setHeader('content-range', 'bytes 3-5/6')
411464
res.setHeader('etag', 'erwsd')
412465
res.statusCode = 206
413466
res.end('def')

test/retry-handler2.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -671,7 +671,7 @@ test('Should handle 206 partial content | throwOnError: false', async t => {
671671
}, 1e2)
672672
} else if (x === 1) {
673673
t.deepStrictEqual(req.headers.range, 'bytes=3-')
674-
res.setHeader('content-range', 'bytes 3-6/6')
674+
res.setHeader('content-range', 'bytes 3-5/6')
675675
res.setHeader('etag', 'asd')
676676
res.statusCode = 206
677677
res.end('def')
@@ -768,7 +768,7 @@ test('Should handle 206 partial content - bad-etag | throwOnError: false', async
768768
}, 1e2)
769769
} else if (x === 1) {
770770
t.deepStrictEqual(req.headers.range, 'bytes=3-')
771-
res.setHeader('content-range', 'bytes 3-6/6')
771+
res.setHeader('content-range', 'bytes 3-5/6')
772772
res.setHeader('etag', 'erwsd')
773773
res.statusCode = 206
774774
res.end('def')
@@ -1223,7 +1223,7 @@ test('Issue#2986 - Handle custom 206 | throwOnError: false', async t => {
12231223
}, 1e2)
12241224
} else if (x === 1) {
12251225
t.deepStrictEqual(req.headers.range, 'bytes=3-')
1226-
res.setHeader('content-range', 'bytes 3-6/6')
1226+
res.setHeader('content-range', 'bytes 3-5/6')
12271227
res.setHeader('etag', 'asd')
12281228
res.statusCode = 206
12291229
res.end('def')
@@ -1324,7 +1324,7 @@ test('Issue#3128 - Support if-match | throwOnError: false', async t => {
13241324
t.deepStrictEqual(req.headers.range, 'bytes=3-')
13251325
t.deepStrictEqual(req.headers['if-match'], 'asd')
13261326

1327-
res.setHeader('content-range', 'bytes 3-6/6')
1327+
res.setHeader('content-range', 'bytes 3-5/6')
13281328
res.setHeader('etag', 'asd')
13291329
res.statusCode = 206
13301330
res.end('def')
@@ -1425,7 +1425,7 @@ test('Issue#3128 - Should ignore weak etags | throwOnError: false', async t => {
14251425
t.deepStrictEqual(req.headers.range, 'bytes=3-')
14261426
t.equal(req.headers['if-match'], undefined)
14271427

1428-
res.setHeader('content-range', 'bytes 3-6/6')
1428+
res.setHeader('content-range', 'bytes 3-5/6')
14291429
res.setHeader('etag', 'W/asd')
14301430
res.statusCode = 206
14311431
res.end('def')
@@ -1526,7 +1526,7 @@ test('Weak etags are ignored on range-requests | throwOnError: false', async t =
15261526
t.deepStrictEqual(req.headers.range, 'bytes=3-')
15271527
t.equal(req.headers['if-match'], undefined)
15281528

1529-
res.setHeader('content-range', 'bytes 3-6/6')
1529+
res.setHeader('content-range', 'bytes 3-5/6')
15301530
res.setHeader('etag', 'W/efg')
15311531
res.statusCode = 206
15321532
res.end('def')

0 commit comments

Comments
 (0)