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 architecture test
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. Division/modulo by zero yield 0 (no fatals). Plural-form headers get their own evaluator that fails safe to form 0.

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)
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