Skip to content

Security

Mikhail Deynekin edited this page Oct 11, 2026 · 2 revisions

Security Model

Design principle: a template is data, never code. Nothing in the render path can execute PHP, spawn a shell, or read an unsanctioned file β€” unless the host explicitly configures it, behind allow-lists, budgets and containment.

Note

Canonical spec + reporting address: docs/SECURITY.md.

Threat model at a glance

Vector Mitigation Verify it yourself
XSS via variables auto-escape ON by default, context escapers, disable-able |raw 13_security test Β· Syntax
XSS via SSI #echo escaped by default + echo_allow gate + explicit encoding= 05_ssi_core
LFI / source disclosure realpath containment, extension deny-list, dotfile ban, absolute-path ban in template data 13_security
Symlink escape realpath() resolves links before containment 13_security (symlink case)
RCE via #exec deny default Β· allow-lists Β· no shell (proc_open argv) Β· timeout/output caps Β· delegate hook 06_ssi_if_exec
RCE via expressions whitelisted functions, closed grammars, no eval anywhere; inline %{= …} reuses the same evaluator and can be gated off architecture test
Property/method access via data-controlled keys dynamic [] and get() never touch plain objects; get() resolves in the scope stack only 15_expressions_math
DoS via unbounded memos / infinite iterables AST memo is a 4 096-entry FIFO; every host-iterable walk stops at 100 000 items memory-delta + generator tests
RCE via PO headers dedicated safe plural evaluator, length cap, fail-safe form 0 09_i18n (malicious header case)
SSRF via -A/-U file tests refused by design (false + diagnostic) 06_ssi_if_exec
DoS byte/depth/row/tag/length budgets on every recursion; PCRE backtrack failures throw instead of spinning budgets test group
Cache poisoning keys = version+locale+policies+vars hash; atomic writes; hardened cache dirs 10_render_api
Cross-tenant leaks no statics in the core; engines independent (asserted) 10_render_api

