Skip to content

v6.0.0: fix functional bugs found by adversarial review, 100% test coverage - #105

Merged
jkyberneees merged 7 commits into
masterfrom
fix/panel-functional-bugs
Oct 24, 2026
Merged

jkyberneees merged 7 commits into
masterfrom
fix/panel-functional-bugs

Conversation

@jkyberneees

@jkyberneees jkyberneees commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A panel of adversarial reviewers looked at four areas: routing, response hooks, the WebSocket proxy, and hostnames/config. Each bug needed a script that reproduced it before it counted. The fixes were then made test-first: the failing regression tests went in first (RED), then the fixes (GREEN). Two more adversarial review rounds of the branch followed. Every finding from those rounds was either fixed with its own test or is explained under Notes.

The PR also upgrades to restana 7 and moves every dependency to its latest version. restana 7 requires Node.js 24 and matches routes case-sensitively.

The suite grows from 57 to 131 tests and coverage goes from about 90% to 100% (statements, branches, functions, lines). nyc now fails CI below 99%. The version moves to 6.0.0 (latest on npm is 5.0.1) because several of these changes affect behavior someone may depend on (see Breaking changes).

Breaking changes

  • Node.js 24 or newer is required. restana 7 needs it. package.json now declares engines, and CI runs on Node 24.x.
  • restana 7 is the default HTTP framework (migration guide):
    • Routes are matched case-sensitively: GET /Users/42 no longer matches prefix: '/users'. With a catch-all route it goes there, unchanged. restana: { caseSensitive: false } restores the previous behavior.
    • Errors that reach restana's default errorHandler respond with the standard status text, e.g. {"code":404,"message":"Not Found"} instead of "Internal Server Error".
  • Path traversal: requests whose path contains . or .. segments now get 400. This includes %2e forms, \, %2f and %5c separators, and ; parameters such as /..;/. Before, they got past the route's prefix and the middlewares of the route they were meant to hit, because the upstream resolved them later.
  • Prefix handling:
    • The prefix is only stripped from the start of the URL. Stripping ignores case, so it still works with caseSensitive: false.
    • prefixRewrite is used literally, so $& and other replacement patterns are no longer expanded.
    • prefix: '/' now works as a catch-all, and prefixes ending in / match nested paths.
    • A prefixRewrite ending in / no longer produces //.
  • Large Connection: close responses: chunked responses over 1MB are now streamed instead of rejected with 502. Clients that always send Connection: close, such as Python's urllib, couldn't download them before. Memory is still capped at 1MB.
  • Timeouts:
    • timeout: 0 on a route now disables the global timeout, as documented.
    • hooks.request is merged with the timeout instead of replacing it.
    • req.timeout takes precedence over hooks.request.timeout.
  • Query strings:
    • Repeated parameters (?a=1&a=2) are forwarded as-is; before, they arrived as a=1%2C2.
    • No trailing ? is appended when there is no query string. In that case proxyOpts.queryString is null, as it already was with disableQsOverwrite.
  • Route configs: the routes and hooks you pass to gateway() (HTTP and WebSocket) are no longer modified. Sharing a hooks object between an http route and a lambda route, or reusing a routes array, used to crash or leak settings into the second gateway.
  • onError: if the hook sends a response, next(err) is skipped; before, this crashed the process with ERR_HTTP_HEADERS_SENT. A response that was already partly sent when the error happened is destroyed instead of left hanging.
  • Hostnames hook: matching is case-insensitive. Matchers are compiled once, when the hook is created, so later changes to the hostname2prefix array are not picked up. micromatch is now required; it was already a dependency.
  • WebSocket proxy:
    • Close codes and reasons are passed on in both directions.
    • When the upstream closes with a code that can't be relayed (1006, 1011, 1001), the client connection is dropped, so the client sees 1006 instead of a normal 1000.
    • Only the subprotocol already agreed with the client is offered upstream.
    • The query string is forwarded unchanged.
    • Messages sent while an async onOpen is running are queued and forwarded. This includes any message the hook itself reads. Past 1MB of queued data, the client is closed with 4413.
    • Upgrade requests that aren't WebSocket, or match no route, are ended right away when the gateway is the server's only 'upgrade' listener. When other listeners exist, the socket is ended after 1s if none of them answered. This is the same approach engine.io uses.

Bug fixes

