Shopware’s SSRF Bypass Is a Textbook Example of the Adjacent Endpoint Problem
A patched SSRF in Shopware’s uploadFromURL left a nearly identical endpoint exposed – here’s how the bypass works, why even HEAD requests matter, and how to keep your own APIs from suffering the same fate.
Look, I get why this didn’t make front-page headlines. A Moderate severity SSRF, authenticated admin only, fixed in a routine e-commerce platform patch – it’s the kind of advisory that slides right past most people’s radar. But I’ve spent too many hours staring at Burp Suite traffic to ignore it, because the pattern that produced this bug is exactly the kind of thing that burns teams over and over and over.
The disclosure title tells you most of what you need to know: the `/api/_action/media/external-link` endpoint in Shopware allowed authenticated admin users to make server-side HTTP HEAD requests to arbitrary internal IP addresses. The twist? A nearly identical endpoint, uploadFromURL, had already been fixed for the same class of vulnerability. This one was just… missed.
I call this the Adjacent Endpoint Problem. You harden one function, you tighten one input path, but you forget that there are three other code paths that do basically the same thing. And eventually, someone notices. In this post, I’m going to do more than just recap the advisory – I’ll break down the mechanics of this bypass, explain why a HEAD-only SSRF is still dangerous, walk through practical input validation you can drop into your own codebase, and show you how to hunt for these gaps before someone else does.
The Bug
Shopware is a popular PHP-based e-commerce platform, and like any modern CMS, it offers a media management API. The feature in question lets an admin specify a URL, and then Shopware fetches (or at least inspects) that resource. In the uploadFromURL scenario, the server would download the file from the URL and import it into the media library. That’s a classic SSRF-enabling pattern: server reaches out to whatever you give it, including http://169.254.169.254/latest/meta-data/ or http://127.0.0.1:8080/admin or http://192.168.1.1/. That endpoint was patched – Shopware added checks to block internal/private IP ranges.
The problem is, there was another endpoint: /api/_action/media/external-link. According to the advisory, this one allows an admin to “make server-side HTTP HEAD requests” to external URLs. That sounds benign compared to downloading full files – but it’s still making a request to whatever IP you shove into the URL parameter. And crucially, the IP validation that protected uploadFromURL was not applied to external-link.
We don’t have the exact diff (the advisory references GHSA-gq96-5pfx-f4vc and the fix is in versions 6.6.10.1 and 6.7.1.1), but you can infer the shape of the bug easily. Someone on the development team likely built the URL-fetching logic as a standalone helper, someone else later added the SSRF fix to the upload endpoint, but the external-link endpoint continued to call the unfixed helper or duplicated the logic without the validation. Happens all the time.
A malicious admin – or an attacker who’s already compromised an admin account through credential stuffing, phishing, or session hijacking – could send a request like:
POST /api/_action/media/external-link HTTP/1.1
Host: shopware-instance.example.com
Content-Type: application/json
Authorization: Bearer <admin token>
{"url": "http://169.254.169.254/latest/meta-data/"}The server would dutifully issue a HEAD request to the AWS metadata endpoint, and while a HEAD request wouldn’t return the metadata body, it would confirm the endpoint is alive, reveal response headers (like server versions), and in some cases leak sensitive data if the internal service ignores the HTTP method or errors out with verbose messages. More on that in a minute.
Why HEAD is Enough
A lot of people hear “SSRF” and think “the attacker must be able to read the response.” That’s a dangerous misconception. Blind SSRF – where you trigger a request but can’t directly see the response – has been a weapon for years. And even when you can see something – like an HTTP status code, response headers, or timing – you have more than enough to cause damage.
In this Shopware case, the endpoint performs a HEAD request and likely returns at least the HTTP status and maybe some headers to the admin user. I don’t have the actual response schema in front of me, but imagine it returns a JSON blob like:
{
"status": 200,
"content-type": "text/html",
"content-length": 512,
"server": "nginx/1.18.0"
}That’s already a goldmine. You can map internal infrastructure: Does http://10.0.0.5:9200/ return a 200 or a 404? That tells you whether Elasticsearch is running. Does http://redis.internal:6379/ time out, or return something (once you know Redis speaks HTTP-ish in some configurations)? You can scan for open ports, identify services, and build a map of the internal network without ever seeing a page body. Even a simple HEAD to http://127.0.0.1:22/ might hang or error differently than to a port that’s closed, giving you a primitive of port-scanning via timing analysis.
And then there are cases where internal services don’t care about the method. I’ve seen internal dashboards, monitoring tools, and development endpoints that respond to HEAD requests identically to GET, sometimes leaking session tokens, internal hostnames, or configuration snippets in headers. The advisory doesn’t specify whether Shopware forwards only status and headers or also something like error pages, but the fact remains: a HEAD-triggered SSRF is not a theoretical risk. It’s a latticework of pivot points.
The Real Cost of "Moderate"
The CVSS score for this advisory is 4.3, which lands squarely in “Moderate” territory. Why? Because it requires authentication (admin) and only leaks limited data through a HEAD request. That’s a fair scoring if you’re looking at the vulnerability in isolation with zero context. But I push back on that mental model.
When I’m threat modeling, an authenticated admin SSRF doesn’t live in a vacuum. Admin accounts are compromised all the time – weak passwords, phishing, token theft, insider misuse. Once you have admin, the SSRF becomes a pivot that bypasses network segmentation. If the Shopware instance sits in a DMZ but can reach internal services, that SSRF is the bridge the attacker needs. I’ve seen real-world attacks where chaining an SSRF with something like a slightly misconfigured internal Jenkins instance led to full internal network compromise. The severity isn’t just about the bug itself; it’s about what it enables.
And then there’s the Adjacent Endpoint Problem I mentioned earlier. The existence of this bug is itself a signal: this codebase probably has other URL-fetching paths that haven’t been reviewed for SSRF. Plugins, custom extensions, admin tools – if the core team missed a parallel endpoint, what are the odds that the third-party ecosystem didn’t? Attackers know this. When a patch drops for a specific SSRF, the first thing they do is grep the codebase for every other place that constructs an outgoing HTTP request. A patch is a neon sign saying “look here for similar bugs.”
Defense: Proper URL Validation
So, how do you avoid this in your own application? The answer is not “add a regex to block 127.0.0.1.” If that’s your go-to, I guarantee you’ve missed something. IPv6 representations, decimal IP encoding, octal, redirected DNS records, the 0.0.0.0 address, link-local ranges, the AWS metadata IP, Google Cloud metadata, Azure metadata, Docker bridge networks – the list of gotchas is absurdly long. And your regex probably doesn’t catch http://0x7f.0.0.1/ or http://127.1/ or http://[::ffff:169.254.169.254]/.
Instead, you want to parse the URL properly, resolve the hostname (or at least validate the IP), and then check it against a list of disallowed ranges. In Python, you might do something like:
import socket
import ipaddress
from urllib.parse import urlparse
def is_url_safe(url: str) -> tuple[bool, str]:
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
return False, "Invalid hostname"
# Resolve to IP (caution: may cause SSRF on its own if DNS is internal – but you'll handle that)
try:
ip = socket.getaddrinfo(hostname, None)[0][4][0]
except socket.gaierror:
return False, "Could not resolve host"
try:
ip_addr = ipaddress.ip_address(ip)
except ValueError:
return False, "Not a valid IP"
# Check against blocked ranges
if ip_addr.is_loopback or ip_addr.is_private or ip_addr.is_link_local:
return False, f"Blocked IP range: {ip}"
# Additional checks for metadata endpoints
BLOCKED_IPS = [
ipaddress.IPv4Address("169.254.169.254"),
ipaddress.IPv6Address("fd00::a9fe:a9fe"), # example metadata V6
]
if ip_addr in BLOCKED_IPS:
return False, f"Metadata endpoint blocked: {ip}"
return True, "OK"That’s a start. But even this isn’t foolproof – if an attacker controls a DNS record that resolves to an internal IP after the check, you’re still hosed. So you’d want to resolve synchronously and then verify that the resolved IP isn’t private, and even then, consider time-of-check-time-of-use issues if you don’t pin the connection. That’s where network-level controls come in: run your application in an environment where egress to internal subnets is blocked at the firewall, or use a proxy that filters based on destination IP at the connection level, not the HTTP level. Defense in depth.
In Node.js, you can do a similar dance with the net and dns modules, but honestly, the ecosystem has libraries like ssrf-filter or custom wrappers around http.Agent that reject connections to private IPs. Just be careful: many of those libraries fail to handle IPv6 mapping or decimal-encoded IPs correctly out of the box. Test with something like:
curl -X POST https://yourserver/api/fetch \
-H "Content-Type: application/json" \
-d '{"url": "http://2130706433/"}' # That's 127.0.0.1 in decimalIf that succeeds and hits your loopback, your filter is broken.
The Shopware team’s fix (in 6.6.10.1 and 6.7.1.1) presumably extends the same IP validation from uploadFromURL to the external-link endpoint. That’s a good tactical fix. But strategically, the real defense is consolidating all outbound HTTP calls into a single, validated gateway function. When every piece of code that makes a server-side request goes through a choke point that enforces IP restrictions, it’s harder to overlook a new endpoint. I’ve built similar internal libraries for Node.js apps: a custom fetch that wraps node-fetch with mandatory URL validation, and a CI check that fails if anyone imports the raw fetch directly. Buy back your sanity.
Detection: Find These Gaps Before Attackers Do
If you’re on the defensive side, you need to ask: does my application have an Adjacent Endpoint Problem? You can find out with a combination of static analysis and targeted fuzzing.
First, grep your codebase for every line that initiates an HTTP request – fetch(), axios(), http.request(), curl_exec(), file_get_contents with URLs, etc. For each one, trace back to the source of the URL. Is it user-supplied? Even if it’s admin-supplied? Mark it as potentially vulnerable and manually audit the validation. I’ve found dozens of SSRF bugs this way, many of them in “already patched” areas.
Second, write integration tests that fire off a battery of SSRF probes against every endpoint that accepts a URL. I use a list that includes:
http://127.0.0.1:80/http://[::1]:80/http://0x7f000001/http://0177.0.0.1/http://127.1/http://169.254.169.254/latest/meta-data/http://metadata.google.internal/http://kubernetes.default.svc/http://localhost:22/- A domain you control that resolves to a private IP (via DNS rebinding simulation)
Automate this so it runs nightly. If any endpoint ever returns a non-error that indicates the request went through, you’ve got a problem. For this specific Shopware pattern, you’d want to add HEAD methods to your probes as well, since GET-only tests might miss a HEAD-only endpoint.
For quick manual validation, a dirty curl one-liner got me through many bug bounty sessions:
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $TOKEN" \
-X POST \
-d '{"url":"http://127.0.0.1:80/"}' \
"https://target.example/api/_action/media/external-link"If it returns 200 and the response time suggests a real connection, start diving deeper.
Lessons from the Adjacent Endpoint
The Shopware advisory isn’t an isolated incident – it’s a symptom of how we build software. We fix the thing we see, merge the PR, and move on. But vulnerabilities have siblings. Every time you patch an SSRF, an IDOR, a deserialization flaw, you should immediately ask: “What other endpoints do the same thing?” Run a cross-reference search across the entire codebase for similar patterns. If the bug was in a file upload handler that processes a URL, look at every other file import, image proxy, webhook handler, avatar fetcher, and URL-preview generator. Chances are high at least one of them shares the same flawed logic.
I’ve spent a good chunk of my bug hunting career exploiting exactly this gap. A program rolls out a patch for a critical finding, and I pull the diff, note the changed code, and then search the rest of the application for anything that resembles the vulnerable version. More often than not, the patch is incomplete. Not because the developers are incompetent, but because they’re under pressure and the codebase has sprawled. When you’ve got 500 API endpoints and only a handful of them are “known dangerous,” it’s easy to miss one.
This is also where automated API mapping tools and Swagger/OpenAPI specs go from “nice to have” to “essential.” If the Shopware team had an exhaustive endpoint catalog and a CI check that runs SSRF probes against every single endpoint that accepts a URL, the external-link bypass would have lit up like a Christmas tree the moment they wrote the test. But many teams don’t have that yet. If you’re building APIs, you need to treat them like an attacker would: enumerate every route, every parameter, and ask what happens when you feed it something ugly.
One more thing: this advisory is for authenticated admin SSRF. But consider the real-world scenario where an admin’s session gets stolen via a client-side XSS or a credential leak. Suddenly that “moderate” becomes a critical stepping stone. When you prioritize patches, don’t just look at the CVSS vector in isolation. Think about what an attacker who’s already inside your application perimeter can do with the bug. If the answer is “pivot to internal networks, read cloud metadata, scan internal services,” bump that issue up in your mental severity.
The fix in Shopware is available now, and if you’re running an affected instance (prior to 6.6.10.1 or 6.7.1.1), you should patch immediately. But even after you patch, go look at your own code. Audit every URL-accepting endpoint. Build that choke-point validation layer. And treat every security patch as a signal to search for its siblings. Because if you don’t, someone else will, and they’ll be holding a HEAD request and a smile.
Get your own house in order. If you’ve got an e-commerce platform, a CMS, or any app that fetches resources based on user input, run through your endpoints with the probes I listed above. It takes 30 minutes, and you might just find the next advisory before GitHub does.
Related posts
- Security
How I got free cinema credit by ordering -2 popcorns
A missing input validation on M-Tix Cinema XXI's food ordering API let me increase my account balance by submitting negative quantities. No tools needed — just a browser.
May 19, 2026 · 6 min - Security
How I analyze API security headers in 30 seconds
A quick checklist for reading HTTP response headers and spotting security misconfigurations before you even look at the response body.
May 18, 2026 · 7 min - Security
Common auth mistakes I find when reverse-engineering APIs
After years of poking at APIs that weren't meant to be poked at, these are the auth patterns that break most often — and why.
May 18, 2026 · 9 min