GitHub Advisoryadvisory · Packagist
GHSA-9ccq-2jfg-qw33 Grav: Origin validation bypass in Uri::referrer() and Pages::referrerRoute() via unanchored prefix match
LOW
Official page ↗
- Aliases
- CVE-2026-72702
- Ecosystem
- Packagist
- Packages
- getgrav/grav
- Severity
- Low
- Weaknesses
- CWE-346
- Reviewed
- 2026-09-17
Affected packages
• Packagist getgrav/grav: < 2.0.16 → fixed in 2.0.16
Description
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
'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 …
Related
Alias · CVE (1)
- CVE-2026-72702 Grav CMS before 2.0.16 contains an origin validation bypass in the Uri::referrer() and Pages::referrerRoute() methods, which validate the… vulnerability
Weakness · CWE (1)
- CWE-346 Origin Validation Error weakness