CVE-2026-42607
Description
Grav is a file-based Web platform. Prior to 2.0.0-beta.2, an authenticated user with administrative privileges can achieve Remote Code Execution (RCE) by uploading a specially crafted ZIP file through the "Direct Install" tool. While the system attempts to block direct .php file uploads, it fails to inspect the contents of uploaded ZIP archives. Once a malicious plugin is extracted, it can execute arbitrary PHP code or drop a persistent web shell on the server. This vulnerability is fixed in 2.0.0-beta.2.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
getgrav/gravPackagist | < 2.0.0-beta.2 | 2.0.0-beta.2 |
Affected products
1Patches
15a12f9be8314Security fixes: XSS detection, SVG XXE, Zip Slip, media attribute
10 files changed · +584 −6
CHANGELOG.md+5 −0 modified@@ -15,6 +15,11 @@ * [security] Hardened the new-user uniqueness guard in `UserObject::save` (GHSA-rr73-568v-28f8): `strpos(..., '@@')` replaced with `str_contains` (the old form was falsy when the transient-key marker was at position 0, silently bypassing the check) and the check now runs for every `FlexStorageInterface` backend rather than only `FileStorage`. A low-privileged user with `admin.users.create` can no longer disrupt a super-admin account by submitting the admin's username through the "add user" form. * [security] Added HMAC integrity to `Framework\Cache\Adapter\FileCache` (GHSA-gwfr-jfjf-92vv): every cache payload is signed with `Security::getNonceKey()` on write and verified on read; tampered, attacker-planted, or pre-upgrade files are treated as cache misses and removed instead of being unserialized. The on-disk format is now versioned (`v2\n<expires>\n<key>\n<hmac>\n<serialized>`); existing caches rebuild transparently on first read. * [security] Closed GHSA-vj3m-2g9h-vm4p (5-part advisory): (#1) `JobQueue` now HMAC-signs the `serialized_job` blob — a tampered queue file can no longer smuggle a forged `Job` for direct RCE via `Job::exec → call_user_func_array`; legitimate items still execute via the structured-fields fallback. (#3) `Session::getFlashObject` now wraps its payload in a versioned HMAC envelope, refusing to unserialize legacy/forged values. (#4) `InstallCommand` git-clone arguments (`branch`, `url`, `path` from `user/.dependencies`) now go through `escapeshellarg`, with a `--` separator before url/path to block option-injection. (#5) `twig_array_reduce` (plus `twig_array_some`/`twig_array_every`) added to `cleanDangerousTwig`'s callable blocklist alongside the existing `twig_array_map`/`filter`. (#2) was the same FileCache issue addressed by GHSA-gwfr above. + * [security] Tightened `Security::detectXss` `on_events` regex (GHSA-9695-8fr9-hw5q): the previous form required quotes/whitespace around the `=` sign and was bypassed by `<img onerror=alert(1)>`. Same fix knocks down the regex-bypass half of GHSA-c2q3-p4jr-c55f and GHSA-w8cg-7jcj-4vv2. + * [security] Added `svg`, `math`, `option`, `select` to default `security.xss_dangerous_tags` (GHSA-w8cg-7jcj-4vv2, GHSA-c2q3-p4jr-c55f) — XML-namespace tags that allow inline scripting plus the select-context break used to escape admin form templates. + * [security] `MediaObjectTrait::attribute()` now gates the attribute name through a strict identifier regex + denylist (`on*`, `style`, `xmlns`, `srcdoc`, `formaction`) so a Markdown image like `)` no longer injects an event handler onto the rendered tag (GHSA-r7fx-8g49-7hhr). + * [security] Hardened SVG dimension reading in `VectorImageMedium` against XXE / billion-laughs (GHSA-3446-6mgw-f79p): DOCTYPE/ENTITY declarations are stripped before parse and `simplexml_load_string` is now called with `LIBXML_NONET` (and `libxml_disable_entity_loader` for PHP < 8). The companion fix lives in rhukster/dom-sanitizer for SVG sanitization paths. + * [security] `Installer::unZip` now pre-validates every archive entry and refuses Zip Slip primitives — `..` segments, absolute paths, Windows drive letters, NUL bytes (GHSA-w48r-jppp-rcfw). Note: this hardens the path layer only; a well-formed but malicious plugin whose own PHP code is the payload remains a "trust the source" problem when using directInstall. # v2.0.0-beta.1 ## 04/16/2026
system/config/security.yaml+10 −0 modified@@ -32,6 +32,16 @@ xss_dangerous_tags: - title - base - isindex + # GHSA-w8cg-7jcj-4vv2: svg/math allow inline scripting via XML namespaces + # and event-handler attributes; flag them as dangerous in user-editable + # content so the on_events regex bypass (now fixed for GHSA-9695) has + # belt-and-suspenders coverage. + - svg + - math + # GHSA-c2q3-p4jr-c55f: option/select are used by attackers to break out + # of the admin select-template context. + - option + - select uploads_dangerous_extensions: - php - php2
system/src/Grav/Common/GPM/Installer.php+47 −0 modified@@ -179,6 +179,24 @@ public static function unZip($zip_file, $destination) $archive = $zip->open($zip_file); if ($archive === true) { + // GHSA-w48r-jppp-rcfw: validate every entry name before extraction. + // ZipArchive::extractTo would otherwise honour `../` segments and + // absolute paths, letting a crafted plugin/theme ZIP write files + // anywhere the web server can reach (Zip Slip, CVE-2018-1000544 + // family). Note: this hardens the path layer; it does NOT and + // cannot defend against a well-formed but malicious plugin whose + // own PHP code is the payload — that's a "trust the source" + // problem the admin must own when using directInstall. + $numFiles = $zip->numFiles; + for ($i = 0; $i < $numFiles; $i++) { + $entryName = (string) $zip->getNameIndex($i); + if (!self::isSafeArchiveEntry($entryName)) { + self::$error = self::ZIP_EXTRACT_ERROR; + $zip->close(); + return false; + } + } + Folder::create($destination); $unzip = $zip->extractTo($destination); @@ -207,6 +225,35 @@ public static function unZip($zip_file, $destination) return false; } + /** + * Reject Zip Slip primitives in archive entry names: empty names, NUL + * bytes, absolute paths, or any path segment that is `..`. Forward and + * back slashes are both treated as separators so Windows-authored + * archives are also covered. + * + * @internal Public for testing. + */ + public static function isSafeArchiveEntry(string $name): bool + { + if ($name === '' || str_contains($name, "\0")) { + return false; + } + if (str_starts_with($name, '/') || str_starts_with($name, '\\')) { + return false; + } + // Windows drive letter: C:\..., D:/... + if (preg_match('#^[A-Za-z]:[/\\\\]#', $name) === 1) { + return false; + } + // Any `..` path segment, regardless of slash flavour. + foreach (preg_split('#[\\\\/]+#', $name) as $segment) { + if ($segment === '..') { + return false; + } + } + return true; + } + /** * Instantiates and returns the package installer class *
system/src/Grav/Common/Media/Traits/MediaObjectTrait.php+35 −2 modified@@ -325,18 +325,51 @@ public function reset() /** * Add custom attribute to medium. * + * Reachable from Markdown via `?attribute=name,value` on image excerpts, so + * the attribute NAME is editor-controlled. We restrict it to plain HTML + * attribute identifiers (alphanumerics + `-`/`:`/`_`) and reject any name + * that would inject script when rendered onto an `<img>` tag — event + * handlers (`on*`), inline style, the XML namespace, srcdoc, and the + * various `form*` attributes whose URL targets are themselves trusted as + * actions. GHSA-r7fx-8g49-7hhr. + * * @param string $attribute * @param string $value * @return $this */ public function attribute($attribute = null, $value = '') { - if (!empty($attribute)) { - $this->attributes[$attribute] = $value; + if (empty($attribute) || !is_string($attribute)) { + return $this; } + if (!self::isSafeAttributeName($attribute)) { + return $this; + } + $this->attributes[$attribute] = $value; return $this; } + /** @internal */ + private static function isSafeAttributeName(string $name): bool + { + // Strict shape: HTML attribute names are letter-led, then alnum/-/_/:/./$ + // Anything else (whitespace, quotes, `<>`, etc.) is rejected outright. + if (!preg_match('/^[A-Za-z][A-Za-z0-9_:.\-]*$/', $name)) { + return false; + } + $lower = strtolower($name); + // Event handlers — primary GHSA-r7fx-8g49-7hhr vector. + if (str_starts_with($lower, 'on')) { + return false; + } + // Attribute names that open a scripting context regardless of value. + // We deliberately do NOT deny `src` / `href` here — themes legitimately + // call `$image->attribute('src', $signed_url)` from PHP, and the + // primary script-injection surface is the event-handler family above. + $denylist = ['style', 'xmlns', 'srcdoc', 'formaction']; + return !in_array($lower, $denylist, true); + } + /** * Switch display mode. *
system/src/Grav/Common/Page/Medium/VectorImageMedium.php+18 −1 modified@@ -46,7 +46,24 @@ public function __construct($items = [], ?Blueprint $blueprint = null) return; } - $xml = simplexml_load_string(file_get_contents($path)); + // GHSA-3446-6mgw-f79p: strip DOCTYPE/ENTITY declarations and pass + // LIBXML_NONET to prevent XXE / billion-laughs / network exfiltration + // when reading width/height from an attacker-supplied SVG. + $svg = (string) file_get_contents($path); + $svg = preg_replace('/<!DOCTYPE\b[^>]*(?:\[[^\]]*\])?[^>]*>/is', '', $svg) ?? $svg; + $svg = preg_replace('/<!ENTITY\b[^>]*>/i', '', $svg) ?? $svg; + + $previousEntityLoader = null; + if (\PHP_VERSION_ID < 80000 && function_exists('libxml_disable_entity_loader')) { + $previousEntityLoader = libxml_disable_entity_loader(true); + } + try { + $xml = simplexml_load_string($svg, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); + } finally { + if ($previousEntityLoader !== null) { + libxml_disable_entity_loader($previousEntityLoader); + } + } $attr = $xml ? $xml->attributes() : null; if (!$attr instanceof \SimpleXMLElement) { return;
system/src/Grav/Common/Security.php+10 −3 modified@@ -229,9 +229,16 @@ public static function detectXss($string, ?array $options = null): ?string // Set the patterns we'll test against $patterns = [ - // Match any attribute starting with "on" or xmlns (must be preceded by whitespace/special chars) - // Allow optional whitespace between 'on' and event name to catch obfuscation attempts - 'on_events' => '#(<[^>]+[\s\x00-\x20\"\'\/])(on\s*[a-z]+|xmlns)\s*=[\s|\'\"].*[\s|\'\"]>#iUu', + // Match any attribute starting with "on" or xmlns (must be preceded by an + // attribute boundary: whitespace, NUL, quote or slash). We deliberately + // do NOT try to match the attribute value itself — the previous regex + // required quotes-or-spaces around the `=` sign and was bypassed by + // unquoted handlers like `<img src=x onerror=alert(1)>` + // (GHSA-9695-8fr9-hw5q, also exploited by GHSA-c2q3-p4jr-c55f and + // GHSA-w8cg-7jcj-4vv2). Detecting the attribute name + `=` is enough + // for a tripwire; trade-off is occasional false positives when an + // unrelated `on*=` substring appears inside another attribute's value. + 'on_events' => '#<[^>]*?[\s\x00-\x20\"\'\/](on\s*[a-z]+|xmlns)\s*=#iu', // Match javascript:, livescript:, vbscript:, mocha:, feed: and data: protocols 'invalid_protocols' => '#(' . implode('|', array_map('preg_quote', $invalid_protocols, ['#'])) . ')(:|\&\#58)\S.*?#iUu',
tests/unit/Grav/Common/Security/DetectXssTest.php+137 −0 added@@ -0,0 +1,137 @@ +<?php + +use Codeception\Util\Fixtures; +use Grav\Common\Grav; +use Grav\Common\Security; + +/** + * Class DetectXssTest + * + * Tests for Security::detectXss() — specifically the on_events regex hardening + * for GHSA-9695-8fr9-hw5q (unquoted event handlers), with parallel coverage + * for the same bypass pattern called out in GHSA-c2q3-p4jr-c55f and + * GHSA-w8cg-7jcj-4vv2. + * + * Naming convention: test{Method}_{GHSA_ID}_{description} + */ +class DetectXssTest extends \PHPUnit\Framework\TestCase +{ + /** @var Grav */ + protected $grav; + + protected function setUp(): void + { + parent::setUp(); + $grav = Fixtures::get('grav'); + $this->grav = $grav(); + } + + // ========================================================================= + // GHSA-9695-8fr9-hw5q: unquoted on* handlers must be detected + // ========================================================================= + + /** + * @dataProvider providerGHSA9695_UnquotedOnEvents + */ + public function testDetectXss_GHSA9695_FlagsUnquotedEventHandler(string $payload, string $description): void + { + $result = Security::detectXss($payload); + self::assertSame('on_events', $result, "Should flag on_events for: $description"); + } + + public static function providerGHSA9695_UnquotedOnEvents(): array + { + return [ + ['<img src=x onerror=alert(1)>', 'advisory PoC: unquoted onerror, no space before >'], + ['<img src=x onerror=eval(atob(/Y/.source))>', 'advisory PoC: atob/regex.source obfuscation'], + ['<svg onload=alert(1)>', 'unquoted onload on svg'], + ['<body onload=alert(1)>', 'unquoted onload on body'], + ['<a href=# onclick=alert(1)>x</a>', 'unquoted onclick'], + // GHSA-c2q3-p4jr-c55f payload — the exact taxonomy escape sequence: + ['</option></select><img src=x onerror=alert(1)>', 'GHSA-c2q3 select-context break + unquoted onerror'], + // Obfuscation: whitespace inside the event name (e.g. on error=) + ['<img src=x on error=alert(1)>', 'whitespace between on and event name'], + // xmlns is also covered by the same rule — keep regression coverage: + ['<svg xmlns=http://example.com/ns>', 'unquoted xmlns'], + ]; + } + + /** + * @dataProvider providerGHSA9695_QuotedOnEvents + */ + public function testDetectXss_GHSA9695_StillFlagsQuotedEventHandlersAfterFix(string $payload, string $description): void + { + // Make sure tightening the regex didn't regress the previously-working + // quoted forms. + $result = Security::detectXss($payload); + self::assertSame('on_events', $result, "Should still flag quoted on_events for: $description"); + } + + public static function providerGHSA9695_QuotedOnEvents(): array + { + return [ + ['<img src="x" onerror="alert(1)">', 'double-quoted onerror'], + ["<img src='x' onerror='alert(1)'>", 'single-quoted onerror'], + ['<body onload="document.location=\'evil\'">', 'quoted onload'], + ['<svg onload="fetch(\'/x\')">', 'svg with quoted onload'], + ]; + } + + // ========================================================================= + // Negative coverage: legitimate content should not trip on_events + // ========================================================================= + + /** + * @dataProvider providerSafeContent + */ + public function testDetectXss_SafeContentReturnsNullOnEventsRule(string $payload, string $description): void + { + // Some safe content may still trip OTHER rules (e.g. the dangerous_tags + // list), but the on_events rule specifically should not fire. + $result = Security::detectXss($payload); + self::assertNotSame('on_events', $result, "on_events must not fire for: $description"); + } + + public static function providerSafeContent(): array + { + return [ + ['<p>Hello world</p>', 'plain paragraph'], + ['<a href="https://example.com">link</a>', 'link with href'], + ['<img src="/foo.png" alt="bar">', 'plain img'], + ['Pricing on demand', 'word starting with "on" outside any tag'], + ['<button>Click me</button>', 'button tag (ends in "on")'], + ['<section>content</section>', 'section tag'], + ]; + } + + // ========================================================================= + // GHSA-w8cg-7jcj-4vv2: svg/math + GHSA-c2q3 option/select added to defaults + // ========================================================================= + + /** + * @dataProvider providerGHSAw8cg_NewlyDangerousTags + */ + public function testDetectXss_GHSAw8cg_FlagsNewlyDangerousTags(string $payload, string $description): void + { + $result = Security::detectXss($payload); + // Either dangerous_tags (new) or on_events (already covered by #1) is + // an acceptable trip — both indicate the payload is flagged. + self::assertNotNull($result, "Should flag: $description"); + self::assertContains( + $result, + ['dangerous_tags', 'on_events'], + "Expected dangerous_tags or on_events for: $description, got '$result'" + ); + } + + public static function providerGHSAw8cg_NewlyDangerousTags(): array + { + return [ + ['<svg><script>alert(1)</script></svg>', 'GHSA-w8cg svg with embedded script'], + ['<svg></svg>', 'svg tag alone'], + ['<math><mtext>x</mtext></math>', 'math tag (similar XML namespace risk)'], + ['</option></select>injected', 'GHSA-c2q3 option/select context break'], + ['<select><option>x</option></select>', 'option/select wrapping'], + ]; + } +}
tests/unit/Grav/Common/Security/MediaAttributeSecurityTest.php+125 −0 added@@ -0,0 +1,125 @@ +<?php + +use Codeception\Util\Fixtures; +use Grav\Common\Grav; +use Grav\Common\Media\Traits\MediaObjectTrait; + +/** + * Class MediaAttributeSecurityTest + * + * Covers: GHSA-r7fx-8g49-7hhr (Markdown image `?attribute=NAME,VALUE` reaches + * `MediaObjectTrait::attribute()` which let an editor set arbitrary HTML + * attribute names — including event handlers — on the rendered <img>). + * + * The fix gates the attribute name through an allowlist regex and a small + * denylist of script-context names. Safe `data-*`/`aria-*`/typical media + * attributes still pass. + * + * Naming convention: test{Method}_{GHSA_ID}_{description} + */ +class MediaAttributeSecurityTest extends \PHPUnit\Framework\TestCase +{ + /** @var Grav */ + protected $grav; + + protected function setUp(): void + { + parent::setUp(); + $grav = Fixtures::get('grav'); + $this->grav = $grav(); + } + + private function newMedium(): object + { + // Lightweight stand-in: any class that uses MediaObjectTrait works. + // attribute() reads/writes the trait's own protected $attributes; we + // expose it through reflection in the assertions. The trait declares + // a handful of abstract methods we don't exercise — stub them. + return new class { + use MediaObjectTrait; + public function addMetaFile($filepath) {} + public function __toString(): string { return ''; } + public function url($reset = true) { return ''; } + public function get($name, mixed $default = null, $separator = null) { return $default; } + public function set($name, mixed $value, $separator = null) { return $this; } + protected function createThumbnail($thumb) { return null; } + protected function createLink(array $attributes) { return null; } + protected function getItems(): array { return []; } + }; + } + + private function attrs(object $medium): array + { + $r = new ReflectionClass($medium); + $p = $r->getProperty('attributes'); + $p->setAccessible(true); + return (array) $p->getValue($medium); + } + + /** + * @dataProvider providerGHSAr7fx_DangerousAttributeNames + */ + public function testAttribute_GHSAr7fx_RejectsDangerousAttributeNames(string $name, string $description): void + { + $m = $this->newMedium(); + $m->attribute($name, 'value-that-must-not-stick'); + + self::assertArrayNotHasKey($name, $this->attrs($m), "Should not store dangerous attribute: $description"); + self::assertArrayNotHasKey(strtolower($name), $this->attrs($m), "case-insensitive: $description"); + } + + public static function providerGHSAr7fx_DangerousAttributeNames(): array + { + return [ + ['onerror', 'GHSA-r7fx PoC: onerror handler'], + ['onload', 'onload handler'], + ['onclick', 'onclick handler'], + ['ONERROR', 'uppercase event handler'], + ['OnMouseOver', 'mixed-case event handler'], + ['style', 'inline style (CSS expression risk)'], + ['xmlns', 'XML namespace'], + ['srcdoc', 'iframe srcdoc'], + ['formaction', 'form action override'], + // Malformed names — must be rejected even before the denylist hits. + ['bad name', 'whitespace in attribute name'], + ['bad>tag', 'attribute name with `>` (tag-break attempt)'], + ['"onerror', 'attribute name with leading quote'], + ['', 'empty name (already handled by empty() check)'], + ['1foo', 'attribute name not letter-led'], + ]; + } + + /** + * @dataProvider providerGHSAr7fx_SafeAttributeNames + */ + public function testAttribute_GHSAr7fx_AcceptsSafeAttributeNames(string $name, string $description): void + { + $m = $this->newMedium(); + $m->attribute($name, 'safe-value'); + + self::assertArrayHasKey($name, $this->attrs($m), "Should accept safe attribute: $description"); + self::assertSame('safe-value', $this->attrs($m)[$name], "value should round-trip: $description"); + } + + public static function providerGHSAr7fx_SafeAttributeNames(): array + { + return [ + ['alt', 'alt'], + ['title', 'title'], + ['class', 'class'], + ['id', 'id'], + ['loading', 'loading'], + ['decoding', 'decoding'], + ['width', 'width'], + ['height', 'height'], + ['data-foo', 'data-* attribute (common theme use)'], + ['data-image-id', 'data-* with hyphens'], + ['aria-label', 'aria-* attribute'], + ['rel', 'rel'], + // src/href intentionally allowed — themes legitimately call + // $image->attribute('src', $signed_url) from PHP. + ['src', 'src (themes override URLs)'], + ['href', 'href (link wrappers)'], + ]; + } +}
tests/unit/Grav/Common/Security/SvgXxeSecurityTest.php+126 −0 added@@ -0,0 +1,126 @@ +<?php + +/** + * Class SvgXxeSecurityTest + * + * Covers: GHSA-3446-6mgw-f79p (XXE via SVG upload). Grav reads dimensions + * from uploaded SVGs via simplexml_load_string in VectorImageMedium; the + * hardening pre-strips DOCTYPE/ENTITY declarations and parses with + * LIBXML_NONET so attacker-controlled `SYSTEM` entity references can't + * pull in `/etc/passwd` or trigger network requests. + * + * The hardening is applied in two places: VectorImageMedium itself and + * (independently) the dom-sanitizer library. This test file exercises the + * parsing primitives that both rely on; a separate test in dom-sanitizer's + * own suite covers that side. + * + * Naming convention: test{Method}_{GHSA_ID}_{description} + */ +class SvgXxeSecurityTest extends \PHPUnit\Framework\TestCase +{ + /** + * Mirror VectorImageMedium's hardened SVG parse so the test exercises + * the exact strip-and-parse sequence we ship. + */ + private static function safeParseSvg(string $content): ?\SimpleXMLElement + { + $content = preg_replace('/<!DOCTYPE\b[^>]*(?:\[[^\]]*\])?[^>]*>/is', '', $content) ?? $content; + $content = preg_replace('/<!ENTITY\b[^>]*>/i', '', $content) ?? $content; + + $previousEntityLoader = null; + if (\PHP_VERSION_ID < 80000 && function_exists('libxml_disable_entity_loader')) { + $previousEntityLoader = libxml_disable_entity_loader(true); + } + try { + $xml = simplexml_load_string($content, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); + } finally { + if ($previousEntityLoader !== null) { + libxml_disable_entity_loader($previousEntityLoader); + } + } + return $xml === false ? null : $xml; + } + + /** + * After DOCTYPE/ENTITY stripping, any `&name;` reference in the body + * becomes an undefined entity. simplexml may then refuse to parse the + * doc entirely (returning null) — which is itself a safe outcome: + * nothing was expanded, nothing leaked. We accept either result and + * focus the assertions on what MUST NOT happen (file contents leaking + * into the output). + */ + public function testParse_GHSA3446_DoesNotExpandExternalSystemEntity(): void + { + $payload = <<<'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE svg [ + <!ENTITY xxe SYSTEM "file:///etc/passwd"> +]> +<svg xmlns="http://www.w3.org/2000/svg" width="&xxe;" height="100"> + <text>&xxe;</text> +</svg> +SVG; + $xml = self::safeParseSvg($payload); + + if ($xml === null) { + // Parser refused the doc (undefined &xxe; after strip) — safe. + $this->addToAssertionCount(1); + return; + } + + // If a parser was lenient enough to accept it, the entity must NOT + // have been substituted with /etc/passwd contents. + $width = (string) $xml->attributes()->width; + $textContent = (string) $xml->text; + self::assertStringNotContainsString('root:x:', $width, 'must not have read /etc/passwd into the width attribute'); + self::assertStringNotContainsString('root:x:', $textContent, 'must not have read /etc/passwd into <text>'); + self::assertStringNotContainsString('/bin/', $width); + self::assertStringNotContainsString('/bin/', $textContent); + } + + public function testParse_GHSA3446_BillionLaughsDoesNotExpand(): void + { + $payload = <<<'SVG' +<?xml version="1.0"?> +<!DOCTYPE lolz [ + <!ENTITY lol "lol"> + <!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;"> + <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;"> +]> +<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"> + <text>&lol3;</text> +</svg> +SVG; + $startPeak = memory_get_peak_usage(); + $xml = self::safeParseSvg($payload); + $delta = memory_get_peak_usage() - $startPeak; + + // The DoS angle: bounded memory regardless of whether the parser + // returns a SimpleXMLElement (lenient) or null (strict). + self::assertLessThan(1024 * 1024, $delta, 'parsing must not allocate megabytes from entity expansion'); + + // If the parser DID accept it, the lol3 reference must not have been + // expanded into hundreds of `lol`s. + if ($xml !== null) { + $textContent = (string) $xml->text; + self::assertLessThan(50, strlen($textContent), 'entities must not have expanded'); + } else { + $this->addToAssertionCount(1); + } + } + + public function testParse_GHSA3446_PlainSvgWidthHeightStillParsed(): void + { + $xml = self::safeParseSvg('<svg xmlns="http://www.w3.org/2000/svg" width="42" height="84"><rect/></svg>'); + self::assertNotNull($xml); + self::assertSame('42', (string) $xml->attributes()->width); + self::assertSame('84', (string) $xml->attributes()->height); + } + + public function testParse_GHSA3446_PlainSvgViewBoxStillParsed(): void + { + $xml = self::safeParseSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 150"><rect/></svg>'); + self::assertNotNull($xml); + self::assertSame('0 0 200 150', (string) $xml->attributes()->viewBox); + } +}
tests/unit/Grav/Common/Security/ZipSlipSecurityTest.php+71 −0 added@@ -0,0 +1,71 @@ +<?php + +use Grav\Common\GPM\Installer; + +/** + * Class ZipSlipSecurityTest + * + * Covers: GHSA-w48r-jppp-rcfw (malicious plugin/theme ZIP via directInstall). + * The unZip path now pre-validates every entry name and aborts the install + * if any look like Zip Slip primitives — `../` traversal, absolute paths, + * Windows drive letters, NUL bytes, etc. Well-formed entries still extract + * normally. + * + * Note: this test pins the path-layer hardening only. Defending against a + * well-formed but malicious plugin (whose own PHP is the payload) is a + * separate "trust the source" problem the admin owns when using + * directInstall — see the changelog and advisory triage notes. + * + * Naming convention: test{Method}_{GHSA_ID}_{description} + */ +class ZipSlipSecurityTest extends \PHPUnit\Framework\TestCase +{ + /** + * @dataProvider providerGHSAw48r_DangerousEntryNames + */ + public function testIsSafeArchiveEntry_GHSAw48r_RejectsDangerousNames(string $name, string $description): void + { + self::assertFalse(Installer::isSafeArchiveEntry($name), "Should reject: $description"); + } + + public static function providerGHSAw48r_DangerousEntryNames(): array + { + return [ + ['', 'empty entry name'], + ["foo\0bar", 'NUL byte in name'], + ['../etc/passwd', 'classic parent-dir traversal'], + ['plugin/../../etc/passwd', 'embedded traversal'], + ['plugin/sub/../../../etc', 'deep traversal'], + ['/etc/passwd', 'absolute Unix path'], + ['/var/www/html/shell.php', 'absolute web-root path'], + ['\\windows\\system32', 'absolute Windows-style backslash'], + ['C:\\windows\\evil.dll', 'Windows drive letter (backslash)'], + ['c:/windows/evil.dll', 'Windows drive letter (forward-slash, lowercase)'], + ['plugin\\..\\..\\etc', 'Windows-style backslash traversal'], + ['..', 'bare ..'], + ['./../foo', 'mixed ./ and ../'], + ]; + } + + /** + * @dataProvider providerGHSAw48r_SafeEntryNames + */ + public function testIsSafeArchiveEntry_GHSAw48r_AcceptsLegitimateNames(string $name, string $description): void + { + self::assertTrue(Installer::isSafeArchiveEntry($name), "Should accept: $description"); + } + + public static function providerGHSAw48r_SafeEntryNames(): array + { + return [ + ['plugin/', 'plugin folder root'], + ['plugin/plugin.php', 'plugin entry file'], + ['plugin/blueprints.yaml', 'blueprint'], + ['plugin/templates/index.html.twig', 'nested template'], + ['plugin/assets/img/logo-1.svg', 'nested static asset with hyphen'], + ['plugin/sub.dir/file.ext', 'dotted segment that is not ..'], + ['plugin/.hidden', 'dotfile inside plugin'], + ['plugin/CHANGELOG.md', 'all-caps + extension'], + ]; + } +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.