Namespace middleware that never calls next() admits the socket, bypassing authentication
Status derivation. No socket.io/VERSION file on disk, so the released version is unknown and this finding's status against the latest release cannot be derived.
Full finding note (as written in the manifest)
Affected
github.com/malcolmston/socketio <= v0.4.0. Parity case namespace-middleware-silent (group behaviour), recorded as divergence B4 in parity/socket.io/COVERAGE.md. Upstream oracle: socket.io@4.8.1 / socket.io-client@4.8.1 on Node v24.18.0.
Upstream vs port behaviour
Upstream nsp.use(fn) continues a handshake only when the middleware calls next(). A middleware that returns without calling either next() or next(err) leaves the handshake unanswered: the socket is never added to the namespace, no connection handler runs, and the client eventually surfaces a timeout connect error.
Namespace.runMiddleware in this port breaks out of the chain when next was not called but returns nil. A nil error is interpreted by the caller as "accepted", so the handshake is answered with CONNECT and the OnConnection handler runs.
| scenario | upstream | port |
|---|---|---|
middleware returns without calling next() | no reply; client times out; no connection event | CONNECT succeeds, connection fires, socket fully joined |
Impact
This is an authentication bypass. "Decline silently / hold the handshake and decide later" is a standard auth-middleware shape — deliberately not calling next(err) so the client learns nothing about why it was refused:
nsp.Use(func(h *socketio.Handshake, next func(error)) {
tok, ok := h.Auth["token"].(string)
if !ok || !valid(tok) {
return // deliberately silent: reject without leaking why
}
next(nil)
})
Against upstream this rejects the socket. Against this port it admits every socket, including sockets presenting no credential at all. The same failure mode is reached by any early return that forgets next(err): a panic/recover path, a ctx.Err() bail-out, or a return inside a validation branch. Every such path becomes an unconditional accept. The safe default for a middleware chain must be deny, not allow.
An unauthenticated remote attacker connecting to a namespace guarded this way is admitted with a live socket and full access to whatever the connection handler grants.
Repro
Register the middleware above on a namespace, then from a client:
sock, err := client.Dial(url+"/admin", client.Options{}) // no auth supplied
Upstream: Dial fails with a connect timeout. Port: Dial returns a live, joined socket.
Harness: GOWORK=off go test ./parity/socket.io/
Fix
In Namespace.runMiddleware, treat "chain terminated without next being called" as a failure. Match upstream by not answering the handshake at all, or at minimum return a non-nil error so the caller rejects the connection. Never return nil for a middleware that did not explicitly continue. Adding a regression test that asserts a silent middleware yields no connection event is advisable, since the current behaviour is indistinguishable from success at every layer above the chain.
Fixed
A fix has landed in the port's development tree (unreleased, > 0.4.0): Namespace.runMiddleware now returns a distinct errMiddlewareSilent sentinel when a middleware returns without calling next, and conn.handleConnect treats it as a denial that leaves the handshake unanswered (no CONNECT, no connection event) rather than an accept. Parity case namespace-middleware-silent now passes: a silent middleware yields no connection event and the client times out. The finding is retained for the record and for affectedVersions <= 0.4.0.