itools / htmlsecuritycheck
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
Package info
github.com/interactivetools-com/HtmlSecurityCheck
pkg:composer/itools/htmlsecuritycheck
Requires
- php: ^8.1
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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):
- Error codes and fixes - every message, and what to change in the content
- AI reference - the complete API and a summary of every rule in one file, written for AI coding assistants
- Changelog
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:anddata:text/htmllinks,<object>,<base>, andx-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 asinput[value^="a"], which aurl()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 withsearch-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: fixedoverlays, invalid HTML,idornamevalues 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-...'toscript-srcand 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'scontent_security_policyoption; 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