Skip to content

fix(res.send): add Content-Length only if Transfer-Encoding is not present - #7485

Open
hktitof wants to merge 2 commits into
expressjs:4.xfrom
hktitof:fix/4x-content-length-transfer-encoding
Open

hktitof wants to merge 2 commits into
expressjs:4.xfrom
hktitof:fix/4x-content-length-transfer-encoding

Conversation

@hktitof

@hktitof hktitof commented Sep 24, 2026

Copy link
Copy Markdown

Context: this is the 4.x branch missing what master already carries. #4893 stopped adding Content-Length next to Transfer-Encoding in june and #7459 kept the etag working while doing it, both merged on master, neither ever landed here

What is the current behavior?

when res.send() runs with a Transfer-Encoding header already set it still adds Content-Length next to it. RFC 9110 section 8.6 says a server must not send Content-Length in a response with Transfer-Encoding, and node's own http client refuses to parse such a response

raw response from untouched 4.22.3 (origin/4.x 899b524), Transfer-Encoding set by the app, body and etag untouched:

PROBE | HTTP/1.1 200 OK
PROBE | X-Powered-By: Express
PROBE | Transfer-Encoding: chunked
PROBE | Content-Type: text/html; charset=utf-8
PROBE | Content-Length: 5
PROBE | ETag: W/"5-qvTGHdzF6KLavt4PO0gs2a6pQ00"

a node client on the same wire fails before the body is even read:

Error: Parse Error: Content-Length can't be present with Transfer-Encoding
    code: 'HPE_INVALID_CONTENT_LENGTH'

so any node client hitting an express 4.x app that sets Transfer-Encoding (a streaming proxy handler, a gateway mixin, anything forwarding the hop-by-hop header) gets a connection level error on every response

Steps to reproduce

  1. npx mocha test/res.send.js on 4.x with the fix reverted, the test in "when Transfer-Encoding is set"
  2. Expected: no Content-Length on the wire, body and ETag exactly as before
  3. Actual (raw output on untouched 4.x): 1 failing, Error: Parse Error: Content-Length can't be present with Transfer-Encoding

What is the new behavior?

the body length is still calculated on every path because the automatic ETag depends on it, that is the exact regression #7459 fixed on master, so only the header write is guarded:

// Because Content-Length and Transfer-Encoding can't be present in the response headers together,
// Content-Length should be added only if there is no Transfer-Encoding header
if (!this.get('Transfer-Encoding')) {
  this.set('Content-Length', len);
}

same wire probe on the fixed branch:

PROBE | HTTP/1.1 200 OK
PROBE | Transfer-Encoding: chunked
PROBE | Content-Type: text/html; charset=utf-8
PROBE | ETag: W/"5-qvTGHdzF6KLavt4PO0gs2a6pQ00"
PROBE Transfer-Encoding present: true | Content-Length present: false

204, 205 and 304 paths are untouched (they manage their own headers), HEAD responses keep skipping the body, and the full test run passes with the one new test proving the guard

Does this PR introduce a breaking change?

  • Yes
  • No

…esent

Content-Length and Transfer-Encoding can't be present in the response
headers together, so Content-Length should be added only if there is no
Transfer-Encoding header.

The body length still needs to be calculated on every path because the
automatic ETag depends on it, so only the header write is guarded. This
imports the same invariant master already carries from expressjs#4893 and expressjs#7459
onto the 4.x branch.
@krzysdz krzysdz added the 4.x label Sep 24, 2026

@kgeminicdev kgeminicdev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the backport. I checked it against master and it matches: the guard in res.send is identical to master after #4893 and #7459, and the length is still computed for the ETag.

I also verified it locally on 4.x (899b524): with lib/response.js reverted, the new test fails with Parse Error: Content-Length can't be present with Transfer-Encoding, and with this change it passes, along with the rest of test/res.send.js (71 passing).

One small suggestion inline: porting #4893's tests too.

Not for this PR, just noting it: res.redirect still sets Content-Length unconditionally (on both 4.x and master), so it could hit the same conflict if Transfer-Encoding was set earlier. That might be worth a separate follow-up.

Comment thread test/res.send.js
})
})

describe('when Transfer-Encoding is set', function () {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this also carry over the tests from #4893? They cover an empty body (send('')) and the other Transfer-Encoding values (compress, deflate, gzip), which this test doesn't:

describe('when Transfer-Encoding header is present', function(){
  ['chunked', 'compress', 'deflate', 'gzip'].forEach(function(encoding){
    it('should not add Content-Length header if Transfer-Encoding header is equal to ' + encoding, function(done){
      var app = express();
      app.use(function(_, res){
        res.status(200).set('Transfer-Encoding', encoding).send('');
      });
      request(app)
        .get('/')
        .expect(utils.shouldNotHaveHeader('Content-Length'))
        .expect(utils.shouldHaveHeader('Transfer-Encoding'))
        .expect(200, '', done);
    })
  });
})

I ran them against this branch unchanged and all 4 pass, so it's just for coverage parity with master.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea @kgeminicdev, i ported the four encoding cases from #4893 so chunked, compress, deflate and gzip are all covered now, and i reverted lib/response.js on the branch to be sure they bite, all five fail with the same Content-Length and Transfer-Encoding parse error without the fix, then pass with it, full test/res.send.js run is 76 passing and the whole test folder is 1222 passing, pushed as a separate commit a308170 so the sha you reviewed is still the one you tested

…js#4893

master pinned chunked, compress, deflate and gzip plus the empty body in expressjs#4893, this 4.x backport only pinned chunked, so this adds the missing cases to keep the coverage the same as master
@hktitof

hktitof commented Oct 5, 2026

Copy link
Copy Markdown
Author

thanks for checking this against master and testing it locally on 4.x @kgeminicdev, really appreciate it, i ported the four encoding cases from #4893 as a separate commit a308170 so the sha you reviewed is untouched, on the res.redirect note you are right it can hit the same conflict, i will not mix it into this backport so this stays a straight port

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants