Repository navigation
Conversation
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
left a comment
There was a problem hiding this comment.
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.destroyedis true; - after
finish, and by thenresOnFinishhas already calleddetachSocket, sores.socketisnull.
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
left a comment
There was a problem hiding this comment.
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.destroyedis true; - after
finish, and by thenresOnFinishhas already calleddetachSocket, sores.socketisnull.
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.
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
closecan fire while the zlib stream is still flushing and the socket is alive. The unconditionaldestroy(stream)in theclosehandler then drops the in-flight bytes, and since_endis only called from the stream'sendevent, 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
test/compression.jssimulating the race from 1.8.2: intermittent HTTP response corruption on keep-alive connections (regression from the CVE-2026-87776 fix) #296: fails on the pristine tree (response never terminates), passes with the fix.Closes #296