fix(api): block SSRF to private/reserved targets in shared middleware#315
Open
sebastiondev wants to merge 1 commit into
Open
fix(api): block SSRF to private/reserved targets in shared middleware#315sebastiondev wants to merge 1 commit into
sebastiondev wants to merge 1 commit into
Conversation
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).
β Deploy Preview for web-check ready!Built without sensitive environment variables
To edit notification comments on pull requests, go to your Netlify project configuration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, rawnet/dnscalls, etc.) without checking whether the target is public. On the hosted instance atweb-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
commonMiddlewareinapi/_common/middleware.jsβ used by all 35 endpoints (api/*.js) on both the Vercel/Node and Netlify code pathsapi/screenshot.jsβ headless Chromium navigation to the target URLapi/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()/axiosto the targetapi/tls-connection.js,api/ports.jsβ rawnet.connect()to arbitrary host:portapi/dns.js,api/dnssec.js,api/mail-config.js,api/txt-records.js,api/dns-server.jsβ DNS lookups against the targetProof of concept
Before the fix, against any web-check instance (public or default self-hosted):
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 400with{"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 exportsassertSafeTarget(url). It:http/httpsschemes (blocksfile:,gopher:,ftp:,dict:and friends β the usual SSRF-to-something-else pivots).URLparser).dns.lookup(..., { all: true })and checks every returned address (so a DNS record that mixes a public and a private answer is still rejected).net.BlockListseeded 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::/96v4/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)isawaited afternormalizeUrl(url)on both the Vercel/Node and Netlify branches. If it throwsUnsafeTargetError, the middleware returnsHTTP 400with the error message; anything else propagates as before. No handler needs to be touched β the guard runs beforehandler(url, ...)is called..env.sampleβ documents the newALLOW_PRIVATE_TARGETS=trueopt-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:
Setting
ALLOW_PRIVATE_TARGETS=trueand rerunning flips every reject to allow, so the self-host escape hatch works.Design notes for reviewers
api/*.jsis defined asexport 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.dns.lookupwithall: 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.net.BlockListrather 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.ALLOW_PRIVATE_TARGETS=trueand get the pre-patch behaviour verbatim. Everyone else gets a 400 for private targets and no other observable change.api/redirects.js) or recurse into child URLs (api/sitemap.jswalks 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 (customlookupon 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.mddoesn't exist,web-check.xyzis 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 ofcommonMiddlewareand there is none βnormalizeUrlonly prependshttps://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 at169.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.