SSRF, OPEN REDIRECTS, AND OAUTH REDIRECT_URI
Three classic bugs, one common root cause: the server trusted a URL the user supplied. SSRF makes your server fetch where the attacker says. Open redirect makes your domain a phishing launchpad. OAuth redirect_uri lets an attacker steal the token.
The scenario referenced below runs on gapbench.vibe-eval.com — a public security benchmark we operate. The client engagement that originally surfaced these patterns is anonymized; the gapbench scenarios are the reproducible equivalents.
One bad habit, three vulnerabilities
The habit is “trust a URL the user gave me.” It produces three different bugs depending on what the server does with the URL.
If the server fetches it: SSRF.
If the server redirects to it: open redirect.
If the OAuth provider redirects to it: redirect_uri attack.
The fixes rhyme. Validate. Allow-list. Don’t accept arbitrary URLs from arbitrary parties. Easy to write down, easy to skip.
Variant one — SSRF on the image proxy
The most common SSRF surface in AI-generated apps is the image-proxy feature. “Let users supply a URL, fetch the image server-side, serve it back” — done in two lines:
app.get('/proxy/image', async (req, res) => {
const response = await fetch(req.query.url)
response.body.pipe(res)
})
Looks fine. It is not. Set ?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ on AWS and the response is your IAM role’s temporary credentials. Set it to http://localhost:5432/ and you get back database error messages with internal information. Set it to http://internal-admin.svc.cluster.local/ and you get whatever lives there.
Live demo: https://gapbench.vibe-eval.com/site/ssrf-image-proxy/. Adjacent variant for cloud metadata: https://gapbench.vibe-eval.com/site/gcp-metadata-ssrf/.
The fix is layered. You allow-list domains the proxy can reach. You block private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, ::1, fc00::/7) at the resolver level. You set IMDSv2 on AWS so the metadata endpoint requires a session token, which a single curl-style request can’t provide. And you put the proxy on a different network egress that doesn’t have access to internal services.
Variant two — open redirect
Pattern:
app.get('/login/callback', (req, res) => {
const next = req.query.next || '/dashboard'
res.redirect(next)
})
Send a user a link to your-site.com/login/callback?next=https://attacker.example. They click. They see your domain. The browser follows the redirect. They land on the attacker’s site. Combine with a clone of your login page and you have a phishing kit.
This is widely viewed as a “low severity” bug because it’s “just a redirect.” It’s not low severity when the user trusts your domain. Banks treat it as critical because it bypasses the user’s domain-recognition heuristic.
Live: https://gapbench.vibe-eval.com/site/open-redirect/.
The fix: only redirect to relative URLs. If you must support absolute URLs, allow-list the hosts. The check is one line:
const allowedHosts = ['your-site.com', 'app.your-site.com']
const next = req.query.next || '/dashboard'
const url = new URL(next, 'https://your-site.com')
if (!allowedHosts.includes(url.host)) return res.redirect('/dashboard')
res.redirect(url.toString())
Variant three — OAuth redirect_uri
The most consequential of the three. OAuth flows authenticate the user with a provider (Google, GitHub, your own SSO) and return them to your app via a redirect_uri. If the provider accepts a permissive redirect_uri — any subdomain, any path, or worse, a wildcard — an attacker can:
- Initiate the OAuth flow with their crafted
redirect_uripointing at a server they control (or a takeover-eligible subdomain you forgot to remove from the allowlist). - Trick the victim into clicking the OAuth start link.
- Receive the authorization code or token at their endpoint.
- Exchange or replay it for the victim’s session.
We see this most often on apps where the redirect_uri allowlist was set up loosely during development (“just allow localhost and any subdomain so we can test”) and never tightened before launch. We also see it as a pivot from subdomain takeover — an old subdomain on the allowlist that has lapsed, and an attacker registers it on the underlying provider and points the OAuth dance there.
Live: https://gapbench.vibe-eval.com/site/oauth-redirect/. Clean control: https://gapbench.vibe-eval.com/site/ref-oauth/.
The fix:
- Exact-match
redirect_uriin the OAuth provider’s dashboard. No wildcards. No subdomain patterns. Each entry is a full URL. - Use the
stateparameter on every OAuth flow and verify it on the callback. This prevents CSRF on the OAuth dance. - Use PKCE for public clients. The PKCE code_challenge / code_verifier handshake makes the authorization code useless to anyone who didn’t initiate the flow.
- Audit the
redirect_uriallowlist quarterly. Remove anything that’s not in active use. Especially remove anything pointing at subdomains you don’t actively control DNS for.
A related variant, oauth-token-leak-referer, lives at https://gapbench.vibe-eval.com/site/oauth-token-leak-referer/ — it’s the case where the token ends up in the URL fragment and leaks via Referer header to third-party scripts. Same root cause: trust on the wrong side of the URL.
A specific incident — chained SSRF to RCE
Anonymized. A media-heavy product had an avatar-upload feature. Users could upload an image, or paste a URL and the server would fetch the image. The fetch was the SSRF surface. Standard image-proxy bug.
The chain. The product ran on AWS with IMDSv1 (the older metadata service). An attacker pasted http://169.254.169.254/latest/meta-data/iam/security-credentials/ as the avatar URL. The “image” came back as the role name. Second request, with the role name appended, returned the temporary IAM credentials. The attacker now had AWS API access with whatever permissions the workload role held.
The role had S3 write access to an internal bucket used for backup data. The attacker uploaded a malicious build artifact to that bucket. Three days later, the team’s CI pipeline pulled artifacts from that bucket as part of a deploy step. The attacker’s artifact got deployed.
The post-mortem traced the root cause to the SSRF. None of the downstream steps were “vulnerable” individually — the IAM role had legitimate access to its bucket, the deploy pipeline trusted its artifact source. But the SSRF stitched them into an RCE chain. Total time from initial SSRF to running attacker code in production: less than two hours.
The fixes were layered. Allow-list domains for the image proxy. Block egress to the metadata IP range from any workload that didn’t legitimately need it. Enable IMDSv2 (which requires a session-token PUT before metadata GETs, breaking single-request SSRF chains). Sign artifacts in the deploy pipeline. Each fix was a half-day of work; the chain stayed broken because every link was reinforced.
The defense-in-depth view
SSRF, open redirect, and OAuth redirect_uri attacks are individually low-to-medium severity. They become high-severity when chained. A defense strategy that focuses on each in isolation misses the chain risk. The general principles:
- Don’t trust user-supplied URLs anywhere they’re acted on by the server. Validate or allow-list every one.
- Enable cloud-platform mitigations even if you don’t think you need them. IMDSv2, GCP metadata header check, Azure managed-identity scope. Cheap to enable, expensive to retrofit during incident response.
- Reduce the workload’s privilege. A role that can only read its own logs cannot be pivoted into RCE through SSRF. Role scoping is the most underrated SSRF mitigation.
- Audit OAuth redirect_uri allowlists quarterly. Subdomain takeover plus a permissive allowlist is a common chain we see.
- Use
stateand PKCE for every OAuth flow. Even if you don’t currently have a known weakness, these mitigate vulnerabilities you don’t know about yet.
Wrong fix vs right fix — image proxy
// WRONG: only blocks the well-known metadata IP
const blocked = ['169.254.169.254']
if (blocked.includes(new URL(req.query.url).hostname)) return res.status(400).end()
fetch(req.query.url).then(r => r.body.pipe(res))
// Misses: 0.0.0.0, ::1, localhost, RFC1918 ranges, IPv6 link-local, DNS rebinding
// WRONG: hostname allowlist that doesn't follow redirects
const allowed = ['cdn.example.com', 'images.example.com']
if (!allowed.includes(new URL(req.query.url).hostname)) return res.status(400).end()
fetch(req.query.url).then(r => r.body.pipe(res))
// Misses: open redirect on cdn.example.com, attacker-controlled CNAME
// RIGHT: allowlist + redirect-following control + private-IP filter
import { isIP } from 'net'
import dns from 'dns/promises'
async function safeImageFetch(rawUrl: string) {
const url = new URL(rawUrl)
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('bad protocol')
if (!ALLOWED_HOSTS.includes(url.hostname)) throw new Error('host not allowed')
// Resolve and reject private/loopback addresses (covers DNS-rebinding-resistant case)
const addrs = await dns.resolve(url.hostname)
for (const a of addrs) {
if (isPrivateIP(a)) throw new Error('private addr')
}
// No redirect following — handle redirects in app logic if needed
return fetch(rawUrl, { redirect: 'manual' })
}
Cross-stack notes
- Node:
fetchfollows redirects by default in modern versions.redirect: 'manual'to disable. - Python (requests):
allow_redirects=Trueis the default;Falseto disable. URL parsing has historically been quirky —urllib.parseandrequestssometimes disagree on hostname extraction, which has been the source of SSRF bypasses. - Go:
http.Clientfollows redirects by default; overrideCheckRedirectto disable. Same private-IP filter discipline applies. - Java (URL): Older Java URL handling has known SSRF bypass issues with
0.0.0.0aliasing. Audit specifically.
How we detect all three
For SSRF: we hit the proxy endpoint with payloads pointing at private IPs, cloud metadata, localhost services. We watch the response. Anything other than “blocked” is a finding.
For open redirect: we send ?next=https://example.org and follow the redirect. If we land on example.org, finding.
For OAuth: we read the OAuth start URL, modify redirect_uri, and walk the flow to see if the provider accepts the modification. If it does, the allowlist is too permissive.
The detections are all runtime — none of these can be reliably caught from source code alone, because the validation logic is often present-but-broken in ways that look correct.
CWE / OWASP
- CWE-918 — Server-Side Request Forgery
- CWE-601 — URL Redirection to Untrusted Site
- CWE-346 — Origin Validation Error
- OWASP API Top 10 — API7:2023 Server Side Request Forgery
- OWASP Top 10 — A10:2021 SSRF, A05:2021 Security Misconfiguration
Reproduce it yourself
- SSRF image proxy: https://gapbench.vibe-eval.com/site/ssrf-image-proxy/
- Cloud metadata SSRF: https://gapbench.vibe-eval.com/site/gcp-metadata-ssrf/
- Open redirect: https://gapbench.vibe-eval.com/site/open-redirect/
- OAuth redirect_uri: https://gapbench.vibe-eval.com/site/oauth-redirect/
- OAuth token leak via Referer: https://gapbench.vibe-eval.com/site/oauth-token-leak-referer/
- PKCE downgrade: https://gapbench.vibe-eval.com/site/pkce-downgrade/
- Clean OAuth control: https://gapbench.vibe-eval.com/site/ref-oauth/
Related reading
- Pattern: CORS = * with credentials = true
- Pattern: JWT alg=none is not dead
- Tool: vibe-code-scanner
COMMON QUESTIONS
TEST THE URL-TRUST SURFACES
We probe the SSRF, redirect, and OAuth surfaces and tell you which ones accepted hostile URLs.