Crashes and hangs

  • An async proxyHandler that rejected caused an unhandled rejection or a request that never finished. It now goes to onError and next(err).
  • A Lambda payload with no statusCode, or with an object body, crashed the process.
  • WebSocket: an onOpen error with close code 1008/1011, or a rejection with no value, crashed the process. A hook that threw synchronously lost its close code. An unreachable or reset upstream left the client open forever. When the client went away, the upstream connection leaked. Upgrades that weren't WebSocket hung.
  • Built-in WebSocket configuration: pathRegex: undefined with a prefix ending in / threw at startup.

Wrong responses

  • Lambda: isBase64Encoded is honored, non-string bodies are serialized, and the status code defaults to 200.
  • Connection: close: the upstream connection is released when the client disconnects while buffering. Error responses no longer carry upstream headers such as content-encoding: gzip. A Transfer-Encoding: gzip, chunked upstream is no longer answered with both transfer-encoding and content-length.
  • The documented req.query.x = … in onRequest failed when the request had no query string: restana uses a frozen empty object there.
  • proxyOpts.queryString is always a plain object. Repeated parameters are serialized correctly for fast-proxy-lite, fast-proxy (http-legacy) and custom proxies.
  • The http-legacy proxy type, listed in the docs, was rejected at startup.
  • /services.json printed {} for RegExp prefixes.
  • WebSocket: messages sent during an async onOpen were lost. A bogus handshake, or a client that left during onOpen, still opened an upstream connection. Close reasons are now cut on a UTF-8 character boundary.

Resource exhaustion

  • The hostnames hook kept a cache that grew without limit: random subdomains under a wildcard pattern kept about 21MB per 20k hosts. Hosts named like object properties (constructor) also produced broken URLs.

Dependencies

  • restana ^6.0.0 → ^7.0.0. restana 7 no longer exports restana/libs/methods, so the gateway's list of HTTP methods is now defined inline.
  • mocha ^11.7.5 → ^12.0.3, @types/node ^25.7.0 → ^26.6.5.
  • All other dependencies were already on their latest major. The lockfile now has their newest releases.
  • npm audit --omit=dev: 0 vulnerabilities. The 7 remaining findings are in dev-only tools (artillery, aws-sdk via http-lambda-proxy, @azure/identity) and have no non-breaking fix.

Docs

  • Landing page (docs/README.md): new v6.x breaking-changes section and a note that Node.js 24 is required. The comment on the restana option explains caseSensitive.
  • docs/index.html: the docsify assets load exact versions, and a stray . rendered on the page is gone.
  • README.md: the Node.js requirement and a link to the breaking changes.

Tests

  • New test files: test/routing.test.js, test/default-hooks.test.js, test/ws-proxy-bugs.test.js, and a raw HTTP client helper, test/raw-http.js.
  • test/routing.test.js covers both case-sensitive routing (the default) and caseSensitive: false.
  • New cases in test/hostnames-hook.test.js.
  • test/smoke.test.js: the large-chunked Connection: close test now expects the response to be streamed.
  • nyc check-coverage thresholds of 99% are set in package.json.

