diff --git a/lib/handler/retry-handler.js b/lib/handler/retry-handler.js index ee2f69a2043..7ec506e21eb 100644 --- a/lib/handler/retry-handler.js +++ b/lib/handler/retry-handler.js @@ -68,6 +68,8 @@ class RetryHandler { this.start = 0 this.end = null this.etag = null + this.statusCode = null + this.headers = null } onResponseStartWithRetry (controller, statusCode, headers, statusMessage, err) { @@ -183,6 +185,8 @@ class RetryHandler { onResponseStart (controller, statusCode, headers, statusMessage) { this.error = null this.retryCount += 1 + this.statusCode = statusCode + this.headers = headers if (statusCode >= 300) { const err = new RequestRetryError('Request failed', statusCode, { @@ -320,6 +324,16 @@ class RetryHandler { } if (!this.error) { + // Verify that the received body length matches the expected range + // when we have a finite end position (from Content-Length or Content-Range) + if (this.end != null && Number.isFinite(this.end)) { + if (this.start !== this.end + 1) { + throw new RequestRetryError('Content-Range mismatch', this.statusCode, { + headers: this.headers, + data: { count: this.retryCount } + }) + } + } this.retryCount = 0 return this.handler.onResponseEnd?.(controller, trailers) } diff --git a/test/interceptors/retry.js b/test/interceptors/retry.js index 05ec772688f..6f4baf77b4d 100644 --- a/test/interceptors/retry.js +++ b/test/interceptors/retry.js @@ -458,6 +458,73 @@ test('Should handle 206 partial content - bad-etag', async t => { } }) +test('#4970 - Should reject resumed partial content when body exceeds Content-Range', async t => { + t = tspl(t, { plan: 5 }) + + let x = 0 + const injectedResponse = 'HTTP/1.1 302 Found\r\nLocation: http://evil.com\r\nContent-Length: 0\r\n\r\n' + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + if (x === 0) { + t.ok(true, 'pass') + res.setHeader('content-length', '5') + res.setHeader('etag', '123') + res.write('use') + setTimeout(() => { + res.destroy() + }, 1e2) + } else if (x === 1) { + t.deepStrictEqual(req.headers.range, 'bytes=3-4') + t.deepStrictEqual(req.headers['if-match'], '123') + res.statusCode = 206 + res.setHeader('etag', '123') + res.setHeader('content-range', 'bytes 3-4/5') + res.end(`r1${injectedResponse}`) + } + x++ + }) + + const requestOptions = { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + }, + retryOptions: { + retry: (err, { state, opts }, done) => { + if (err.message.includes('other side closed')) { + setTimeout(done, 100) + return + } + + return done(err) + } + } + } + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request(requestOptions) + t.strictEqual(response.statusCode, 200) + await t.rejects(response.body.text(), { + name: 'RequestRetryError', + code: 'UND_ERR_REQ_RETRY', + message: 'Content-Range mismatch' + }) +}) + test('retrying a request with a body', async t => { t = tspl(t, { plan: 2 }) let counter = 0