VYPR
Medium severity5.4GHSA Advisory· Published May 11, 2026· Updated May 13, 2026

CVE-2026-42842

CVE-2026-42842

Description

The form plugin for Grav adds the ability to create and use forms. Prior to 9.1.0, a Stored Cross-Site Scripting (XSS) vulnerability exists in the Grav CMS Form plugin's select field template. Taxonomy tag and category values are rendered with the Twig |raw filter in the admin panel, bypassing the global autoescape protection. An editor-level user can inject arbitrary JavaScript that executes in any administrator's browser session when they view or edit any page in the admin panel. This vulnerability is fixed in 9.1.0.

Affected products

1

Patches

2
6bffb4c98be4

[security] Drop |raw on select option text (GHSA-c2q3-p4jr-c55f)

https://github.com/getgrav/grav-plugin-formAndy MillerApr 23, 2026via ghsa
3 files changed · +14 5
  • blueprints.yaml+1 1 modified
    @@ -1,7 +1,7 @@
     name: Form
     slug: form
     type: plugin
    -version: 9.0.0
    +version: 9.0.1
     description: Enables forms handling and processing
     icon: check-square
     author:
    
  • CHANGELOG.md+6 0 modified
    @@ -1,3 +1,9 @@
    +# v9.0.1
    +## 04/23/2026
    +
    +1. [](#bugfix)
    +   * [security] Fixed stored XSS in select-field option text (GHSA-c2q3-p4jr-c55f). Removed the `|raw` filter from `templates/forms/fields/select/select.html.twig`; option labels — including taxonomy values that propagate cross-page through the admin's shared selection pool — are now autoescaped, so a lower-privileged editor can no longer inject script that runs in an admin's browser when they open any page editor.
    +
     # v9.0.0
     ## 04/21/20265
     
    
  • templates/forms/fields/select/select.html.twig+7 4 modified
    @@ -34,7 +34,7 @@
                         {% endfor %}
                     {% endif %}
                     >
    -            {% if field.placeholder %}<option value="" disabled selected>{{ field.placeholder|t|raw }}</option>{% endif %}
    +            {% if field.placeholder %}<option value="" disabled selected>{{ field.placeholder|t }}</option>{% endif %}
     
                 {% set options = field.options %}
                 {% if field.selectize.create and value %}
    @@ -52,7 +52,10 @@
                             {{ item_value.label    ? 'label=' ~ item_value.label : '' }}
                             value="{{ akey }}"
                         >
    -                        {{ avalue|raw }}
    +                        {# GHSA-c2q3-p4jr-c55f: dropped |raw — option text is now
    +                           autoescaped so taxonomy/option values supplied by
    +                           lower-privileged editors can no longer inject script. #}
    +                        {{ avalue }}
                         </option>
                     {% elseif item_value is iterable %}
                         {% set optgroup_label = item_value|keys|first %}
    @@ -62,14 +65,14 @@
                               {% set item_value = (field.selectize and field.multiple ? suboption : subkey)|string %}
                               {% set selected = (field.selectize ? suboption : subkey)|string %}
                               <option {% if subkey is same as (value) or (field.multiple and selected in value) %}selected="selected"{% endif %} value="{{ subkey }}">
    -                            {{ suboption|t|raw }}
    +                            {{ suboption|t }}
                               </option>
                           {% endfor %}
                         </optgroup>
                     {% else %}
                         {% set val = (field.selectize and field.multiple ? item_value : key)|string %}
                         {% set selected = (field.selectize ? item_value : key)|string %}
    -                    <option {% if val is same as (value) or (field.multiple and selected in value) %}selected="selected"{% endif %} value="{{ val }}">{{ item_value|t|raw }}</option>
    +                    <option {% if val is same as (value) or (field.multiple and selected in value) %}selected="selected"{% endif %} value="{{ val }}">{{ item_value|t }}</option>
                     {% endif %}
                 {% endfor %}
     
    
5a12f9be8314

Security fixes: XSS detection, SVG XXE, Zip Slip, media attribute

https://github.com/getgrav/gravAndy MillerApr 23, 2026via ghsa
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 `![alt](img.gif?attribute=onload,alert(1))` 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

5

News mentions

0

No linked articles in our index yet.