Notes

  • Squash-merge recommended. The first commit (RED) adds failing tests without their fixes, so CI fails on that commit alone; a squash merge avoids that.
  • Lockfile: package-lock.json was out of sync with package.json on master, so npm ci failed there. It has been regenerated and npm ci works again.
  • Not changed:
    • A path inside target (e.g. http://host/v2) is still dropped. That is how fast-proxy-lite builds URLs.
    • A query string set inside urlRewrite is still replaced by req.query; disableQsOverwrite is the documented way to keep it.
    • Failures inside fast-proxy-lite (503/504) don't reach onError, because the library answers them itself.

Test plan

  • npm test (Node 26): 131 passing, 100% coverage on statements, branches, functions and lines
  • npx standard: clean
  • npm ci --dry-run: lockfile in sync
  • Every regression test was confirmed failing on the code before its fix
  • CI on Node 24.x: passing

🤖 Generated with Claude Code

Regression tests for bugs confirmed by an adversarial review panel:
routing/url rewriting, query strings, timeouts, error hooks, route config
mutation, default http/lambda response hooks, hostnames hook and the
WebSocket proxy. The large chunked "Connection: close" smoke test now
expects the response to be streamed instead of rejected with 502.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0134bwfxe5igRZeU6cwBSfzE
Routing (index.js):
- onError hook sending a response no longer triggers a second response
  (ERR_HTTP_HEADERS_SENT crash)
- async proxyHandler rejections reach onError/next instead of crashing
- reject "."/".." path segments (incl. %2e and backslash forms) with 400,
  they let requests escape the route prefix upstream
- repeated query parameters are forwarded as-is instead of "a=1%2C2"
- req.query is writable in onRequest when there is no query string
- prefix is stripped case-insensitively, only at the start of the url and
  prefixRewrite is used literally
- prefix: '/' (or a trailing slash prefix) works as a catch-all
- hooks.request no longer drops the route timeout, timeout: 0 disables it
- route and hooks configs are not mutated (shared/reused configs broke)
- accept the documented "http-legacy" proxy type
- services.json prints RegExp prefixes

Default hooks:
- lambda: default status code, serialize non-string bodies, decode
  isBase64Encoded bodies (missing statusCode crashed the process)
- http (Connection: close): stream responses above the 1MB buffer limit
  instead of failing with 502, release the upstream when the client goes
  away, drop upstream headers on error responses

Hostnames hook: case-insensitive matching, no unbounded per-host cache,
no prototype key collisions.

WebSocket proxy: explicit relay propagating close codes and closing the
peer on errors, queue messages during async onOpen, don't connect upstream
for clients that already left, sanitize onOpen close codes, don't hang
non-WebSocket upgrades or end sockets owned by other upgrade listeners,
forward only the negotiated subprotocol and the original query string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0134bwfxe5igRZeU6cwBSfzE
- keep req.query as a plain object for non "http" proxy types, fast-proxy
  (http-legacy) stringified a URLSearchParams into an empty query
- avoid a double slash when prefixRewrite ends with "/"
- destroy a partially sent response when the handler fails without an
  onError hook instead of leaving the client hanging
- also reject dot segments followed by ";" path parameters ("/..;/")
- bound the messages queued while an async WebSocket onOpen hook is
  pending (1MB, closes the client with 4413)
- don't write hooks or matchers into WebSocket route configs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0134bwfxe5igRZeU6cwBSfzE
- destroy a partially sent response when onError doesn't respond either,
  instead of leaving the client hanging
- reject dot segments separated by encoded slashes/backslashes (%2f, %5c)
- don't append "?" to upstream urls when there is no query string
- keep proxyOpts.queryString a plain object; repeated parameters are
  appended individually through an iterator of [name, value] pairs, which
  also works for fast-proxy (http-legacy) and custom proxy types
- move the http-legacy default hooks to lib/default-hooks.js
- don't crash on a non-string pathRegex with a trailing slash prefix
- WebSocket: report upstream closes that can't be relayed (e.g. 1006, 1011)
  as abnormal closes to the client instead of 1000
- WebSocket: truncate close reasons once, on a UTF-8 character boundary
- WebSocket: end upgrades no listener handled within 1s when the server
  has other 'upgrade' listeners (same approach as engine.io)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0134bwfxe5igRZeU6cwBSfzE
- cover proxy factory fallbacks, custom and unsupported proxy types,
  http-legacy (with a fast-proxy stand-in), websocket routes on servers
  without getServer(), unsupported methods, non literal prefixes, servers
  without req.query, onError completing partial responses, lambda
  payloads without status code/body and hostnames without host header
- fix: a "Connection: close" buffered response for an upstream using
  "Transfer-Encoding: gzip, chunked" was sent with both transfer-encoding
  and content-length (invalid framing)
- remove unreachable code: micromatch fallback in the hostnames hook,
  1005 close code mapping (websocket-driver reports 1000), pause/drain
  relay handling (faye send() only returns false once closing), empty
  pathname fallback and the error listener added before destroy()
- enforce 99% statements/branches/functions/lines coverage with nyc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0134bwfxe5igRZeU6cwBSfzE
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0134bwfxe5igRZeU6cwBSfzE
@jkyberneees jkyberneees changed the title v6.0.0: fix functional bugs found by adversarial review, 100% test coverage v7.0.0: fix functional bugs found by adversarial review, 100% test coverage Oct 24, 2026
@jkyberneees jkyberneees changed the title v7.0.0: fix functional bugs found by adversarial review, 100% test coverage v6.0.0: fix functional bugs found by adversarial review, 100% test coverage Oct 24, 2026
- restana ^7.0.0: routes are matched case-sensitively by default
  (restana: { caseSensitive: false } restores the previous behavior);
  inline the HTTP methods list, restana/libs/methods is no longer exported
- mocha ^12.0.3, @types/node ^26.6.5, lockfile refreshed
- require Node.js >= 24 (engines field, CI on 24.x)
- docs: v6.x breaking changes, Node.js requirement, pinned docsify assets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jkyberneees
jkyberneees merged commit 632eeb5 into master Oct 24, 2026
5 checks passed
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.

2 participants