11import process from 'node:process' ;
22import { Buffer } from 'node:buffer' ;
3- import { Duplex , type Readable } from 'node:stream' ;
3+ import { Duplex , Transform , type Readable , type TransformCallback } from 'node:stream' ;
44import http , { ServerResponse , type ClientRequest , type RequestOptions } from 'node:http' ;
55import type { Socket } from 'node:net' ;
66import timer , { type ClientRequestWithTimings , type Timings , type IncomingMessageWithTimings } from '@szmarczak/http-timer' ;
@@ -149,6 +149,19 @@ type UrlType = ConstructorParameters<typeof Options>[0];
149149type OptionsType = ConstructorParameters < typeof Options > [ 1 ] ;
150150type 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+
152165export 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 ) ;
0 commit comments