Visitar URL original
fix: don't destroy compression stream when close fires on a live socket by pintu544 · Pull Request #297 · expressjs/compression · GitHub
Skip to content

fix: don't destroy compression stream when close fires on a live socket - #297

Open
pintu544 wants to merge 1 commit into
expressjs:masterfrom
pintu544:fix/issue-296-close-destroy-live-socket
Open

pintu544 wants to merge 1 commit into
expressjs:masterfrom
pintu544:fix/issue-296-close-destroy-live-socket

Conversation

@pintu544

Copy link
Copy Markdown

Fixes #296.

Problem
After the CVE-2026-87776 fix in 1.8.2 (destroy the zlib stream on response close), ~1 in 600 requests on keep-alive connections gets a corrupted/misframed response: the remaining compressed bytes are dropped and the response is never terminated, breaking framing for that and every subsequent response on the socket.

Root cause
close can fire while the zlib stream is still flushing and the socket is alive. The unconditional destroy(stream) in the close handler then drops the in-flight bytes, and since _end is only called from the stream's end event, the response is never terminated.

Solution
Only destroy the stream when the socket is dead or detached (if (!socket || socket.destroyed)). On a natural close the socket is always dead (abort) or the stream already ended (normal finish), so the CVE-2026-87776 fix is fully preserved; on a live socket the stream finishes naturally and frees its resources without the leak.

Tests

Closes #296

The 1.8.2 fix for CVE-2026-87776 destroys the zlib stream on response
'close'. If 'close' fires while the stream is still flushing and the
socket is alive, the remaining compressed bytes are dropped and the
response is never terminated ('_end' is only called from the stream's
'end' event), corrupting the framing for this and every subsequent
response on the keep-alive socket.

Only destroy when the socket is dead or detached: on a natural close
the socket is always dead (abort) or the stream already ended (normal
finish), so the CVE fix is preserved, while a close on a live socket
lets the stream finish naturally.

Adds a regression test simulating the race from expressjs#296.

@yxshee yxshee 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.

I looked into whether the new guard can trigger in real traffic. As far as I can tell, Node only emits close on a ServerResponse in two cases:

  • when the socket closes, and then res.socket.destroyed is true;
  • after finish, and by then resOnFinish has already called detachSocket, so res.socket is null.

Both cases take the destroy(stream) branch, so with this PR the stream is still destroyed in every case Node itself produces. The new test passes because it calls res.emit('close') by hand while the socket is attached, which I don't think happens outside the test.

To check, I ran 3,000 requests against master on Node 26 through a keep-alive agent (4 sockets). The mix was small JSON responses, streamed 330 KB responses, and every 11th request aborted mid-body. I counted close events where res.socket && !res.socket.destroyed: 0 of 3,000.

So I suspect #296 has a different cause, and this change wouldn't fix it. The reporter mentions aborted requests and sharp streams sharing the keep-alive agent. A way to narrow it down would be a reproduction against 1.8.2 that logs which res gets destroyed, and whether it's the same res whose framing breaks. If you have a way to make Node emit close with the socket still attached, a test that triggers it for real would make this much easier to accept.

@yxshee yxshee 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.

I looked into whether the new guard can trigger in real traffic. As far as I can tell, Node only emits close on a ServerResponse in two cases:

  • when the socket closes, and then res.socket.destroyed is true;
  • after finish, and by then resOnFinish has already called detachSocket, so res.socket is null.

Both cases take the destroy(stream) branch, so with this PR the stream is still destroyed in every case Node itself produces. The new test passes because it calls res.emit('close') by hand while the socket is attached, which I don't think happens outside the test.

To check, I ran 3,000 requests against master on Node 26 through a keep-alive agent (4 sockets). The mix was small JSON responses, streamed 330 KB responses, and every 11th request aborted mid-body. I counted close events where res.socket && !res.socket.destroyed: 0 of 3,000.

So I suspect #296 has a different cause, and this change wouldn't fix it. The reporter mentions aborted requests and sharp streams sharing the keep-alive agent. A way to narrow it down would be a reproduction against 1.8.2 that logs which res gets destroyed, and whether it's the same res whose framing breaks. If you have a way to make Node emit close with the socket still attached, a test that triggers it for real would make this much easier to accept.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1.8.2: intermittent HTTP response corruption on keep-alive connections (regression from the CVE-2026-87776 fix)

2 participants