1. Output escaping

  • Every %{}/#{/@{} substitution is HTML-escaped (ENT_QUOTES|ENT_HTML5|ENT_SUBSTITUTE, double_encode: false) unless the filter chain already produced safe output or explicitly says raw.
  • Context escapers: attr, url (values, not query strings β€” &/' encoded), json/js (JSON_HEX_TAG|APOS|QUOT|AMP β€” </script> breakout safe), css.
  • Rendering user-editable templates? Set the hardened trio:
'syntax' => [
    'auto_escape'      => true,   // default β€” don't turn it off
    'allow_raw_filter' => false,  // remove the only unescape hatch
    'strict'           => true,   // fail loud instead of diagnosing
],

2. Filesystem containment (traversal & source disclosure)

All include paths β€” SSI file=/virtual=, @Include, @Extends, catalogues β€” pass Io\PathGuard:

  1. NUL bytes, URL schemes and .. rejected before resolution (ssi.allow_parent_paths exists; leave it off).
  2. realpath() (collapses .., resolves symlinks) then containment against configured roots.
  3. security.denied_extensions β€” php, phar, phtml, cgi, pl, py, sh, inc, conf, env, htaccess, ini, log, sql, bak, … β€” plus all dotfiles, unconditionally. v3 would serve wp-config.php sources through #include; v4 refuses (regression-tested).
  4. Absolute paths in template data are rejected outright; only host code (renderFile) may pass absolute paths.
  5. WordPress TemplateLoader never locates .php as a template.

3. #exec β€” deny by design

'ssi' => ['exec_mode' => 'deny'],            // default: refuse + diagnose
// 'allow-list': exec_cmd_allow (realpath/basename match) + exec_cgi_extensions
// 'delegate':   fn(string $kind, array $info): bool|string  ← recommended

No shell is ever involved (argv proc_open, quote-aware tokenizer), environment is minimal CGI, timeout ≀ 60 s, output ≀ 16 MiB, control bytes stripped. In-process cgi mode captures output buffers and converts throws into diagnostics. Prefer a custom directive (DirectiveRegistry) β€” same power, testable PHP, zero shell.

4. Expression safety

Two closed interpreters (SSI string-semantics, template PHP-like): tokenizer

  • precedence parser over whitelisted grammars and functions, length caps, depth caps, memoized ASTs. matches accepts string-literal patterns only and routes through Support\Pcre, so a backtrack-limit hit is a clean no-match. Division/modulo by zero yield 0 (no fatals); v5 extends the same degradation to INF/NAN (pow, sqrt, overflowing products). Plural-form headers get their own evaluator that fails safe to form 0.

v5: inline expressions (%{= …})

v5 exposes the same evaluator inside substitution tags, which adds no new trust surface β€” everything computable inline was already computable via <!--@Set v="…"-->%{v}. The defences:

  • Explicit opt-in syntax. The = prefix is mandatory; a bare payload stays a name lookup, so no existing template can be reinterpreted and CSS/JS braces stay inert.
  • Same sandbox, same output pipeline. No eval, no dynamic calls, no writes, no I/O; results pass the unchanged filter + auto-escape chain.
  • Kill switch. syntax.expressions: false refuses evaluation entirely β€” fail-closed, tag kept visible, expr-disabled diagnostic.
  • Fail-visible errors. Lenient mode keeps a malformed tag in the output and records expr-invalid; strict mode throws. Neither half-renders.
  • Quote/bracket-aware payload splitting β€” a | inside a string literal or a bracket group cannot smuggle a filter boundary.

v5: nested access ([…], .prop, get())

Dynamic keys make lookups data-driven, so the accessor layer is hardened (Scope::index(), Scope::step(), Expression::parsePath()):

  • No objects via dynamic key. [] works on arrays, ArrayAccess and UTF-8 string offsets only. A public property or zero-arg method of a plain object stays reachable exclusively through author-written dotted paths, so request data can never become a property or method name (obj[key] with key = 'token' is a miss β€” test-enforced).
  • Key hygiene. int/string only; ≀ 256 chars; null bytes rejected; arrays/objects/null as keys are misses; every miss is null, never a warning or fatal.
  • get() parses, it does not evaluate. Its path argument must match a strict grammar (dotted word runs + integer or quoted bracket keys) and resolves only in the scope stack β€” no globals, constants, $_*, environment or filesystem fallback exists. _SERVER, ../../etc/passwd, a[, a..b and overlong paths all degrade to the caller's default.

5. Resource budgets

Budget Default Effect when exceeded
template bytes 16 MiB exception before parsing
include bytes 8 MiB include refused
SSI depth / tags 16 / 256 further includes refused + diagnostic
loop rows / depth 10 000 / 32 truncate / skip + diagnostic
partial+layout nesting 64 (+ chain ≀ 8) skip / exception
expression length 4 096 error path (false / exception)
expression nesting depth 32 syntax error (bounds index chains too)
expression AST memo (v5) 4 096 entries FIFO eviction (bounded memory)
iterable walk (v5) 100 000 items count/sum/avg/in stop at the budget
dynamic key / get() path (v5) 256 / 128 chars miss β†’ null / default
loop range span 100 000 syntax error
exec time / output 5 s / 64 KiB terminate / truncate + diagnostic
diagnostics entries 100 silently capped (bounded memory)

6. No hidden state (enforced)

The core contains no eval() and no $GLOBALS/superglobal reads β€” checked by a token_get_all() scan in the test suite on every CI run. Sanctioned edges: HtmlPhp\Input (explicit sanitising reader), the WordPress\* adapters (they exist to talk to WP's globals), and AdminPage (admin form handling). Time and request data are injected.

7. Cache & filesystem hygiene

FileCache: hashed sharded keys, atomic writes (temp + verified length + rename, mode preservation), probabilistic GC, and directory hardening β€” deny-all .htaccess + empty index.html written into the cache root. WpCache: blog-scoped object-cache groups + namespaced transients + an enumerable key registry (so clear() actually clears).

8. Error disclosure

Exceptions carry structured context for logs (template, line, directive); output gets the configured errmsg or nothing. #printenv is escaped, allow-list-gated and switchable off. The linter surfaces policy notices (ssi-exec-denied) pre-deploy rather than at runtime.

Checklist for hosts

  • auto_escape untouched (true), allow_raw_filter: false if templates are user-editable
  • exec_mode: deny (or delegate with a reviewed callable)
  • cache directory outside the docroot, or harden_directory on
  • ssi.allow_parent_paths off; roots minimal
  • bin/htmlphp lint wired into CI
  • logs shipped somewhere you actually read (log.driver)
  • diagnostics monitored (htmlphp.error event / wp htmlphp health)

Reporting

Private report: Mikhail@Deynekin.com (or GitHub private vulnerability reporting when enabled). Supported: 4.x on PHP in security support (8.3 β†’ Dec 2030, 8.4 β†’ Dec 2031, 8.5 β†’ Dec 2032, 8.6 from Nov 2026). We credit reporters in the CHANGELOG unless declined.

Clone this wiki locally