SSRF is one of those vulnerabilities that looks harmless from the outside but becomes catastrophic the moment you understand what the server can reach that you can’t.
This one lived inside a webhook testing feature. Completely legitimate functionality. Completely unprotected.
The Code
app.post('/api/webhook/test', async (req, res) => {
const { url, payload } = req.body;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.text();
return res.json({
status: response.status,
body: data
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
The feature is simple — test a webhook by sending a POST request to a URL and returning the response. Useful for developers. Dangerous without guardrails.
Breaking It Down
The endpoint takes a url from the request body and uses the server to make an HTTP request to it. The full response — status code and body — is returned to the caller.
The flow:
- Attacker submits any URL in the request body
- The server makes a request to that URL — not the browser
- The response is returned directly to the attacker
The attacker never talks to the target directly. The server does — on their behalf. That’s the core of SSRF.
Why This Is Dangerous
From the internet, an attacker can’t reach:
- Internal services running on
localhostor127.0.0.1 - Private network ranges —
10.x.x.x,172.16.x.x,192.168.x.x - Cloud metadata services —
169.254.169.254 - Internal admin panels, databases, monitoring dashboards
But the server can reach all of these. By controlling the URL the server fetches, the attacker uses the server as a proxy into restricted infrastructure.
Attack 1 — Internal Service Enumeration
POST /api/webhook/test
{
"url": "http://localhost:8080/admin",
"payload": {}
}
The server requests its own admin panel — running locally on port 8080, not exposed to the internet. The full response comes back to the attacker. Internal admin interfaces, status pages, debug endpoints — all suddenly accessible.
Attack 2 — Cloud Metadata Endpoint
On AWS, every EC2 instance has a metadata service at a fixed IP:
POST /api/webhook/test
{
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"payload": {}
}
The server fetches its own cloud metadata. The response contains the IAM role name attached to the instance. Follow-up request:
POST /api/webhook/test
{
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/[role-name]",
"payload": {}
}
Response:
{
"AccessKeyId": "ASIA...",
"SecretAccessKey": "...",
"Token": "...",
"Expiration": "2026-04-21T..."
}
Temporary AWS credentials — leaked. The attacker now has IAM access to whatever that role permits. In misconfigured environments this means full S3 bucket access, EC2 control, or worse.
Attack 3 — Internal Network Scanning
By iterating through internal IP ranges the attacker can map the internal network:
POST /api/webhook/test
{ "url": "http://10.0.0.1", "payload": {} }
{ "url": "http://10.0.0.2", "payload": {} }
{ "url": "http://10.0.0.3", "payload": {} }
Response times and error messages reveal which hosts are alive. Open ports can be probed by appending port numbers. The internal network topology is mapped without the attacker ever touching it directly.
Root Cause
One line:
const response = await fetch(url, { ... });
url comes directly from req.body with no validation. The server will fetch any URL — internal, external, loopback, cloud metadata, file system on some platforms. The developer never asked: “Should the server be allowed to reach this destination?”
The Fix
Fix 1 — Validate the URL before fetching:
const { URL } = require('url');
function isSafeUrl(rawUrl) {
let parsed;
try {
parsed = new URL(rawUrl);
} catch {
return false;
}
// Only allow HTTP/HTTPS
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
const hostname = parsed.hostname.toLowerCase();
// Block loopback
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false;
// Block cloud metadata IPs
if (hostname === '169.254.169.254') return false;
// Block private IP ranges
const privateRanges = [/^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./];
if (privateRanges.some(r => r.test(hostname))) return false;
return true;
}
app.post('/api/webhook/test', async (req, res) => {
const { url, payload } = req.body;
if (!isSafeUrl(url)) {
return res.status(400).json({ error: 'URL not allowed' });
}
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.text();
return res.json({ status: response.status, body: data });
});
Fix 2 — Resolve and re-validate after DNS resolution:
DNS rebinding attacks can bypass hostname checks by resolving a public hostname to a private IP after validation. Re-validate the resolved IP:
const dns = require('dns').promises;
const { address } = await dns.lookup(parsed.hostname);
if (isPrivateIP(address)) {
return res.status(400).json({ error: 'Resolved to internal address' });
}
Fix 3 — Use an allowlist instead of a blocklist:
If webhooks only need to reach known external endpoints, define them explicitly:
const ALLOWED_DOMAINS = ['hooks.example.com', 'api.partner.com'];
if (!ALLOWED_DOMAINS.includes(parsed.hostname)) {
return res.status(400).json({ error: 'Domain not permitted' });
}
Allowlists are always stronger than blocklists.
Fix 4 — Disable redirects:
Servers that follow redirects can be redirected from a public URL to an internal one after the initial validation passes:
const response = await fetch(url, {
method: 'POST',
redirect: 'error', // never follow redirects
body: JSON.stringify(payload)
});
Key Takeaways
- Any feature that fetches a URL on behalf of the user is a potential SSRF vector — webhook testers, URL previewers, PDF generators, image importers
- The server’s network perspective is completely different from the attacker’s — what looks like a harmless URL fetch can reach infrastructure the attacker can never touch directly
- Cloud metadata endpoints are the highest-value SSRF target —
169.254.169.254leaks IAM credentials that can compromise entire AWS environments - Blocklists alone are not sufficient — DNS rebinding, IPv6 variants, and redirect chains can bypass them. Combine with allowlists and post-resolution checks
redirect: 'error'— always disable automatic redirect following when fetching user-supplied URLs
Found this useful? More code reviews coming. Hit me up on X if you want to discuss.