The Vulnerability You Never Meant to Add
You ask an AI to build a feature: a link preview generator, a webhook receiver, an image importer that fetches a URL the user supplies. The AI writes clean, working code. It fetches the URL, processes the response, and returns the result. The feature ships, and nobody thinks twice — until an attacker supplies a URL like `http://169.254.169.254/latest/meta-data/iam/security-credentials/` and gets back your cloud provider's IAM credentials.
That is Server-Side Request Forgery. It is OWASP A10:2021, it is one of the most impactful vulnerabilities in modern web applications, and AI assistants generate it constantly because every short tutorial on "fetch a URL from the server" skips the part about validating what that URL actually points to.
This article explains how SSRF lands in AI-generated code, what an attacker can do with it, and the concrete steps to close it before deployment.
What SSRF Is
SSRF happens when your server makes an HTTP request to a URL that an attacker controls. The attacker uses your server as a proxy to reach destinations it should not touch: internal services on your private network, cloud metadata endpoints, loopback addresses, or other infrastructure that is inaccessible from the public internet.
The server's outbound request carries the server's own identity — its IAM role, its internal network position, its service-to-service trust. From the attacker's perspective, they just gained all of that for free.
SSRF is particularly dangerous in cloud environments, where internal metadata endpoints hand out credentials, configuration, and instance details to any process that can reach them on the local network. In AWS, that endpoint is `http://169.254.169.254`. In GCP it is `http://metadata.google.internal`. In Azure it is `http://169.254.169.254` with a required header. Your app container can always reach these addresses. An attacker with SSRF can reach them through your app.
Why AI-Generated Code Is Especially Vulnerable
SSRF requires user-controlled input to flow into an HTTP request without validation. That is exactly the pattern an AI produces when you ask it to "fetch a URL from the user" or "make an HTTP request based on user input."
#
A link preview generator
// ❌ AI-generated: user-supplied URL fetched directly
app.post('/api/preview', async (req, res) => {
const { url } = req.body
const response = await fetch(url)
const html = await response.text()
const title = html.match(/<title>(.*?)<\/title>/i)?.[1] ?? ''
res.json({ title })
})
An attacker sends `url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/my-role"`. The server faithfully fetches it and returns the credential JSON in the title field.
#
A webhook validator
// ❌ Validates a webhook by pinging the URL the user registered
app.post('/webhooks/verify', async (req, res) => {
const { callbackUrl } = req.body
const ping = await fetch(callbackUrl, { method: 'POST', body: 'ping' })
res.json({ verified: ping.ok })
})
The "callback URL" can be an internal service: `http://10.0.0.1:8080/admin/reset-password`. The server posts to it, and the attacker has just triggered an internal action.
#
An image importer
// ❌ Downloads an image from a user-supplied URL and stores it
app.post('/api/import-image', async (req, res) => {
const { imageUrl } = req.body
const image = await fetch(imageUrl)
const buffer = await image.arrayBuffer()
await fs.writeFile(`./uploads/${Date.now()}.jpg`, Buffer.from(buffer))
res.json({ success: true })
})
Same pattern, same risk. The AI has no reason to add validation — the tutorial it was trained on did not either.
What Attackers Do With SSRF
Once an attacker can make your server issue arbitrary HTTP requests, the impact depends on what is reachable:
**Cloud credential theft.** The IMDSv1 metadata endpoint on AWS, GCP, and Azure returns the instance's IAM credentials to any HTTP client that can reach the link-local address. Those credentials may have broad permissions across your cloud account. This is the highest-impact SSRF outcome and the one that has driven the largest cloud breaches of recent years.
**Internal network scanning.** The attacker probes private IP ranges by watching response times and error codes. They map which ports are open on internal hosts — databases, admin panels, container registries, internal APIs — that are firewalled from the internet but reachable from your application server.
**Bypassing access controls.** Many internal services trust requests that originate from within the network. SSRF lets an attacker speak from inside that trust boundary. An internal admin endpoint that has no authentication because "it's on the internal network" is wide open.
**Reading internal files via file:// URI.** Some HTTP libraries honour `file://` URIs. An attacker requests `file:///etc/passwd` or `file:///app/.env` and the server reads and returns the local filesystem.
**Pivoting deeper.** Using the compromised server as a stepping stone, the attacker reaches further internal services, eventually chaining SSRF into full remote code execution or a complete cloud account takeover.
How to Fix SSRF: Defence in Depth
No single control eliminates SSRF, but layering several makes exploitation impractical.
#
1. Validate the URL before fetching
Parse the URL and apply an allowlist of permitted schemes, hosts, and ports. Reject anything that does not pass every check.
import { URL } from 'url'
import dns from 'dns/promises'
const BLOCKED_RANGES = [
/^127\./,
/^10\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./,
/^169\.254\./, // link-local / cloud metadata
/^::1$/, // IPv6 loopback
/^fc00:/, // IPv6 unique local
/^fe80:/, // IPv6 link-local
]
async function isSafeUrl(rawUrl) {
let parsed
try {
parsed = new URL(rawUrl)
} catch {
return false
}
// Only allow https:// from public internet
if (parsed.protocol !== 'https:') return false
// Resolve hostname to IP and block private/loopback ranges
let addresses
try {
addresses = await dns.resolve4(parsed.hostname)
} catch {
return false
}
for (const ip of addresses) {
if (BLOCKED_RANGES.some(rx => rx.test(ip))) return false
}
return true
}
app.post('/api/preview', async (req, res) => {
const { url } = req.body
if (!await isSafeUrl(url)) {
return res.status(400).json({ error: 'URL not permitted' })
}
const response = await fetch(url)
// ...
})
Resolve the hostname to an IP **before** fetching and block private ranges at that layer. Blocking only the hostname string is insufficient — an attacker can register a public domain that resolves to `169.254.169.254`.
#
2. Disable redirects or re-validate after each redirect
HTTP redirects are a common SSRF bypass. An attacker supplies a public URL that redirects to `http://169.254.169.254`. If you validate before fetching but blindly follow redirects, the validation is bypassed.
const response = await fetch(url, {
redirect: 'manual', // Do not follow redirects automatically
})
if (response.status >= 300 && response.status < 400) {
// Re-validate the Location header before deciding to follow
}
Either disable redirects entirely or run the same validation on every `Location` header before following.
#
3. Enforce IMDSv2 on AWS
AWS Instance Metadata Service version 2 (IMDSv2) requires a PUT request with a session token before any metadata is accessible. If your EC2 instances or ECS tasks enforce IMDSv2, simple GET requests to `169.254.169.254` return a 401 rather than credentials — closing the highest-impact SSRF scenario.
Enable it at the infrastructure level so application-layer SSRF cannot reach the credentials endpoint even if URL validation is bypassed.
#
4. Use an egress proxy or allowlist at the network layer
Restrict outbound HTTP from your application to only the destinations it legitimately needs. Everything else is blocked by default. This makes SSRF exploitation significantly harder even when application-level validation is imperfect.
#
5. Run your server with minimal IAM permissions
Apply least privilege to the IAM role your server assumes. If the application only needs to read from an S3 bucket, its role should not have permissions to read secrets, call EC2 APIs, or assume other roles. A successful SSRF against a least-privileged role yields far less.
How DeployReady Detects SSRF Risk
DeployReady performs static analysis on your codebase and probes your running application. On the static side it scans for patterns where user-controlled input flows into `fetch`, `axios`, `http.request`, `got`, and similar HTTP clients without an intervening validation step, flagging those as potential SSRF sinks.
npx deployready@latest analyze ./my-app
✦ Parsing codebase...
✦ Running static analysis...
✦ Probing localhost:3000...
🔴 CRITICAL: User input flows into fetch() without URL validation — /api/preview
🔴 CRITICAL: Redirect following enabled on user-supplied URL — /webhooks/verify
🟡 WARNING: No egress restrictions detected — server can reach internal ranges
🟡 WARNING: IMDSv2 enforcement not confirmed
Production Readiness Score: 34 / 100
The scan gives you the affected route, the specific pattern, and a fix direction — before the code ever reaches production.
The Bottom Line
SSRF is not a subtle vulnerability. It follows a simple, predictable pattern: user-controlled URL → server-side fetch → no validation. AI coding assistants generate that pattern routinely because tutorials do not include the validation step. The fix is systematic: parse, resolve, block private ranges, reject redirects to private destinations, and enforce IMDSv2 at the infrastructure layer.
Validate before you deploy, and your server stays your server.
npx deployready@latest analyze .
Resources
---
**Worried your vibe-coded app has an SSRF exposure?** [Scan it with DeployReady](https://www.npmjs.com/package/deployready) or [book a security check](https://www.belsoftsolutions.com/meeting).