Preventing SSRF in outbound webhook delivery
A customer opens your dashboard, pastes http://169.254.169.254/latest/meta-data/iam/security-credentials/ into the webhook URL field, and clicks save. If your dispatcher accepts it, your worker will make that request from inside your VPC, with its instance role attached, and hand the response body — or at least its status code and timing — back to the customer through your delivery-log UI. That is server-side request forgery, and a webhook platform is the most natural place in a backend for it to appear, because taking a user-supplied URL and fetching it is not a bug here, it is the product. This guide is the sender-side counterpart to the boundary map in webhook endpoint hardening, and it pairs closely with IP allowlisting and egress controls for webhooks, which covers the same egress path from the consumer’s point of view.
The metadata endpoint is only the headline target. The same primitive reaches your internal admin panels on 10.0.0.0/8, an unauthenticated Redis on 127.0.0.1:6379, a Kubernetes API server, or a colleague’s laptop over a VPN route. The defence is not one check but four, applied in order: constrain the URL, resolve and screen the addresses, connect to the address you actually screened, and re-apply everything to redirects — with an egress proxy underneath as the control that survives your own mistakes.
Prerequisites
- Runtime: Node.js 20+ with TypeScript 5. The address-pinning technique uses the
lookuphook exposed by Node’s socket layer, which is stable from Node 18 onward. - HTTP client:
undici6 (npm i undici). The same approach works withnode:httpsdirectly; it does not work cleanly with clients that hide their connect step. - Testing: Vitest 1 plus the ability to point a test hostname at an arbitrary address (a local DNS stub or a hosts-file entry).
- Network: a dispatch subnet whose route table you control, and ideally a forward proxy (Squid 6 or a cloud equivalent) you can place in front of it.
- Existing signing: deliveries should already be signed per HMAC signature verification — SSRF controls sit before that, on the connection rather than the payload.
Step 1: Constrain the URL before you store it
Registration-time validation is about failing fast and legibly. A customer who typos a port should get a 400 with a reason, not a stream of failed deliveries three days later. Parse with the WHATWG URL parser — never with a regex — and check five things: the scheme, the port, the absence of embedded credentials, that the host is a name or a literal address you will screen later, and that the URL is absolute.
const ALLOWED_SCHEMES = new Set(['https:']);
const ALLOWED_PORTS = new Set([443]);
export type UrlRejection =
| 'not_absolute'
| 'scheme_not_allowed'
| 'port_not_allowed'
| 'credentials_in_url'
| 'empty_host';
export function parseDestination(raw: string): { url: URL } | { reject: UrlRejection } {
let url: URL;
try {
url = new URL(raw);
} catch {
return { reject: 'not_absolute' };
}
if (!ALLOWED_SCHEMES.has(url.protocol)) return { reject: 'scheme_not_allowed' };
// An explicit port of '' means the scheme default (443 for https).
const port = url.port === '' ? 443 : Number(url.port);
if (!ALLOWED_PORTS.has(port)) return { reject: 'port_not_allowed' };
// https://user:pass@internal.host/ smuggles credentials and confuses parsers.
if (url.username !== '' || url.password !== '') return { reject: 'credentials_in_url' };
if (url.hostname === '') return { reject: 'empty_host' };
return { url };
}
Engineering note: allowing http: “just for staging” is how this control dies. If you must support plaintext, gate it on a per-environment flag that is hard-coded to false in the production build, not on a database column an internal tool can flip. The transport half of that argument is made in TLS configuration for webhook endpoints.
Step 2: Resolve the host and deny reserved address space
The hostname is where the interesting attacks live, because a name can point anywhere and can be repointed at will. Resolve it explicitly with dns.promises.lookup(host, { all: true }) and screen every answer — an attacker returning two A records where one is public and one is 127.0.0.1 will otherwise get through whichever your client happens to pick.
Screening means denying by range, not by string matching. http://127.0.0.1/ is easy to spot; http://2130706433/, http://0x7f.1/, http://[::ffff:127.0.0.1]/ and http://localtest.me/ all reach the same place and none of them look like it. Normalising to a numeric address first and then comparing against CIDR blocks eliminates the entire encoding-trick class.
import { promises as dns } from 'node:dns';
import net from 'node:net';
const DENIED_V4: Array<[string, number]> = [
['0.0.0.0', 8], // "this network"
['10.0.0.0', 8], // RFC1918 private
['100.64.0.0', 10], // RFC6598 carrier-grade NAT
['127.0.0.0', 8], // loopback
['169.254.0.0', 16], // link-local, includes cloud metadata
['172.16.0.0', 12], // RFC1918 private
['192.0.0.0', 24], // IETF protocol assignments
['192.168.0.0', 16], // RFC1918 private
['198.18.0.0', 15], // benchmarking
['224.0.0.0', 4], // multicast
['240.0.0.0', 4], // reserved, includes 255.255.255.255
];
function v4ToInt(ip: string): number {
return ip.split('.').reduce((acc, o) => (acc << 8) + Number(o), 0) >>> 0;
}
function isDeniedV4(ip: string): boolean {
const value = v4ToInt(ip);
return DENIED_V4.some(([base, bits]) => {
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
return (value & mask) === (v4ToInt(base) & mask);
});
}
function isDeniedV6(ip: string): boolean {
const lower = ip.toLowerCase();
if (lower === '::' || lower === '::1') return true; // unspecified, loopback
if (lower.startsWith('fe80') || lower.startsWith('fe9')) return true; // link-local
if (/^f[cd]/.test(lower)) return true; // fc00::/7 unique local
// IPv4-mapped (::ffff:a.b.c.d) must be screened with the v4 rules.
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
if (mapped) return isDeniedV4(mapped[1]);
return false;
}
export function isDeniedAddress(ip: string): boolean {
const family = net.isIP(ip);
if (family === 4) return isDeniedV4(ip);
if (family === 6) return isDeniedV6(ip);
return true; // not a parseable address: deny by default
}
export async function resolvePublicAddresses(host: string): Promise<string[]> {
// Literal addresses skip DNS but are screened identically.
if (net.isIP(host) !== 0) {
if (isDeniedAddress(host)) throw new Error(`ssrf_denied:${host}`);
return [host];
}
const answers = await dns.lookup(host, { all: true, verbatim: true });
if (answers.length === 0) throw new Error(`ssrf_no_answer:${host}`);
for (const answer of answers) {
if (isDeniedAddress(answer.address)) throw new Error(`ssrf_denied:${answer.address}`);
}
return answers.map((a) => a.address);
}
Engineering note: deny by default when parsing fails. A branch that returns false for an address it did not understand converts every future address format into a bypass; returning true converts it into a support ticket, which is the cheaper failure.
Step 3: Pin the socket to the address you validated
Here is the flaw that survives every naive implementation. You resolve hook.evil.tld, get a public address, approve it, and then call fetch('https://hook.evil.tld/…'). The HTTP client resolves the name again. With a one-second TTL and an attacker-controlled authoritative server, the second answer is 169.254.169.254. You validated one thing and connected to another: a classic time-of-check to time-of-use gap, exploited via DNS rebinding.
The fix is to remove the second resolution. Undici’s connect options accept a lookup function; supply one that returns the address you already screened, while leaving the Host header and TLS SNI set to the original hostname so certificate verification and virtual hosting still work.
import { Agent, request } from 'undici';
import net, { type LookupFunction } from 'node:net';
import { resolvePublicAddresses } from './ssrf-guard';
export function pinnedAgent(hostname: string, address: string): Agent {
const lookup: LookupFunction = (_host, _options, callback) => {
// The kernel never gets a chance to resolve again: one answer, ours.
callback(null, address, net.isIP(address) as 4 | 6);
};
return new Agent({
connect: {
lookup,
servername: hostname, // SNI + hostname verification still use the NAME
},
connectTimeout: 3_000,
headersTimeout: 5_000,
bodyTimeout: 10_000,
});
}
export async function deliverPinned(url: URL, body: string, headers: Record<string, string>) {
const [address] = await resolvePublicAddresses(url.hostname);
const dispatcher = pinnedAgent(url.hostname, address);
try {
return await request(url, {
method: 'POST',
body,
headers: { ...headers, 'content-type': 'application/json' },
dispatcher,
maxRedirections: 0, // handled explicitly in Step 4
});
} finally {
await dispatcher.close();
}
}
Engineering note: keep servername pointing at the hostname. Pinning the address while also forcing SNI to that address would break certificate verification against every real endpoint, and engineers who hit that usually “fix” it by disabling verification — trading an SSRF fix for a man-in-the-middle hole.
Step 4: Re-run the policy on every redirect hop
An attacker who cannot register an internal URL will register a public one that redirects. https://hook.evil.tld/start passes every check, returns 302 Location: http://169.254.169.254/, and an HTTP client with redirects enabled follows it without consulting you. Automatic redirect following is therefore incompatible with a destination policy, and the first thing to do is switch it off.
Then decide: refuse redirects entirely, or follow them under policy. Refusing is defensible — a webhook endpoint that moves should be re-registered — but it generates support load from consumers behind path-rewriting gateways. Following under policy means treating each hop as a brand-new destination: re-parse, re-resolve, re-screen, re-pin, with a hard hop budget and a rule that a cross-origin redirect drops the signature headers so credentials never travel to an unvetted host.
const MAX_HOPS = 3;
export async function deliverFollowingRedirects(
initial: URL,
body: string,
signedHeaders: Record<string, string>,
) {
let target = initial;
for (let hop = 0; hop <= MAX_HOPS; hop++) {
// Every hop repeats the FULL policy, not a subset of it.
const parsed = parseDestination(target.toString());
if ('reject' in parsed) throw new Error(`redirect_rejected:${parsed.reject}`);
const sameOrigin = target.origin === initial.origin;
const headers = sameOrigin ? signedHeaders : { 'content-type': 'application/json' };
const res = await deliverPinned(parsed.url, body, headers);
if (res.statusCode < 300 || res.statusCode > 399) return res;
const location = res.headers['location'];
if (typeof location !== 'string') return res;
// Relative Locations resolve against the CURRENT hop, not the original.
target = new URL(location, target);
await res.body.dump(); // release the socket before the next hop
}
throw new Error('redirect_budget_exhausted');
}
Engineering note: relative Location values resolve against the hop that emitted them, not the original URL. Resolving against the original is a subtle bug that lets a redirect chain walk to an origin you never approved while your logs still show the customer’s hostname.
Step 5: Enforce the boundary with a dedicated egress proxy
Application-layer checks protect the code path you remembered to route through them. Six months from now someone will add a “test this endpoint” button using plain fetch, and it will bypass everything above. The network fixes that: put dispatch workers on a subnet with no default route to the internet and no route to your service subnets, and give them exactly one next hop — a forward proxy that re-applies destination policy.
The proxy is a second, independent implementation of the same rule, and its value is precisely that it is independent. A Squid ACL that denies to_localnet and the link-local block is ten lines of config, is enforced for every process on the subnet, and does not care which HTTP client someone chose. It is also where you terminate the fixed source address that consumers will allowlist.
import { ProxyAgent, request } from 'undici';
// One dispatcher for the whole worker: the proxy is the only way out.
const egress = new ProxyAgent({
uri: process.env.EGRESS_PROXY_URL!, // e.g. http://egress.internal:3128
connectTimeout: 3_000,
headersTimeout: 5_000,
bodyTimeout: 10_000,
});
export async function deliverViaProxy(url: URL, body: string, headers: Record<string, string>) {
// The in-process checks still run: the proxy is a backstop, not a substitute,
// because only the application knows which tenant registered this URL.
const parsed = parseDestination(url.toString());
if ('reject' in parsed) throw new Error(`destination_rejected:${parsed.reject}`);
await resolvePublicAddresses(parsed.url.hostname);
const res = await request(parsed.url, {
method: 'POST',
body,
headers,
dispatcher: egress,
maxRedirections: 0,
});
// A 403 from the proxy means the two policies disagree — alert on this.
if (res.statusCode === 403 && res.headers['x-squid-error']) {
throw new Error('egress_policy_denied');
}
return res;
}
Engineering note: alert on proxy denials specifically. If the proxy is rejecting destinations your application already approved, the two policies have drifted apart and one of them is wrong — that is a signal worth paging on, not a line to filter out of the logs.
Verification and testing
Test the rebinding case, not just the obvious one. A test suite that only asserts http://127.0.0.1/ is rejected will pass against an implementation with the time-of-check gap wide open.
import { describe, expect, it, vi } from 'vitest';
import { promises as dns } from 'node:dns';
import { isDeniedAddress, resolvePublicAddresses } from './ssrf-guard';
describe('destination screening', () => {
it('denies every reserved representation of loopback and metadata', () => {
for (const ip of ['127.0.0.1', '169.254.169.254', '10.1.2.3', '::1', '::ffff:127.0.0.1']) {
expect(isDeniedAddress(ip)).toBe(true);
}
expect(isDeniedAddress('203.0.113.9')).toBe(false);
});
it('denies a host whose answer set mixes public and private addresses', async () => {
vi.spyOn(dns, 'lookup').mockResolvedValue([
{ address: '203.0.113.9', family: 4 },
{ address: '169.254.169.254', family: 4 },
] as never);
await expect(resolvePublicAddresses('mixed.evil.tld')).rejects.toThrow(
'ssrf_denied:169.254.169.254',
);
});
it('pins the connection so a rebound second answer is never used', async () => {
const lookup = vi
.spyOn(dns, 'lookup')
.mockResolvedValueOnce([{ address: '203.0.113.9', family: 4 }] as never)
.mockResolvedValueOnce([{ address: '169.254.169.254', family: 4 }] as never);
await resolvePublicAddresses('rebind.evil.tld');
// The guard must resolve exactly once per delivery; a second call means
// the client is re-resolving and the pin is not in effect.
expect(lookup).toHaveBeenCalledTimes(1);
});
});
For an end-to-end check, point a test hostname at 169.254.169.254 in your resolver and confirm the delivery fails with ssrf_denied rather than a timeout — a timeout means the request was attempted and merely failed to complete, which is a different and much worse outcome. On the proxy side, curl -x http://egress.internal:3128 http://169.254.169.254/ from a dispatch host must return the proxy’s 403, and your application log line for that delivery should carry the ssrf_denied reason label rather than a generic connection error. The techniques for reading those delivery records are in debugging failed webhook deliveries.
Failure modes and gotchas
- Validating at registration and never again. The check runs when the customer saves the URL, the host is repointed a week later, and every delivery from then on goes wherever the attacker likes. Registration validation is a usability feature; the dispatch-time resolution and screen is the security control, and both must exist.
- Screening one address from a multi-record answer.
dns.lookupwithout{ all: true }returns a single address chosen by the resolver, and the next call may choose a different one from the same set. Screen every record, and connect to a record you screened. - Leaving redirects on.
fetchfollows up to twenty redirects by default and undici follows whatevermaxRedirectionssays. A public URL that302s tohttp://169.254.169.254/defeats a perfect denylist. Set the limit to zero and handle hops yourself. - Error messages that leak the response. Returning the upstream body, headers, or precise timing into a customer-visible delivery log turns a blocked-but-attempted request into an information disclosure. Log the full detail internally, and expose only a stable reason code to the customer.
- Forgetting IPv6 and mapped addresses. A denylist covering RFC1918 but not
::1,fc00::/7or::ffff:127.0.0.1is bypassed by a single AAAA record. Screen both families, and normalise IPv4-mapped forms through the IPv4 rules.
Frequently Asked Questions
Does address pinning break consumers behind a CDN whose addresses rotate constantly?
Not if the pin lives only for the duration of one delivery. Resolve, screen, connect and discard: the address is fixed for a few hundred milliseconds, which is far shorter than any sane rotation interval, so the consumer never notices. The mistake is caching a pinned address across deliveries, which turns a security control into a stale-endpoint outage the next time the CDN drains a point of presence.
Should a delivery rejected by the destination policy be retried like a normal failure?
Treat it as a terminal outcome for that attempt rather than feeding it into the backoff schedule, because a denied address is a configuration state and not a transient network condition. Record the reason code, count the consecutive denials per endpoint, and disable the endpoint with a notification once it crosses a small threshold. That converts an endless stream of denied dispatch attempts into one actionable message to the customer.
With HTTPS the proxy only sees a CONNECT to a hostname — can it still enforce the policy?
Yes, because the proxy has to resolve that hostname itself before it can open the tunnel, and its destination ACLs evaluate the resolved address rather than the string in the request line. Configure the deny rules against the numeric destination, not against hostname patterns, and keep the proxy's resolver cache short so it cannot hold an answer that has since become hostile. What the proxy cannot see is anything inside the tunnel, which is why the redirect policy stays in the application.
Do 307 and 308 redirects need different handling from 301, 302 and 303?
They do, and they are the dangerous ones. A 307 or 308 preserves the method and the body, so following one blindly replays the entire signed payload at whatever host the Location names, whereas a 303 downgrades to GET and leaks far less. If you follow redirects at all, apply the full destination policy to every status in the 3xx family and strip the signature headers the moment the origin changes, so a body-preserving hop cannot carry credentials somewhere unvetted.
A reserved-range denylist passes an address that is public but still inside our own infrastructure — is that a gap?
It is, and it is the one most denylists miss. Your own public load balancers, your control-plane API, and your published egress addresses are all routable, so a customer can register them and use your dispatcher to make authenticated-looking requests back at you or into a delivery loop. Add your own advertised ranges and service hostnames to the deny set alongside the reserved blocks, and generate that list from the same source of truth your infrastructure code uses.
Does this guard need to cover outbound requests other than event delivery?
Yes. Every place a customer-supplied URL is fetched shares the same primitive: the test-this-endpoint button, an endpoint health probe, a schema or manifest import, an avatar or logo fetch. Route all of them through the same validated client rather than reimplementing the checks, and treat any direct use of a raw HTTP client in application code as a review finding, because that is exactly how the boundary gets reopened.