Bug
ipToInt returns -1 for any IPv6 address. The CIDR branch continues; the exact-match branch can't match an IPv4 entry. isBlocked returns false unconditionally for all IPv6 clients, regardless of what is configured in IP_BLOCKLIST.
Source
src/middleware/ip-blocklist.ts
43 function ipToInt(ip: string): number {
44 const parts = ip.split(".").map(Number);
45 if (parts.length !== 4 || parts.some(...)) return -1; // IPv6 always hits this
46 ...
88 function isBlocked(clientIp: string): boolean {
95 if (clientInt === -1) continue; // CIDR branch skips all IPv6
96 if (clientIp === entry.ip) return true; // exact branch can't match IPv4 entry
WS mirror: src/routes/ws.ts:602 (isClientIpBlocked) → same function. Auth-ban keys: ws.ts:130-152.
Impact
The middleware does not fail-closed — an unmatchable IPv6 IP flows to next() (the 400 path at ip-blocklist.ts:140-160 only fires when IP is null, not when it's an IPv6 string).
An operator who adds an abuser's IPv4 address to the blocklist is silently bypassed the moment the abuser switches to IPv6. The same root cause lets IPv6 rotation bypass the WS auth-failure ban, which also keys on the parsed IP.
Fix
Either add full IPv6 CIDR matching to parseEntry/isBlocked (using a vetted IP-parsing library, consistent with the /64 key fix), or if IPv6 blocklisting is out of scope, fail-closed: when PARSED_BLOCKLIST is non-empty and the client IP is IPv6, return a 403 rather than next(). Document the choice inline.
Note: the existing >>> vs === operator precedence at line 96 is correct — do not change it.
Verification
With IP_BLOCKLIST=<some-ipv4> set, connect from an IPv6 address → today: allowed. After fix: either matched (with IPv6 rules) or 403 (fail-closed).
Bug
ipToIntreturns-1for any IPv6 address. The CIDR branchcontinues; the exact-match branch can't match an IPv4 entry.isBlockedreturns false unconditionally for all IPv6 clients, regardless of what is configured inIP_BLOCKLIST.Source
src/middleware/ip-blocklist.tsWS mirror:
src/routes/ws.ts:602(isClientIpBlocked) → same function. Auth-ban keys:ws.ts:130-152.Impact
The middleware does not fail-closed — an unmatchable IPv6 IP flows to
next()(the 400 path atip-blocklist.ts:140-160only fires when IP is null, not when it's an IPv6 string).An operator who adds an abuser's IPv4 address to the blocklist is silently bypassed the moment the abuser switches to IPv6. The same root cause lets IPv6 rotation bypass the WS auth-failure ban, which also keys on the parsed IP.
Fix
Either add full IPv6 CIDR matching to
parseEntry/isBlocked(using a vetted IP-parsing library, consistent with the/64key fix), or if IPv6 blocklisting is out of scope, fail-closed: whenPARSED_BLOCKLISTis non-empty and the client IP is IPv6, return a 403 rather thannext(). Document the choice inline.Note: the existing
>>>vs===operator precedence at line 96 is correct — do not change it.Verification
With
IP_BLOCKLIST=<some-ipv4>set, connect from an IPv6 address → today: allowed. After fix: either matched (with IPv6 rules) or 403 (fail-closed).