The webhook URL field your users fill in can reach your internal network
Por Seaworthy · 20 de agosto de 2026
Photo by Jordan Harrison on Unsplash
A webhook feature is a small, ordinary-looking piece of code:
app.post('/api/webhooks', async (req, res) => {
const { url } = req.body
await fetch(url, {
method: 'POST',
body: JSON.stringify({ event: 'user.created' }),
})
res.sendStatus(200)
})
The user provides a URL, your server sends a request to it. That's the entire feature, and it's also a server-side request forgery vulnerability, because url is attacker-controlled and your server will fetch whatever it's given, including URLs that were never meant to be reachable from the outside world.
What "internal" means from your server's point of view
Your production server can typically reach places a browser on the public internet can't: other services on the same private network, an internal admin panel with no auth because it was "only reachable internally," and cloud metadata endpoints like http://169.254.169.254/latest/meta-data/iam/security-credentials/ on AWS, which on a misconfigured instance can hand back temporary IAM credentials to anything that asks, no authentication required, because the endpoint trusts network position rather than identity.
A webhook URL field that fetches whatever it's given doesn't care whether that request is being made on behalf of a legitimate third-party endpoint or an attacker pointing your server at http://169.254.169.254/latest/meta-data/iam/security-credentials/my-role and reading back the response in whatever your webhook delivery system logs or returns. This exact pattern, an SSRF through a webhook or link-preview feature reaching cloud metadata, is behind some of the more well-documented breaches of the last several years, and it keeps recurring because the vulnerable code is genuinely indistinguishable from the correct code at a glance.
The fix is validating where the request can go, not just that it's a URL
import { isIP } from 'node:net'
import dns from 'node:dns/promises'
const BLOCKED_RANGES = [/^127\./, /^10\./, /^192\.168\./, /^169\.254\./, /^172\.(1[6-9]|2\d|3[01])\./]
async function isSafeUrl(urlStr) {
const url = new URL(urlStr)
if (url.protocol !== 'https:') return false
const address = isIP(url.hostname) ? url.hostname : (await dns.lookup(url.hostname)).address
return !BLOCKED_RANGES.some((range) => range.test(address))
}
Checking the hostname string alone isn't enough, since a hostname can resolve to an internal address via DNS even if it doesn't look like one, which is why the check above resolves the address and validates that, not just the URL's literal text. This needs to run at request time, immediately before the fetch, rather than once at registration time, since DNS answers can change between when a webhook URL is saved and when it's actually used.
For anything beyond a side project, pairing this with an egress firewall rule blocking outbound requests to the metadata endpoint and private ranges from application servers is worth doing regardless of what the application code checks, since it's a second layer that doesn't depend on every code path remembering to validate.
A redirect can undo the check you just wrote
Validating the URL once, before the fetch, isn't the end of the story either. If the target server responds with a redirect, and fetch follows redirects by default, the second request goes wherever the redirect points, without being re-validated. A URL that passes the initial check because it resolves to a public IP can still 302 to http://169.254.169.254/... on the redirect hop, and by default your code will follow it there without complaint.
Setting redirect: 'manual' on the fetch call and re-running the same validation against the Location header before manually following it closes this gap, at the cost of a few more lines than most generated code bothers with. It's the same principle as the DNS resolution step: check the thing that's actually about to happen, not the thing that was requested.
Why this pattern keeps getting generated
"Send a POST request to this URL" is such an ordinary description of a webhook that there's no obvious signal in the request telling an assistant this particular fetch call needs different treatment than any other. The vulnerability isn't in the syntax, it's entirely in the fact that the URL comes from an untrusted user and the server executing the fetch has network access the user shouldn't inherit. That's context an assistant has no way to infer from "add a webhook feature" alone.
Seaworthy flags server-side fetch calls where the URL originates from request input without a visible allowlist or private-range check nearby, since webhook and link-preview features are the two places this shows up most often.
Este artículo fue generado por IA y resume fuentes públicamente disponibles.
Seaworthy analiza tu repositorio en busca de los problemas que se tratan en artículos como este.
Brechas de seguridad, secretos expuestos y configuraciones erróneas detectados antes de desplegar. Gratis, sin cuenta necesaria.