Repository navigation
Security
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.
| 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 |
- Every
%{}/#{/@{}substitution is HTML-escaped (ENT_QUOTES|ENT_HTML5|ENT_SUBSTITUTE,double_encode: false) unless the filter chain already produced safe output or explicitly saysraw. - 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
],All include paths β SSI file=/virtual=, @Include, @Extends,
catalogues β pass Io\PathGuard:
- NUL bytes, URL schemes and
..rejected before resolution (ssi.allow_parent_pathsexists; leave it off). -
realpath()(collapses.., resolves symlinks) then containment against configured roots. -
security.denied_extensionsβ php, phar, phtml, cgi, pl, py, sh, inc, conf, env, htaccess, ini, log, sql, bak, β¦ β plus all dotfiles, unconditionally. v3 would servewp-config.phpsources through#include; v4 refuses (regression-tested). - Absolute paths in template data are rejected outright; only host code
(
renderFile) may pass absolute paths. - WordPress
TemplateLoadernever locates.phpas a template.
'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 β recommendedNo 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.
Two closed interpreters (SSI string-semantics, template PHP-like): tokenizer
- precedence parser over whitelisted grammars and functions, length caps,
depth caps, memoized ASTs.
matchesaccepts string-literal patterns only and routes throughSupport\Pcre, so a backtrack-limit hit is a clean no-match. Division/modulo by zero yield0(no fatals); v5 extends the same degradation toINF/NAN(pow,sqrt, overflowing products). Plural-form headers get their own evaluator that fails safe to form0.
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: falserefuses evaluation entirely β fail-closed, tag kept visible,expr-disableddiagnostic. -
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.
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,ArrayAccessand 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]withkey = 'token'is a miss β test-enforced). -
Key hygiene.
int/stringonly; β€ 256 chars; null bytes rejected; arrays/objects/nullas keys are misses; every miss isnull, 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..band overlong paths all degrade to the caller's default.
| 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) |
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.
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).
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.
-
auto_escapeuntouched (true),allow_raw_filter: falseif templates are user-editable -
exec_mode: deny(ordelegatewith a reviewed callable) - cache directory outside the docroot, or
harden_directoryon -
ssi.allow_parent_pathsoff; roots minimal -
bin/htmlphp lintwired into CI - logs shipped somewhere you actually read (
log.driver) - diagnostics monitored (
htmlphp.errorevent /wp htmlphp health)
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.
HTML.PHP 5.0.0 Β· MIT Β© Mikhail Deynekin Β· Repository Β· Releases Β· Report a security issue
Wiki pages mirror docs/ β found a mistake? Edit the page or open an issue.
π Home Problem Domain Getting Started
Template Syntax Expressions & Math SSI Guide I18n Guide WordPress Integration CLI
Configuration API Cheatsheet Diagnostics & Troubleshooting
Architecture Security Model Testing & CI
πΊοΈ ROADMAP Migration 4.x β 5.0 Migration 3.x β 4.0 Contributing Repository β