Repository navigation
http.Server 'upgrade' for a WebSocket handshake: Perry sends its own 101 and passes a non-socket to the listener, so socket.write is undefined and nothing the listener writes reaches the client #10470
Description
Found by the package audit (compiling real npm packages from source instead of Perry's native bindings) on
Perry e6dcb62 (v0.5.1587), Linux x64. When a request has Upgrade: websocket and Sec-WebSocket-Key and the
server has an 'upgrade' listener, Perry completes the WebSocket handshake itself with its own 101 before the
listener runs. It then hands the listener an internal WebSocket id instead of a net.Socket. The listener's
socket.write is undefined, its 'data' listener never fires, and head is undefined. Libraries that do the
handshake in JS, such as ws's WebSocketServer, cannot work.
Reproduction
main.ts (compiled with Perry; this is the server):
import http from "node:http";
const server = http.createServer((req, res) => { res.writeHead(426); res.end(); });
server.on("upgrade", (req: any, socket: any, head: any) => {
console.log("'upgrade'", req.url, "typeof socket.write:", typeof socket.write, "head is Buffer:", Buffer.isBuffer(head));
try {
socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Written-By: listener\r\n\r\n");
} catch (e: any) {
console.log("socket.write threw:", e.message);
}
socket.on("data", (d: any) => console.log("server got:", String(d)));
});
server.listen(Number(process.env.PORT), "127.0.0.1", () => console.log("READY"));client.mjs (plain Node, raw TCP so the bytes on the wire are visible):
import net from "node:net";
const key = process.env.NO_KEY ? "" : "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n";
const s = net.connect(Number(process.env.PORT), "127.0.0.1", () =>
s.write(`GET /chat HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\nUpgrade: ${process.env.PROTO || "websocket"}\r\n${key}\r\n`));
s.on("data", (d) => {
console.log("client got:", JSON.stringify(String(d)));
if (String(d).includes("\r\n\r\n")) s.write("ping");
});
setTimeout(() => { console.log("client: done"); s.destroy(); }, 1500);package.json: {"type":"module"}
perry compile main.ts -o server && PORT=18123 ./server & # or: PORT=18123 node main.ts &
PORT=18123 node client.mjsExpected (Node 26.5.1 as the server)
client got: "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Written-By: listener\r\n\r\n"
client: done
server:
READY
'upgrade' /chat typeof socket.write: function head is Buffer: true
server got: ping
Actual (Perry as the server)
Same output with and without PERRY_NO_AUTO_OPTIMIZE=1. The client receives Perry's generated 101, not the
listener's:
client got: "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-Websocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"
client: done
server (no exception is thrown, the write is silently dropped, and "ping" never arrives):
READY
'upgrade' /chat typeof socket.write: undefined head is Buffer: false
Impact
- ws 8.21.1
WebSocketServer, both{ server }and{ port }(the latter creates its ownhttp.Server,
lib/websocket-server.js:103):handleUpgrade(:244) validates the key, andcompleteUpgrade(:372) writes
its ownHTTP/1.1 101 Switching ProtocolswithSec-WebSocket-Acceptand the negotiated
extensions/subprotocol tosocket(:392), then builds theWebSocketon that socket. Under Perry neither the
write nor the frames get through. The audit's hand-written-handshake probe (a Node ws client against a Perry
server) timed out withoutopen. A compiled ws is first blocked by name-basedWebSocketServerrouting
(Native bindings are chosen by class/type name: user classes and compilePackages imports namedDatabase,Decimal,WebSocket,Redis,LRUCache,Command,CronJob,Pool,Socketare rewritten to native handles #10439). - Any server that answers the WebSocket handshake itself: socket.io/engine.io, graphql-ws,
@fastify/websocket
andexpress-ws(all through ws), and hand-written handshake servers (inferred). It also rules out rejecting a
handshake from JS (for examplesocket.write('HTTP/1.1 401 ...'); socket.destroy()for auth), because Perry has
already sent the 101.
Notes
-
Variants (both modes unless noted):
request Perry behavior Upgrade: websocket+Sec-WebSocket-Key(repro)Perry's own 101. Listener gets a non-socket. writeundefined, no'data',headundefined.Upgrade: echo, no key (NO_KEY=1 PROTO=echo)Listener's 101 reaches the client and writeworks.headis still not a Buffer and'data'never fires: http.Server 'upgrade' (raw socket path): data the client sends after the handshake never reachessocket.on('data'), andheadisundefinedinstead of a Buffer #10471.Upgrade: echo+Sec-WebSocket-Key(PROTO=echo)'upgrade'does not fire. The request goes to the normal request handler and the client gets426. Node fires'upgrade'. -
Cause (verified by reading the code):
- The raw, Node-style upgrade path in
crates/perry-ext-http/src/server/raw_upgrade.rsdeliberately skips any
request that hasSec-WebSocket-Key(raw_upgrade.rs:156, and module doc lines 13-28: "Real WebSocket
handshakes (key present) deliberately keep the tungstenite path"). - Those requests reach hyper. When the server has
'upgrade'listeners (or an attached native ws server),
server.rs:1228-1235callshandle_websocket_upgrade. That function returns its own101with
sec-websocket-accept(server.rs:1414-1417) and wraps the stream withtokio_tungstenite/
perry_ext_ws::register_external_ws_stream(server.rs:1401). - The listener is then fired with that ws id as
socketand an empty head (server.rs:1585-1596, the non-raw branch).
fire_upgrade_listenersencodes an empty head asundefinedand a non-empty head as a string, never a Buffer
(crates/perry-ext-http/src/server/upgrade.rs:82-88). - The keyed path fits Perry's native
wsbinding but not JS that owns the handshake. A likely fix: when the server
has JS'upgrade'listeners and no native attached ws server, take the raw path for keyed requests too, and pass
headas a Buffer (inferred).
- The raw, Node-style upgrade path in
-
Relation to A net.Socket given to an 'upgrade' listener cannot be written to: socket.write returns undefined #10342 (open, "A net.Socket given to an 'upgrade' listener cannot be written to: socket.write returns
undefined"): same symptom. Its suggested cause, a missingwriterow insocket_method_name, does not hold at this
commit.crates/perry-ext-net/src/dispatch.rs:175has had"write"since fix(lint): convert #8269's bare handle reads to with_* forms — unbreaks pr-gate on main #8291, and on the keyless raw path
socket.writeworks (table above). The remaining failure is the keyed tungstenite hand-off described here. This
could be posted as a comment on A net.Socket given to an 'upgrade' listener cannot be written to: socket.write returns undefined #10342 instead of a new issue. -
Related: ws: WebSocketServer({ server }) never upgrades — handshake answered by the HTTP handler with 200 OK #9619 (closed;
WebSocketServer({ server })through the native binding), Implement node:http / node:https / node:http2 + WebSocket upgrade for npm-ecosystem HTTP server compat #577 (closed; original upgrade
support). Searched: "upgrade socket", "server upgrade socket write", "'upgrade' event", "ws WebSocketServer
upgrade", "head Buffer upgrade", "Sec-WebSocket-Accept".
Activity