Skip to content

Commit 4206f0e

Browse files
committed
Add strictContentLength option
Fixes #1462
1 parent 3891b46 commit 4206f0e

File tree

5 files changed

+211
-7
lines changed

5 files changed

+211
-7
lines changed

documentation/2-options.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,19 @@ console.log(response.headers['content-encoding']);
797797
//=> 'gzip'
798798
```
799799

800+
### `strictContentLength`
801+
802+
**Type: `boolean`**\
803+
**Default: `false`**
804+
805+
Throw an error if the server response's `content-length` header value doesn't match the number of bytes received.
806+
807+
This is useful for detecting truncated responses and follows RFC 9112 requirements for message completeness.
808+
809+
> [!NOTE]
810+
> - Responses without a `content-length` header are not validated.
811+
> - When enabled and validation fails, a [`ReadError`](8-errors.md#readerror) with code `ERR_HTTP_CONTENT_LENGTH_MISMATCH` will be thrown.
812+
800813
### `dnsLookup`
801814

802815
**Type: `Function`**\

documentation/8-errors.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@ When a cache method fails, for example, if the database goes down or there's a f
3737

3838
### `ReadError`
3939

40-
**Code: `ERR_READING_RESPONSE_STREAM`**
40+
**Code: `ERR_READING_RESPONSE_STREAM` or `ERR_HTTP_CONTENT_LENGTH_MISMATCH`**
4141

4242
When reading from response stream fails.
4343

44+
The error code will be `ERR_HTTP_CONTENT_LENGTH_MISMATCH` when the `strictContentLength` option is enabled and the server specifies a `content-length` header but the actual number of bytes received doesn't match.
45+
4446
### `ParseError`
4547

4648
**Code: `ERR_BODY_PARSE_FAILURE`**

source/core/index.ts

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import process from 'node:process';
22
import {Buffer} from 'node:buffer';
3-
import {Duplex, type Readable} from 'node:stream';
3+
import {Duplex, Transform, type Readable, type TransformCallback} from 'node:stream';
44
import http, {ServerResponse, type ClientRequest, type RequestOptions} from 'node:http';
55
import type {Socket} from 'node:net';
66
import timer, {type ClientRequestWithTimings, type Timings, type IncomingMessageWithTimings} from '@szmarczak/http-timer';
@@ -149,6 +149,19 @@ type UrlType = ConstructorParameters<typeof Options>[0];
149149
type OptionsType = ConstructorParameters<typeof Options>[1];
150150
type DefaultsType = ConstructorParameters<typeof Options>[2];
151151

152+
/**
153+
Stream transform that counts bytes passing through.
154+
Used to track compressed bytes before decompression for content-length validation.
155+
*/
156+
class ByteCounter extends Transform {
157+
count = 0;
158+
159+
override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void {
160+
this.count += chunk.length;
161+
callback(null, chunk);
162+
}
163+
}
164+
152165
export default class Request extends Duplex implements RequestEvents<Request> {
153166
// @ts-expect-error - Ignoring for now.
154167
override ['constructor']: typeof Request;
@@ -181,6 +194,8 @@ export default class Request extends Duplex implements RequestEvents<Request> {
181194
private _nativeResponse?: IncomingMessageWithTimings;
182195
private _flushed: boolean;
183196
private _aborted: boolean;
197+
private _expectedContentLength?: number;
198+
private _byteCounter?: ByteCounter;
184199

185200
// We need this because `this._request` if `undefined` when using cache
186201
private _requestInitialized: boolean;
@@ -596,6 +611,24 @@ export default class Request extends Duplex implements RequestEvents<Request> {
596611
return this;
597612
}
598613

614+
private _checkContentLengthMismatch(): boolean {
615+
if (this.options.strictContentLength && this._expectedContentLength !== undefined) {
616+
// Use ByteCounter's count when available (for compressed responses),
617+
// otherwise use _downloadedSize (for uncompressed responses)
618+
const actualSize = this._byteCounter?.count ?? this._downloadedSize;
619+
if (actualSize !== this._expectedContentLength) {
620+
this._beforeError(new ReadError({
621+
message: `Content-Length mismatch: expected ${this._expectedContentLength} bytes, received ${actualSize} bytes`,
622+
name: 'Error',
623+
code: 'ERR_HTTP_CONTENT_LENGTH_MISMATCH',
624+
}, this));
625+
return true;
626+
}
627+
}
628+
629+
return false;
630+
}
631+
599632
private async _finalizeBody(): Promise<void> {
600633
const {options} = this;
601634
const {headers} = options;
@@ -704,6 +737,15 @@ export default class Request extends Duplex implements RequestEvents<Request> {
704737
|| statusCode === 304;
705738

706739
if (options.decompress && !hasNoBody) {
740+
// When strictContentLength is enabled, track compressed bytes by listening to
741+
// the native response's data events before decompression
742+
if (options.strictContentLength) {
743+
this._byteCounter = new ByteCounter();
744+
this._nativeResponse.on('data', (chunk: Buffer) => {
745+
this._byteCounter!.count += chunk.length;
746+
});
747+
}
748+
707749
response = decompressResponse(response);
708750
}
709751

@@ -722,6 +764,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
722764
this._isFromCache = typedResponse.isFromCache;
723765

724766
this._responseSize = Number(response.headers['content-length']) || undefined;
767+
725768
this.response = typedResponse;
726769

727770
// Workaround for http-timer bug: when connecting to an IP address (no DNS lookup),
@@ -750,11 +793,14 @@ export default class Request extends Duplex implements RequestEvents<Request> {
750793
response.once('aborted', () => {
751794
this._aborted = true;
752795

753-
this._beforeError(new ReadError({
754-
name: 'Error',
755-
message: 'The server aborted pending request',
756-
code: 'ECONNRESET',
757-
}, this));
796+
// Check if there's a content-length mismatch to provide a more specific error
797+
if (!this._checkContentLengthMismatch()) {
798+
this._beforeError(new ReadError({
799+
name: 'Error',
800+
message: 'The server aborted pending request',
801+
code: 'ECONNRESET',
802+
}, this));
803+
}
758804
});
759805

760806
const rawCookies = response.headers['set-cookie'];
@@ -890,8 +936,30 @@ export default class Request extends Duplex implements RequestEvents<Request> {
890936
return;
891937
}
892938

939+
// Store the expected content-length from the native response for validation.
940+
// This is the content-length before decompression, which is what actually gets transferred.
941+
// Skip storing for responses that shouldn't have bodies per RFC 9110.
942+
// When decompression occurs, only store if strictContentLength is enabled.
943+
const wasDecompressed = response !== this._nativeResponse;
944+
if (!hasNoBody && (!wasDecompressed || options.strictContentLength)) {
945+
const contentLengthHeader = this._nativeResponse.headers['content-length'];
946+
if (contentLengthHeader !== undefined) {
947+
const expectedLength = Number(contentLengthHeader);
948+
if (!Number.isNaN(expectedLength) && expectedLength >= 0) {
949+
this._expectedContentLength = expectedLength;
950+
}
951+
}
952+
}
953+
893954
// Set up end listener AFTER redirect check to avoid emitting progress for redirect responses
894955
response.once('end', () => {
956+
// Validate content-length if it was provided
957+
// Per RFC 9112: "If the sender closes the connection before the indicated number
958+
// of octets are received, the recipient MUST consider the message to be incomplete"
959+
if (this._checkContentLengthMismatch()) {
960+
return;
961+
}
962+
895963
this._responseSize = this._downloadedSize;
896964
this.emit('downloadProgress', this.downloadProgress);
897965
this.push(null);

source/core/options.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,7 @@ const defaultInternals: Options['_internals'] = {
10091009
maxHeaderSize: undefined,
10101010
signal: undefined,
10111011
enableUnixSockets: false,
1012+
strictContentLength: false,
10121013
};
10131014

10141015
const cloneInternals = (internals: typeof defaultInternals) => {
@@ -2614,6 +2615,26 @@ export default class Options {
26142615
this._internals.enableUnixSockets = value;
26152616
}
26162617

2618+
/**
2619+
Throw an error if the server response's `content-length` header value doesn't match the number of bytes received.
2620+
2621+
This is useful for detecting truncated responses and follows RFC 9112 requirements for message completeness.
2622+
2623+
__Note__: Responses without a `content-length` header are not validated.
2624+
__Note__: When enabled and validation fails, a `ReadError` with code `ERR_HTTP_CONTENT_LENGTH_MISMATCH` will be thrown.
2625+
2626+
@default false
2627+
*/
2628+
get strictContentLength() {
2629+
return this._internals.strictContentLength;
2630+
}
2631+
2632+
set strictContentLength(value: boolean) {
2633+
assert.boolean(value);
2634+
2635+
this._internals.strictContentLength = value;
2636+
}
2637+
26172638
// eslint-disable-next-line @typescript-eslint/naming-convention
26182639
toJSON() {
26192640
return {...this._internals};

test/stream.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,3 +557,103 @@ test('PATCH stream without body completes successfully', withServer, async (t, s
557557
const data = await getStream(stream);
558558
t.is(data, 'patched');
559559
});
560+
561+
test('throws error when content-length does not match bytes transferred - stream', withServer, async (t, server, got) => {
562+
server.get('/', (_request, response) => {
563+
response.writeHead(200, {
564+
'content-length': '100',
565+
});
566+
response.write('ok'); // Only 2 bytes
567+
response.end();
568+
});
569+
570+
await t.throwsAsync(
571+
getStream(got.stream('', {strictContentLength: true})),
572+
{
573+
instanceOf: RequestError,
574+
message: /Content-Length mismatch/,
575+
},
576+
);
577+
});
578+
579+
test('throws error when content-length does not match bytes transferred - promise', withServer, async (t, server, got) => {
580+
server.get('/', (_request, response) => {
581+
response.writeHead(200, {
582+
'content-length': '100',
583+
});
584+
response.write('ok');
585+
response.end();
586+
});
587+
588+
await t.throwsAsync(
589+
got('', {strictContentLength: true}),
590+
{
591+
instanceOf: RequestError,
592+
message: /Content-Length mismatch/,
593+
},
594+
);
595+
});
596+
597+
test('does not throw when content-length matches bytes transferred', withServer, async (t, server, got) => {
598+
const payload = 'hello world';
599+
server.get('/', (_request, response) => {
600+
response.writeHead(200, {
601+
'content-length': String(Buffer.byteLength(payload)),
602+
});
603+
response.end(payload);
604+
});
605+
606+
const body = await got('', {strictContentLength: true}).text();
607+
t.is(body, payload);
608+
});
609+
610+
test('does not throw when content-length header is absent', withServer, async (t, server, got) => {
611+
server.get('/', (_request, response) => {
612+
response.writeHead(200);
613+
response.end('hello');
614+
});
615+
616+
const body = await got('', {strictContentLength: true}).text();
617+
t.is(body, 'hello');
618+
});
619+
620+
test('throws generic abort error (not content-length error) when strictContentLength is false (default)', withServer, async (t, server, got) => {
621+
server.get('/', (_request, response) => {
622+
response.writeHead(200, {
623+
'content-length': '100',
624+
});
625+
response.write('ok'); // Only 2 bytes
626+
response.end();
627+
});
628+
629+
await t.throwsAsync(
630+
got('').text(),
631+
{
632+
instanceOf: RequestError,
633+
// Should get generic abort error, not content-length specific error
634+
message: /aborted pending request/,
635+
},
636+
);
637+
});
638+
639+
test('validates content-length for gzip compressed responses', withServer, async (t, server, got) => {
640+
// Gzipped 'ok' - H4sIAAAAAAAA/8vPBgBH3dx5AgAAAA== base64 encoded
641+
const gzippedData = Buffer.from('H4sIAAAAAAAA/8vPBgBH3dx5AgAAAA==', 'base64');
642+
const incorrectLength = gzippedData.length + 10; // Intentionally wrong
643+
644+
server.get('/', (_request, response) => {
645+
response.writeHead(200, {
646+
'content-encoding': 'gzip',
647+
'content-length': String(incorrectLength),
648+
});
649+
response.end(gzippedData);
650+
});
651+
652+
await t.throwsAsync(
653+
got('', {strictContentLength: true}).text(),
654+
{
655+
instanceOf: RequestError,
656+
message: /Content-Length mismatch/,
657+
},
658+
);
659+
});

0 commit comments

Comments
 (0)