Search by

itools / htmlsecuritycheck

itools

Allowlist check for HTML that has to be printed unencoded, such as rich text from a WYSIWYG editor: rejects content that could run script, send the page's data to another host, or mislead the visitor, and never rewrites what it accepts

dev-main / 1.x-dev 2026-10-14 22:33 UTC

This package is auto-updated.

Last update: 2026-10-14 22:33:55 UTC


README

HtmlSecurityCheck: Reject HTML That Could Run Script

Rich text from a WYSIWYG editor such as TinyMCE or CKEditor is printed unencoded, so htmlspecialchars() can't protect it, and the editor's own filtering runs only in the browser, which a request sent straight to the server skips. HtmlSecurityCheck runs on the server and rejects content that could run script, send the page's data to another host, or mislead the visitor. Each error names the tag or attribute at fault.

  • It never rewrites the content. What passes is stored and shown byte for byte as submitted.
  • It reads HTML the way a browser does. Its HTML5 tokenizer is tested against html5lib's tests, and CI fails if headless Chrome finds script in any test input the check accepts.
  • A typical page checks in under 0.04 ms, about 50 to 115 times faster than HTMLPurifier on the same pages, and every real page under 100 KB we measured checks in under 1 ms (benchmarks).

Documentation

Full docs (browse on GitHub):

Quick Start

Requires PHP 8.1+. No extensions, no dependencies.

composer require itools/htmlsecuritycheck
use Itools\HtmlSecurityCheck\HtmlSecurityCheck;

// save.php: check the field, then store it as submitted
$result = HtmlSecurityCheck::check($_POST['body']);   // never throws; invalid UTF-8 is a rejection too
if (!$result->ok) {
    foreach ($result->errors as $violation) {
        echo htmlspecialchars($violation->message), "<br>";   // onclick= event handler attributes are not allowed
    }
    exit;
}

// show.php: HTML from an import, an email or an older save was never checked, so check it before printing
echo HtmlSecurityCheck::check($body)->ok ? $body : '<p>This content is hidden until its HTML is fixed.</p>';

More of the API (the AI reference has all of it). The switches are static properties, shown with their defaults; set them once at startup:

$violation->code;              // 'event-handler', stable across releases, so switch on it
$violation->detail;            // 'onclick', text from the content, so encode it before output
$violation->template;          // '%s= event handler attributes are not allowed', for translation with Violation::TEMPLATES
HtmlSecurityCheck::rules();    // the rule tables and the current switches, for settings pages and debugging

HtmlSecurityCheck::$allowForms          = false;   // <form>, <input>, <button>: a form can imitate a login box
HtmlSecurityCheck::$allowStyleAttribute = true;    // style="...", through the CSS check; styles its own element only
HtmlSecurityCheck::$allowStyleElement   = true;    // <style>, through the CSS check; can restyle the whole page
HtmlSecurityCheck::$allowEmbeds         = true;    // <iframe> whose src host is in $iframeHosts
HtmlSecurityCheck::$iframeHosts         = ['www.youtube.com', 'www.youtube-nocookie.com', 'player.vimeo.com', 'www.google.com'];
HtmlSecurityCheck::$urlSchemes          = ['http', 'https', 'mailto', 'tel'];   // add 'sms' or an app's scheme to let links open it

What It Rejects

  • Script: <script>, onerror=, javascript: and data:text/html links, <object>, <base>, and x-init=, data-* and custom elements, which the page's own JavaScript (Alpine, Stimulus, web components) would run.
  • Sending page data to another host: <link rel="stylesheet">, CSS selectors such as input[value^="a"], which a url() can use to send out what a field holds, and a tag left open at the end, which turns the page text up to the next quote into part of its URL.
  • Misleading the visitor: <meta http-equiv="refresh">, <form>, an <iframe> from a host not in $iframeHosts, and links with search-ms: or another scheme that opens a program.
  • Markup that browsers and other parsers read differently: <svg>, <template>, <noscript>, a < inside <style>, and bytes that are not UTF-8.
  • A whole pasted page: <html>, <head>, <body> and <title>.

Element names, attribute names, iframe hosts, the schemes on href, src and the other URL attributes, and CSS function names and at-rules must be on the check's allowlists, so anything it doesn't know is rejected. The one exception is a data:image/ URL on <img src>, which passes. CSS url() must be http:, https: or a URL with no scheme, and a few patterns the lists can't catch (\ escapes, behavior:, [value^=) are rejected on their own.

What It Does Not Check

  • Where you print it. A passing fragment is safe between ordinary tags such as <div> or <td>, not inside <script>, <style>, <textarea>, <title>, inline SVG or an attribute value.
  • End tags. A </div> in the content passes even when it closes one of your page's elements, so the content can end a wrapper early.
  • Everything else. Images and url() on other hosts, position: fixed overlays, invalid HTML, id or name values that match a page script's variables, and length all pass. Cap the size before the check; check time grows in step with size.

Additional Protection

Content that passes is safe to print without these. They add a second layer in case a rule is ever bypassed, and cover content the check never saw:

  • A Content Security Policy on pages that show it. script-src 'self'; object-src 'none'; base-uri 'none' runs your own script files and nothing else. To keep an inline script, add 'nonce-...' to script-src and the same nonce to the tag.
  • Turn script off in the editor. Content saved before the check existed opens there unchecked. Give the editor's frame the policy script-src 'none'; object-src 'none' (TinyMCE's content_security_policy option; iframe mode only).
  • Show rejected content without running it. Print it in <iframe sandbox srcdoc="<?= htmlspecialchars($body) ?>"></iframe>: no script, forms or popups, and no access to your page or its cookies. Images and CSS still load.

When You Might Not Want HtmlSecurityCheck

  • Visitor posts. A rejection works for staff who can fix the HTML, not for a visitor who can't. Forum posts and comments need a sanitizer such as HTMLPurifier.
  • Inline SVG or MathML. Both are rejected with no switch. Upload SVG as a file, check it with SvgSecurityCheck, and show it with <img src>.
  • Markup for Alpine, HTMX, Stimulus or web components. data-* attributes and custom elements are rejected with no switch.

Security

Element names, attribute names, iframe hosts, URL schemes and CSS function names and at-rules are checked against allowlists, so anything browsers add later is rejected until it is added here. CSS property names are not checked, so a property browsers add later passes; every property that loads something today does it through url(), limited to http:, https: or a URL with no scheme. What the rules cannot anticipate is a browser changing how it handles something already allowed. Update when a new release comes out, and report HTML that gets through as described in SECURITY.md.

Related Libraries

  • SvgSecurityCheck - checks uploaded SVG files for anything that could run script and lists each problem it finds.
  • ZenDB - injection-proof PHP/MySQL database layer with automatic XSS-safe output.
  • SmartArray - database rows as chainable collections, with fields that HTML-encode themselves on output.
  • SmartString - PHP strings that HTML-encode themselves on echo, interpolation, and concatenation.

Questions?

This library was developed for CMS Builder. Post a message in our "CMS Builder" forum here: https://www.interactivetools.com/forum/

License

MIT