Every URL your admin pastes is an SSRF vector
Build a guarded fetch for user-supplied URLs that validates scheme and DNS on every redirect hop, rejects non-canonical IP literals, and caps time and size — and route all such fetches through it.
The moment your server fetches a URL that a human typed into your UI — “install from URL,” “import config,” webhook validation, link previews — you have handed that human a proxy into your server’s network position. On cloud hosts that position includes things no external attacker can reach: the cloud metadata endpoint at 169.254.169.254 (which on many platforms serves live credentials), internal databases bound to private addresses, other services on localhost. “But only the admin can paste URLs” is weaker armor than it sounds: admin sessions get CSRF’d, marketplaces serve attacker-influenced URLs, and defense in depth exists precisely because the outer layer fails.
Cookey fetches admin-supplied URLs in four places — connector install, marketplace index, well-known grant discovery, update checks — and a naive fetch(url) would have been wrong in all four, in the same five ways: it follows redirects invisibly, it resolves DNS to wherever DNS says, it accepts http://, it waits as long as the server stalls, and it reads as many bytes as the server sends.
One module, one rule
The repo’s answer is a single module, apps/proxy/src/lib/safe-fetch.ts, with a rule attached: all server-side fetches of admin-supplied URLs go through safeFetch, no exceptions. The guard enforces, per its header comment: https only (localhost allowed in development), DNS resolution of every hop with private/internal ranges rejected, re-validation on every redirect (max 3), a 5-second timeout, and a 64 KB size cap.
Equally important is what it doesn’t claim: it is not a general-purpose proxy hardener, and it does not fully close DNS rebinding (more below). It makes the fetch-a-config-document use case safe, which is why the caps can be so tight — no legitimate connector JSON is over 64 KB or takes 5 seconds to serve.
Validate every hop
The core is per-hop validation. Redirects are handled manually so every Location target goes back through the same checks — the classic bypass is a public URL that 302s to http://169.254.169.254/:
export async function safeFetch(rawUrl, options = {}) {
// …timeout setup…
let url = new URL(rawUrl);
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
await assertUrlSafe(url); // scheme + DNS check, every hop
const response = await fetch(url, {
redirect: "manual", // never auto-follow
signal: controller.signal,
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) throw new SafeFetchError("Redirect without Location", "redirect");
if (hop === MAX_REDIRECTS) throw new SafeFetchError("Too many redirects", "redirect");
url = new URL(location, url); // relative redirects resolve here
continue; // …and get re-validated
}
const text = await readCapped(response, maxBytes);
return { status: response.status, headers: response.headers, text, finalUrl: url.toString() };
}
}
assertUrlSafe handles the part most homegrown guards get wrong — hostnames that are IP addresses in disguise:
// Literal IP in the URL. Only CANONICAL forms are allowed through the
// literal path — decimal (`https://2130706433/`), octal (`0177.0.0.1`),
// hex (`0x7f000001`), and short (`127.1`) spellings are rejected
// outright rather than falling through to DNS, because the OS resolver
// would happily dial them as numeric addresses.
const bareHost = url.hostname.replace(/^\[|\]$/g, "");
const ipVersion = isIP(bareHost);
if (ipVersion !== 0) {
if (isPrivateIp(bareHost)) throw new SafeFetchError(/* … */, "private_address");
return;
}
if (/^[0-9.]+$/.test(bareHost) || /^0x[0-9a-f.]+$/i.test(bareHost) || bareHost.includes(":")) {
throw new SafeFetchError(`Refusing non-canonical IP literal host "${bareHost}"`, "private_address");
}
const addresses = await lookup(bareHost, { all: true }); // real hostname: resolve it
for (const { address } of addresses) {
if (isPrivateIp(address)) throw new SafeFetchError(/* … */, "private_address");
}
Three details here repay study. First, non-canonical numeric hosts are rejected before DNS — 2130706433 is 127.0.0.1 to the OS dialer, and a blocklist that only string-matches dotted quads misses it. Second, isPrivateIp normalizes IPv4-mapped IPv6 in both spellings (::ffff:127.0.0.1 and ::ffff:7f00:1) before classifying, then covers loopback, RFC1918, link-local (which is where the metadata IP lives), CGNAT 100.64/10, 0.0.0.0/8, and the IPv6 equivalents. Third, lookup(host, { all: true }) checks every resolved address, not the first — a hostname with one public and one private A record fails.
The size cap streams rather than trusting headers, because Content-Length is attacker-controlled:
const reader = response.body.getReader();
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel();
throw new SafeFetchError(`Response exceeds ${maxBytes} byte cap`, "too_large");
}
// …accumulate
}
Placement guidance: this is one file in your server’s lib/, with a typed error (SafeFetchError carrying a reason of scheme | private_address | dns | redirect | timeout | too_large | network) so callers can render honest messages. The rule that makes it effective is enforceable in review or a lint rule: raw fetch of anything derived from user input is a rejected diff.
Rejections, and the gap that remains
Every rejection is fail-closed and attributed — the admin sees why (“resolves to private address,” “exceeds 64 KB cap”) rather than a generic failure, which matters because legitimate users hit these guards (a connector hosted behind five redirects, say) and need to know the fix is theirs to make. DNS failure is treated as unsafe, not retried around.
The known residual gap is DNS rebinding: safeFetch resolves and validates, then calls fetch, which resolves again — a malicious authoritative server with a zero TTL can answer public for the check and private for the connect. Closing it fully requires pinning the connection to the validated IP (a custom agent/dialer), which undici makes awkward. Cookey’s compensating controls are the layers around the fetch: the result is never trusted code (connectors are pure data, frozen at install), the response is capped and parsed against a strict schema, and the review screen shows the fetched document’s egress hosts before anything is installed. Ship the guard knowing which gap you’ve left and what stands behind it.
Answering the question once
Once every admin-URL fetch flows through one guarded function, “can this feature be used to reach our internals?” becomes a question you answer once, in one file, instead of once per feature — and new fetch-shaped features inherit the answer by construction. The typed rejection reasons flow into logs, so probing attempts (private_address rejections in production) become a signal you can alert on rather than an invisible near-miss. And when a pen test or bug bounty report arrives claiming SSRF, you have a specific, auditable artifact to test against, including an honest note about the rebinding gap and the mitigations layered behind it.