Skip to content

Malformed request path (e.g. "//") crashes the server: unguarded new URL() in createRequest #33

Description

@andrevanzuydam

Summary

An unauthenticated request with a malformed path, for example GET //, crashes the
entire Node process. createRequest() builds new URL(req.url, base) with no guard,
and new URL("//", base) throws ERR_INVALID_URL. That throw is not caught anywhere
in the request dispatch path, so it surfaces as an uncaught exception and kills the
process. In a container this is a crash + restart on every such request, and scanners
and bots send // routinely, so it recurs on its own. This is effectively an
unauthenticated remote denial of service and affects any tina4-nodejs app.

Version observed: tina4-nodejs 3.13.70 on Node 22 (also reproduces on Node 24).

Reproduce

Start any tina4-nodejs server, then send a raw request with a // path. curl
normalizes the path, so use a raw socket:

printf 'GET // HTTP/1.1\r\nHost: localhost\r\n\r\n' | nc localhost 7148

The process exits with:

TypeError: Invalid URL
    at new URL (node:internal/url)
    at createRequest (packages/core/src/request.ts:49)
    at Server.dispatch (packages/core/src/server.ts:1051)
    at Server.emit (node:events)
    at parserOnIncoming (node:_http_server)
  code: 'ERR_INVALID_URL',
  input: '//',
  base: 'https://your-host'

Confirmed in isolation:

new URL("//", "https://example.com")   // throws ERR_INVALID_URL

Root cause

packages/core/src/request.ts:49

const url = new URL(req.url ?? "/", `${proto}://${host}`);

req.url is client controlled. A value of // (and other malformed paths) makes
new URL throw. This call runs at the very top of dispatch()
(server.ts:1051, const req = createRequest(rawReq);) with no surrounding
try/catch, so the throw escapes the request handler and becomes uncaught.

Proposed fix

Two layers of defense.

  1. Guard the parse in createRequest so a malformed path falls back to /:
let url: URL;
try {
  url = new URL(req.url ?? "/", `${proto}://${host}`);
} catch {
  url = new URL("/", `${proto}://${host}`);
}
  1. Wrap the dispatch() body in try/catch so any per-request throw returns a 400
    instead of taking down the process:
async function dispatch(rawReq: IncomingMessage, rawRes: ServerResponse): Promise<void> {
  try {
    // ... existing body ...
  } catch (e) {
    try { rawRes.statusCode = 400; rawRes.end("Bad Request"); } catch { /* socket gone */ }
  }
}

Parity

The comment on request.ts:49 claims parity with PHP/Python/Ruby. Those most likely
tolerate a malformed path already, so this looks like a Node-only regression. Worth a
cross-framework check so all four behave the same (return 400, stay up).

Suggested regression test (no mocks)

Boot a real server, open a raw TCP socket, send GET // HTTP/1.1, assert the response
status is 400 (not a dropped connection), then send a normal GET /health on a new
connection to prove the process is still alive.

Workaround in the meantime

App-level process.on("uncaughtException", ...) keeps the process alive, but that is a
band-aid: the offending request gets no response and the process state after an uncaught
exception is undefined. The framework fix above is the correct place to handle it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions