
Stored XSS via missing XSS safety check in Admin2 Pages API partial validation
5,1
Medium
Detected by

Fluid Attacks AI SAST Scanner
Disclosed by
Santiago Alvarez
Summary
Full name
Stored XSS via missing XSS safety check in Admin2 Pages API partial validation
Code name
State
Public
Release date
Affected product
grav-plugin-api
Vendor
Grav
Affected version(s)
1.7.52
Vulnerability name
Stored cross-site scripting (XSS)
Vulnerability type
Remotely exploitable
Yes
CVSS v4.0 vector string
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:L/VI:L/VA:N/SC:L/SI:L/SA:N
CVSS v4.0 base score
5.1
Exploit available
Yes
CVE ID(s)
Description
Grav 2.0.0-rc.9 with Admin2 2.0.0-rc.14 contains a stored cross-site scripting (XSS) vulnerability in the Admin2 Pages API save flow.
An authenticated non-superadmin user with page write permissions can persist arbitrary HTML event-handler JavaScript in page Markdown content through PATCH /api/v1/pages/{route}. The payload is stored in the page file and later rendered in the public frontend without sanitization. Any visitor who opens the affected page, including an authenticated administrator, executes the attacker-controlled JavaScript in the site's origin.
The issue is not that Grav's Security::detectXss() detector fails to recognize the payload. In the tested branch, the detector correctly flags unquoted event attributes as on_events. The vulnerable behavior is that the Admin2/API partial page validation path validates changed fields with Validation::validate() but does not run the XSS safety check (Validation::checkSafety()) before saving page content.
Note:
The existing upstream patch for GHSA-9695-8fr9-hw5q / CVE-2026-42612 updates the Security::detectXss() on_events pattern so unquoted event-handler attributes such as <img src=x onerror=alert(1)> are detected.
That patch is present in the tested origin/2.0 worktree and works at the detector level:
However, the Admin2/API page save flow does not rely on the full blueprint validation path that executes Validation::checkSafety(). Instead, PATCH /api/v1/pages/{route} uses partial changed-field validation through validateChangedFields(). That helper calls Validation::validate() for the submitted content field, but it does not call Validation::checkSafety(), where Security::detectXss() is actually enforced against field values.
As a result, the patched detector correctly identifies the payload when invoked directly or throughbin/grav security, but the Admin2/API save path never invokes that detector before $page->save(). The payload is therefore accepted with HTTP/1.1 200 OK, written to the Markdown file, and rendered/executed later on the public page.
This was validated end-to-end against Grav 2.0.0-rc.9 with Admin2 2.0.0-rc.14 and API plugin 1.0.0-rc.14.
Vulnerability
Source -> sink path
Source - authenticated page update request
A user with
api.pages.writecan update page content through the Admin2 backend API:In-memory assignment before save
PagesController::update()copies request-controlledcontentdirectly into the page object:Relevant code:
user/plugins/api/classes/Api/Controllers/PagesController.php:480-482
Partial validation path omits XSS safety check
Before saving,
PagesController::update()calls:validatePageChanges()maps the submittedcontentfield into a$changesarray and delegates tovalidateChangedFields():validateChangedFields()then callsValidation::validate($value, $field)for each changed field. It does not callValidation::checkSafety($value, $field), which is the method that invokesSecurity::detectXss()for field values.Relevant code:
user/plugins/api/classes/Api/Controllers/PagesController.php:537-542user/plugins/api/classes/Api/Controllers/PagesController.php:2275-2292user/plugins/api/classes/Api/Controllers/AbstractApiController.php:193-219system/src/Grav/Common/Data/Validation.php:108-152
Existing XSS detector is present but not enforced in this save path
The tested 2.0 branch contains the fixed
on_eventsdetector:The page blueprint also includes an
xss_checkfield:However, the Admin2/API partial validation path does not execute the XSS safety check before persisting page content.
Relevant code:
system/src/Grav/Common/Security.php:232-242system/blueprints/pages/default.yaml:23-36
Public rendering sink
After the page is saved, the public frontend renders the stored Markdown as HTML. The payload appears in the response as:
The browser executes the
onerrorhandler when the broken image loads.
Impact
An authenticated page editor can store JavaScript in a published page. The payload executes for any user who opens the affected page, including unauthenticated visitors and authenticated administrators.
Potential impact includes:
Executing arbitrary JavaScript in the trusted site origin.
Performing same-origin requests using the victim's browser session.
Modifying page content or administrative state if the victim has sufficient permissions and the targeted endpoints accept same-origin authenticated requests.
Reading same-origin data accessible to the victim's browser context.
HttpOnly cookies reduce direct cookie theft but do not prevent the injected JavaScript from sending authenticated same-origin requests through the victim browser.
Severity according to Grav trust-boundary guidelines
Under Grav's security-severity policy, this should be treated as a cross-trust-boundary issue rather than as expected publisher behavior.
The actor who stores the payload is a non-superadmin page editor with api.pages.write. That role is trusted to edit page content, but it is not trusted to execute arbitrary JavaScript in other users' browser sessions. Once the payload is published, the code executes for any viewer of the page. If a super-admin or administrator views the affected page while authenticated to the same Grav origin, the lower-privilege editor's stored content runs inside that higher-privilege browser session.
PoC
Preconditions
Grav 2.0.0-rc.9 running locally.
Admin2 2.0.0-rc.14 and API plugin 1.0.0-rc.14 installed.
Admin2 route available at
/admin.API prefix configured as
/api/v1.A non-superadmin user with
api.pages.readandapi.pages.write.
Step 1 - Authenticate to the API
Expected result:
API returns an access token.
The user is not a super administrator.
Step 2 - Get the page content
Expected result:
API returns the page object and raw Markdown
content.
Step 3 - Store the XSS payload
Send a partial page update with attacker-controlled Markdown content:
Expected result:
Vulnerable behavior:
HTTP/1.1 200 OK.The payload is written to the page file.
No
422 Unprocessable EntityXSS validation error is returned.
Step 4 - Verify persistence
Observed result:
Step 5 - Verify public rendering
Observed result:
Step 6 - Verify JavaScript execution
Open the public page in a browser:
Expected result:
The browser executes the stored
onerrorhandler and displaysalert(1).
Evidence of Exploitation
Video of exploitation:
Our security policy
We have reserved the ID CVE-2026-11982 to refer to this issue from now on.
System Information
Grav CMS
Version: 2.0.0-rc.9
Admin2 plugin: 2.0.0-rc.14
API plugin: 1.0.0-rc.14
Operating System: Any
References
Github Repository: https://github.com/getgrav/grav-plugin-api
Security: https://github.com/getgrav/grav/security
Vendor Advisory: https://github.com/getgrav/grav/security/advisories/GHSA-5wc5-7v9g-f7v6
Patch: https://github.com/getgrav/grav-plugin-api/commit/b8ca62eddb7dbea92075a78b1c0a507f03d66d4a
Mitigation
An updated version of grav-plugin-api is available at the vendor page.
Credits
The vulnerability was discovered by Santiago Alvarez from Fluid Attacks' Offensive Team using the AI SAST Scanner.
Timeline
Vulnerability discovered
Vendor contacted
Vendor replied
Vendor confirmed
Vulnerability patched
Public disclosure
Does your application use this vulnerable software?
During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.













