DEV Community

tomcate
tomcate

Posted on

My Site Has No Backend — Except One Route. Auditing It Found 4 Real Holes.

ToolVault is 172 developer tools that run entirely in your browser. No backend, no database, no uploads — that is the whole pitch. But there is exactly one server-side route: a proxy that lets the API tester reach endpoints whose CORS policy would block a direct browser call. One day I read its code the way an attacker would. This post is the four holes I found, and the fixes that survived testing against live redirector services.

Hole #1: the redirect that walked past the bouncer

The proxy validated its target before connecting: resolve the host, refuse private and reserved IPs. Solid — except HTTP redirects exist.

attacker.com  →  302 Location: http://169.254.169.254/latest/meta-data/
Enter fullscreen mode Exit fullscreen mode

My redirect loop followed Location and fired the next hop without re-running the validation. One DNS lookup at the door, then the guest list stopped mattering. Any public domain I controlled could pivot the proxy to cloud metadata, localhost services, or the internal network — the classic open-proxy escape.

The fix is one line of discipline, applied per hop: every redirect target goes through the same validation as the first. Not "validated once at entry" — validated at every hop, because every hop is a new request to a new host.

Hole #2: two DNS lookups means you checked a different host than you connected to

The deeper version doesn't even need a redirect. The code did this:

const records = await lookup(host);      // check: is any record private?
// ...later...
https.request({ hostname: host, ... });  // Node resolves host AGAIN
Enter fullscreen mode Exit fullscreen mode

Two independent resolutions. An attacker controlling the authoritative DNS answers the check with a public IP, then answers the real connection with 127.0.0.1. The validation passed; the connection went somewhere else. This is DNS rebinding, and any check-then-connect pattern written as two lookups is vulnerable to it.

The fix that kills the whole class: validate what you connect to, then connect to what you validated.

const ip = await resolveAndValidate(url);   // one lookup, checked
https.request({
  hostname: ip,                             // connect to the validated IP
  headers: { ...headers, host: url.host },  // Host header stays the domain
  servername: url.hostname,                 // TLS SNI stays the domain
});
Enter fullscreen mode Exit fullscreen mode

No second resolution exists, so there is no gap to race. The Host header and SNI still carry the original domain, so virtual-host routing and certificate validation behave exactly as before — verified against a public echo service that reported host: postman-echo.com while the socket connected to an IP.

Hole #3 and #4: the client-side bugs an audit caught for free

Digging through the surrounding code found two bugs that no test had caught:

btoa with a non-Latin1 password crashed the whole page. Basic auth preview ran btoa(user + ":" + pass) during render. A Chinese character in the password field — not exotic for a Chinese-locale site — throws InvalidCharacterError, and since it executed in the render path, the entire tool went white. The digest-auth path three components over already used the UTF-8-safe pattern; nobody had unified them. One shared toBase64Utf8() helper closed it.

A file upload that silently sent a filename instead of the file. The form-data editor updated a row twice in a row — set the File object, then set the display name. Both updates were computed from the same stale props closure, so the second call rebuilt the row from the pre-file state and erased the first. row.file was always null by the time the request was built; the multipart body carried the filename string. No error, no warning — the upload just didn't work, ever.

Both fixes are one-liners once diagnosed. The lesson isn't the bugs; it's that they survived 1,246 passing tests. Unit tests verify the code you thought to write; an adversarial read asks what the code actually permits.

The quiet ones: rate-limit trust and control headers

Two more, cheaper but worth naming:

  • The rate limiter read the client IP from x-real-ip — a header any client can set. Rotate the header, get a fresh bucket, defeat the limit entirely. Now the header is only trusted when the TCP peer itself is our own infra (nginx on the host, the Docker bridge): a public client cannot spoof its source address, so a private-range peer is trustworthy by physics.
  • Every x-proxy-* configuration header (including the "skip TLS verification" flag) was being forwarded to third-party targets. Your debugging flags leaking to someone else's API server is its own genre of incident. Strip the whole prefix before forwarding.

The checklist, compressed

If you run any server-side fetch of user-influenced URLs:

  1. Validate every hop — the entry check and the redirect check are the same check.
  2. Connect to the validated IP — one resolution, Host/SNI pinned to the domain. This deletes rebinding instead of racing it.
  3. Trust forwarded-for-style headers only from provable infra peers.
  4. Strip your own control headers before they reach the outside world.
  5. Stream with a hard cap — buffer a response of unbounded size and one hostile URL is an OOM; destroy the upstream socket at the limit and flag truncation.

And the meta-lesson: the proxy had comments describing excellent security. Comments are not capabilities. The audit that mattered was reading the code as "what can I make this do", not "what does it say it does" — and then verifying the fixes against a live redirector (https://postman-echo.com/redirect-to?url=http://127.0.0.1/... now dies with "forbidden address", which is the most satisfying log line I have read all week).

The proxy that does all of this correctly is part of the API tester on ToolVault — client-side by default, one hardened route when you need it. The rest of the series covers how the 100%-local architecture works and why AI crawlers love it.

Top comments (0)