Skip to content

fix(api): block SSRF to private/reserved targets in shared middleware#315

Open
sebastiondev wants to merge 1 commit into
lissy93:masterfrom
sebastiondev:fix/cwe918-middleware-full-9cfe
Open

fix(api): block SSRF to private/reserved targets in shared middleware#315
sebastiondev wants to merge 1 commit into
lissy93:masterfrom
sebastiondev:fix/cwe918-middleware-full-9cfe

Conversation

@sebastiondev

Copy link
Copy Markdown

Summary

The shared API middleware (api/_common/middleware.js) accepts a user-supplied ?url= query parameter and hands it to every downstream handler (fetch, headless Chromium, raw net/dns calls, etc.) without checking whether the target is public. On the hosted instance at web-check.xyz β€” and on any self-hosted deployment that doesn't sit behind its own network policy β€” this lets an unauthenticated visitor use the API to reach the operator's private network.

That is a classic Server-Side Request Forgery (CWE-918). This PR adds a single, centralised guard in the middleware so every one of the 35 API handlers is protected in one place.

There are two prior community PRs on the same issue (#274 and #288), both still open. This PR takes a smaller, more surgical approach: no changes to individual handlers, one new file, one hook added to the middleware, one opt-out env var.

Vulnerability details

  • Class: Server-Side Request Forgery (CWE-918)
  • Entry point: commonMiddleware in api/_common/middleware.js β€” used by all 35 endpoints (api/*.js) on both the Vercel/Node and Netlify code paths
  • Trust model: the public instance and the default self-hosted config have no authentication; the attacker is any internet visitor
  • Reachable sinks (examples):
    • api/screenshot.js β†’ headless Chromium navigation to the target URL
    • api/http-security.js, api/headers.js, api/redirects.js, api/hsts.js, api/cookies.js, api/carbon.js, api/quality.js, api/social-tags.js, api/robots-txt.js, api/security-txt.js, api/sitemap.js, api/archives.js, api/linked-pages.js, api/tech-stack.js β†’ fetch() / axios to the target
    • api/tls-connection.js, api/ports.js β†’ raw net.connect() to arbitrary host:port
    • api/dns.js, api/dnssec.js, api/mail-config.js, api/txt-records.js, api/dns-server.js β†’ DNS lookups against the target
  • Impact: an unauthenticated attacker can enumerate the operator's internal network, read cloud instance metadata (169.254.169.254 β†’ IAM credentials on AWS/GCP/Azure), probe TCP ports on internal services, and β€” via the response bodies rendered in the UI β€” often exfiltrate the results.

Proof of concept

Before the fix, against any web-check instance (public or default self-hosted):

# 1. Cloud instance metadata β€” steals IAM credentials on AWS/GCP/Azure
curl 'https://<host>/api/headers?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/'

# 2. Loopback service enumeration β€” probes admin panels bound to localhost
curl 'https://<host>/api/http-security?url=http://127.0.0.1:8080/'
curl 'https://<host>/api/tls-connection?url=http://127.0.0.1:5432/'

# 3. Internal LAN scan β€” reaches RFC1918 space from the app server
curl 'https://<host>/api/headers?url=http://10.0.0.5/admin'

# 4. Filter-bypass forms that catch naive allow/deny-list guards
curl 'https://<host>/api/headers?url=http://2130706433/'          # decimal-encoded 127.0.0.1
curl 'https://<host>/api/headers?url=http://0x7f000001/'          # hex-encoded 127.0.0.1
curl 'https://<host>/api/headers?url=http://127.1/'               # short-form 127.0.0.1
curl 'https://<host>/api/headers?url=http://%5B%3A%3A1%5D/'       # IPv6 loopback [::1]
curl 'https://<host>/api/headers?url=http://localtest.me/'        # public DNS name β†’ 127.0.0.1

Each of these previously reached the internal target and returned useful data (headers, TLS handshake result, HTML body, etc.) to the anonymous caller. After this PR every one of them returns HTTP 400 with {"error":"Target ... resolves to a private or reserved address and is not allowed."}.

The fix

One new file, one hook in the middleware, one opt-out env var. Total diff: +156 lines, -0 lines across 3 files.

api/_common/safe-target.js β€” new module that exports assertSafeTarget(url). It:

  1. Parses the URL and rejects non-http/https schemes (blocks file:, gopher:, ftp:, dict: and friends β€” the usual SSRF-to-something-else pivots).
  2. Extracts the hostname (handling IPv6 brackets from the URL parser).
  3. If the hostname is an IP literal, checks it directly. Otherwise resolves it via dns.lookup(..., { all: true }) and checks every returned address (so a DNS record that mixes a public and a private answer is still rejected).
  4. Uses Node's built-in net.BlockList seeded from the IANA special-purpose registries: RFC1918, loopback, link-local, CGNAT, multicast, broadcast, TEST-NET, benchmarking, IPv6 unique-local, IPv6 link-local, IPv6 documentation, 64:ff9b::/96 v4/v6 translation, and IPv4-mapped IPv6 addresses (via Node's built-in v4-mapping semantics β€” a subtle point noted in the code comments).

api/_common/middleware.js β€” 20 lines added. assertSafeTarget(url) is awaited after normalizeUrl(url) on both the Vercel/Node and Netlify branches. If it throws UnsafeTargetError, the middleware returns HTTP 400 with the error message; anything else propagates as before. No handler needs to be touched β€” the guard runs before handler(url, ...) is called.

.env.sample β€” documents the new ALLOW_PRIVATE_TARGETS=true opt-out for self-hosters who legitimately want to scan their own LAN.

Testing

Ran the module directly against 20 representative inputs covering every documented SSRF filter-bypass class. All 20 behave as expected:

PASS  reject -> reject  file:///etc/passwd
PASS  reject -> reject  gopher://127.0.0.1:6379/_INFO
PASS  reject -> reject  ftp://internal/
PASS  reject -> reject  http://169.254.169.254/latest/meta-data/
PASS  reject -> reject  http://127.0.0.1/
PASS  reject -> reject  http://10.0.0.5/
PASS  reject -> reject  http://192.168.1.1/
PASS  reject -> reject  http://172.16.5.5/
PASS  reject -> reject  http://100.64.0.1/                  (CGNAT)
PASS  reject -> reject  http://127.1/                       (short-form)
PASS  reject -> reject  http://2130706433/                  (decimal encoding)
PASS  reject -> reject  http://0x7f000001/                  (hex encoding)
PASS  reject -> reject  http://0177.0.0.1/                  (octal encoding)
PASS  reject -> reject  http://[::1]/                       (IPv6 loopback)
PASS  reject -> reject  http://[::ffff:127.0.0.1]/          (v4-mapped v6)
PASS  reject -> reject  http://[fe80::1]/                   (link-local v6)
PASS  reject -> reject  http://[fc00::1]/                   (unique-local v6)
PASS  reject -> reject  http://localtest.me/                (public DNS β†’ 127.0.0.1)
PASS  allow  -> allow   https://example.com/
PASS  allow  -> allow   http://1.1.1.1/

20 passed, 0 failed

Setting ALLOW_PRIVATE_TARGETS=true and rerunning flips every reject to allow, so the self-host escape hatch works.

Design notes for reviewers

  • Why one central guard, not per-handler patches? Every endpoint in api/*.js is defined as export const handler = middleware(myHandler). The middleware already owns URL normalisation (normalizeUrl). Adding the check there covers all 35 handlers with one hook and no risk of the next new endpoint forgetting it.
  • Why dns.lookup with all: true? Because a DNS record can return multiple A/AAAA answers, and the handler will use whichever the OS resolver picks. Checking only the first address is a race condition. Checking all of them isn't a full DNS-rebinding fix β€” the guard resolves once and the handler resolves again (TOCTOU) β€” but it defeats the naive "return public + private in the same answer" trick and is the strongest correctness we can add without wrapping every HTTP client's socket dispatch.
  • Why net.BlockList rather than a hand-rolled comparator? It's part of core Node, handles v4-mapped v6 correctly out of the box, and makes the address list read like the IANA registry it's derived from.
  • Backwards compatibility. Self-hosters who scan their own LAN today (a real use case for a personal OSINT dashboard) set ALLOW_PRIVATE_TARGETS=true and get the pre-patch behaviour verbatim. Everyone else gets a 400 for private targets and no other observable change.
  • Known residual gaps this PR does not close. Handlers that follow redirects (api/redirects.js) or recurse into child URLs (api/sitemap.js walks sitemap children) can still re-fetch a URL that wasn't run through the guard. Full DNS-rebinding protection would require pinning the resolved address for the actual fetch (custom lookup on every HTTP agent). Both are legitimate follow-ups that don't diminish the value of closing the primary attack surface here.

Adversarial review

Before submitting, I tried to disprove this finding. The obvious "is it really unauthenticated?" question: yes β€” there is no auth middleware anywhere in api/_common/, SECURITY.md doesn't exist, web-check.xyz is a public instance, and every existing PR/issue on the repo treats the API as reachable by anonymous users. The "does an existing mitigation catch this?" question: I searched for any URL validation upstream of commonMiddleware and there is none β€” normalizeUrl only prepends https:// and brackets IPv6, it does not check the target. The "is the operator already the attacker's peer on the network?" question: no β€” a self-hosted personal instance still exposes the operator's LAN (router admin UI, NAS, printers) to any visitor who knows the URL. The public hosted instance runs on cloud infrastructure whose metadata endpoint sits at 169.254.169.254. Preconditions to exploit (be a public visitor) are strictly weaker than the capability granted (internal network read), so the fix meaningfully raises the bar.

Two prior PRs (#274 and #288) attempted the same fix. This one is intentionally smaller β€” one new file, no changes to any handler, a single opt-out env var β€” to make it easier to review and merge.


Discovered by the Sebastion AI GitHub App.

Every API endpoint receives a user-supplied `?url=` that ends up in
fetch(), dns.lookup(), net.connect() or headless Chromium, with no
validation. An attacker on a public instance could pivot to:

  * cloud metadata services (169.254.169.254)
  * RFC1918 / loopback / link-local hosts on the operator's LAN
  * IPv6 loopback / unique-local (::1, fc00::/7)
  * CGNAT and other reserved ranges

The new `safe-target.js` exposes `assertSafeTarget` which the shared
middleware now calls before invoking any handler. It:

  * rejects non-http(s) schemes
  * resolves the hostname via dns.lookup and rejects if any returned
    address falls inside an IANA-reserved / private range (v4 or v6)
  * canonicalises obfuscated literals (e.g. `2130706433`, `127.1`,
    `0x7f.0.0.1`) via the resolver before checking

Rejections return HTTP 400 with a clear error message, matching the
existing envelope shape; legitimate public targets are unaffected.

Self-hosters who intentionally want to scan their own LAN can opt out
with `ALLOW_PRIVATE_TARGETS=true` in .env (documented in .env.sample).
@netlify

netlify Bot commented Jul 1, 2026

Copy link
Copy Markdown

βœ… Deploy Preview for web-check ready!

Built without sensitive environment variables

Name Link
πŸ”¨ Latest commit 83c664f
πŸ” Latest deploy log https://app.netlify.com/projects/web-check/deploys/6a4508be165ad7000846063d
😎 Deploy Preview https://deploy-preview-315--web-check.netlify.app
πŸ“± Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
πŸ€– Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

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.

1 participant