-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Bug: Security::cleanDangerousTwig() falsely blocks {{ page.header.* }} #4052
Description
Version: Grav 1.8.0-beta.29 (latest from git)
File: system/src/Grav/Common/Security.php:336
Severity: High — breaks core Grav template syntax in page content
Description
Grav's Twig security filter Security::cleanDangerousTwig() scans page content for dangerous PHP functions. It includes header in the blocklist (line 336) to prevent HTTP header injection via {{ header('X-Evil: true') }}.
However, the regex \bheader\b also matches header when used as a property accessor in {{ page.header.myfield }} — Grav's standard, documented way to access page frontmatter variables.
Every {{ page.header.* }} or {{ header.* }} expression in page content is silently replaced with {# BLOCKED: ... #}, producing no output and no error.
Script to Reproduce
#!/usr/bin/env bash
cd /tmp
git clone --recurse-submodules --depth=1 --branch 1.8 https://github.com/getgrav/grav testcase-security-blocks-header
cd testcase-security-blocks-header
bin/grav install
cat >'user/pages/01.home/default.md' <<'EOT'
---
title: hello world
process:
twig: true
twig_first: true
---
* Expected value: "hello world"
* Actual value: "{{ page.header.title }}" (page.header.title), "{{ header.title }}" (header.title)
EOT
php -S 127.0.0.1:8080 system/router.php 2>/dev/null >/dev/null &
PID=$!
sleep 1
HTML=$(curl -s http://127.0.0.1:8080/)
kill -TERM "$PID"
wait "$PID" 2>/dev/null
grep -A 1 'Expected value' <<<"$HTML"
# Prints:
# <li>Expected value: "hello world"</li>
# <li>Actual value: "" (page.header.title), "" (header.title)</li>
grep -R -C 3 BLOCKED cache
# Prints:
# cache/twig/5b/5b847699ad60e4635a0815f1f60d09fa.php:* Actual value: \"{# BLOCKED: {{ page.header.title }} #}\" (page.header.title), \"{# BLOCKED: {{ header.title }} #}\" (header.title)Root Cause
Security.php:336:
$bad_twig_functions = [
...
// Output manipulation
'header', 'headers_sent', 'header_remove', 'http_response_code',
...
];Security.php:383-387:
$quotedFunctions = array_map(
fn($f) => '\b' . preg_quote($f, '/') . '\b',
$bad_twig_functions
);
$functionsPattern = implode('|', $quotedFunctions);
self::$dangerousTwigFunctionsPattern = '/(({{\s*|{%\s*)[^}]*?('
. $functionsPattern . ')[^}]*?(\s*}}|\s*%}))/i';For 'header', the compiled pattern is: \bheader\b
In {{ page.header.myfield }}, the word header is:
- preceded by
.(non-word character → word boundary\bmatches) - followed by
.(non-word character → word boundary\bmatches)
So \bheader\b matches, and the entire expression is wrapped in {# BLOCKED: ... #}.
Impact
{{ page.header.XYZ }} is documented Grav syntax for accessing page frontmatter:
- Grav Documentation — Custom Page Headers explicitly shows:
{{ page.header.author.name|e }} - Used in countless themes, plugins, and user sites
- The
header_var()function provides a workaround but is not the standard pattern
Scope: Affects {{ page.header.* }} in page content (.md files). Theme templates (.html.twig) are not affected because cleanDangerousTwig() only runs on page content, not on template files.
Suggested Fix
Option A — Remove 'header' from the blocklist. The PHP header() function is not a registered Twig function in Grav, so {{ header('...') }} would throw "Unknown function" before any harm could be done.
Option B — Change 'header' to 'header\s*\(' to only match function call syntax, not property access. However, this requires changing the pattern compilation logic since plain strings currently get \b...\b wrapping.
Option C — Add a negative lookbehind to the regex to exclude .header (property access):
self::$dangerousTwigFunctionsPattern = '/(({{\s*|{%\s*)[^}]*?(?<!\.)(?:' . $functionsPattern . ')[^}]*?(\s*}}|\s*%}))/i';This keeps header( calls blocked but allows page.header.XYZ.
I recommend a log entry (in development and production) + an exception (in development; at least as an option) to mitigate silent regressions through unexpected BLOCKED behaviour.
Extended Tests
<?php
require_once 'PATH_TO_A_GRAV_INSTALLATION/vendor/autoload.php';
use Grav\Common\Security;
// --- Test 1: Direct cleanDangerousTwig() behavior ---
echo "=== Test 1: cleanDangerousTwig() regex ===\n\n";
$cases = [
'{{ page.header.myfield }}' => 'page.header property access (SHOULD NOT BE BLOCKED)',
'{{ page.header.author.name|e }}' => 'page.header nested property (SHOULD NOT BE BLOCKED)',
'{{ header_var("myfield") }}' => 'header_var function call (OK)',
'{{ header.title }}' => 'header variable property access (SHOULD NOT BE BLOCKED)',
'{{ header("X-Evil: true") }}' => 'PHP header() function call (SHOULD BE BLOCKED)',
'{% set x = page.header.entries %}' => 'page.header in set tag (SHOULD NOT BE BLOCKED)',
'{{ some_var }}' => 'unrelated variable (OK)',
];
foreach ($cases as $input => $description) {
$output = Security::cleanDangerousTwig($input);
$blocked = str_contains($output, 'BLOCKED');
$status = $blocked ? 'BLOCKED' : 'OK';
$expected = str_contains($description, 'SHOULD NOT BE BLOCKED') ? 'OK' : (str_contains($description, 'SHOULD BE BLOCKED') ? 'BLOCKED' : 'OK');
$correct = ($status === $expected) ? '✓' : '✗ BUG';
echo " {$correct} {$status}: {$input}\n";
echo " {$description}\n";
if ($blocked) {
echo " Result: {$output}\n";
}
echo "\n";
}
// --- Test 2: Demonstrate the regex pattern ---
echo "=== Test 2: Regex pattern analysis ===\n\n";
$bad_functions = ['header']; // just the relevant one
$quoted = array_map(fn($f) => '\b' . preg_quote($f, '/') . '\b', $bad_functions);
$pattern = '/(({{\s*|{%\s*)[^}]*?(' . implode('|', $quoted) . ')[^}]*?(\s*}}|\s*%}))/i';
echo " Pattern: {$pattern}\n\n";
$test_strings = [
'{{ page.header.myfield }}',
'{{ header.title }}',
'{{ header("X-Evil") }}',
'{{ my_header_var }}',
'{{ page.author.name }}',
];
foreach ($test_strings as $str) {
$match = preg_match($pattern, $str, $m);
echo " " . ($match ? 'MATCH' : 'no ') . ": {$str}\n";
if ($match) {
echo " matched: {$m[0]}\n";
}
}
echo "\n=== Test 3: Compiled template output ===\n\n";
echo " When {{ page.header.myfield }} is in page content,\n";
echo " Grav compiles it to: {# BLOCKED: {{ page.header.myfield }} #}\n";
echo " This produces NO OUTPUT instead of the expected value.\n";
echo "\n";
echo " The word boundary \\b matches because '.' is not a word character:\n";
echo " page.header.myfield\n";
echo " ^^^^^^\n";
echo " \\b matches here (before '.')\n";
echo " ^ matches here (after '.')\n";