Skip to content

Bug: Security::cleanDangerousTwig() falsely blocks {{ page.header.* }} #4052

@NoNoNo

Description

@NoNoNo

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 \b matches)
  • followed by . (non-word character → word boundary \b matches)

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";

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions