My Framework Ranked Last in the Security Tests. Here Are the 6 Holes I Fixed (Inside AngeLite, Part 3)
When I ran an attack-payload test suite against my own framework and against Preact, Vue, React and Angular, AngeLite came out as the worst of the group.
That was the useful moment: the security model had been assumed, not tested. This post is about the six gaps that test found, how each one was closed, and what AngeLite still does not protect you from. If you build a UI framework, or use one, the threat model is probably the most reusable part.
The trust model
Templates are code you wrote. The data you render is untrusted. AngeLite is designed so untrusted data can't become markup, script, or a dangerous URL by accident. Everything below follows from that sentence.
What was verified
The project ships benchmarks/security-probe.mjs, which runs the same hostile inputs against AngeLite, Preact, Vue and React (the Angular column comes from its documentation and the project's checks).
A few of the probes and the results at the time of the audit, before and after the fixes:
| Test | AngeLite (before) | AngeLite (after) | Preact | Vue | React | Angular |
|---|---|---|---|---|---|---|
| Escaped text by default | yes | yes | yes | yes | yes | yes |
| href with javascript: | let it through | unsafe: + warning | lets it through | lets it through | neutralises it | sanitises it |
| onclick as text | created the attribute | compile error | does not create it | creates the attribute | does not create it | forbids it |
| HTML in innerHTML | unsanitised | only with trustHtml() | unsanitised | unsanitised | unsanitised | sanitises |
| Expressions reach window | yes, via $el | no, by no path | n/a (JSX) | no | n/a (JSX) | no |
| Works without eval | yes | yes | yes | runtime-only build only | yes | AOT only |
| Trusted Types | no | yes (angelite policy) | no | one reference | no | built in |
The six gaps, and the fixes
javascript:URLs.[href]andhref="{{ }}"accepted them. Fix: an allow-list of schemes (http,https,mailto,tel,sms,ftp, relative) onhref,src,action,formaction,dataandxlink:href. It also ignores characters browsers drop inside a scheme (java\tscript:). Everything else becomesunsafe:<url>with a warning, andtrustUrl()is the explicit escape hatch. Test: a matrix of 12 dangerous and 13 safe URLs across 7 contexts.- Event handler attributes from data.
[attr.onclick]could create a handler. Fix: a compile-time error for[on*],[attr.on*]andon*="{{ }}", in both the browser compiler and the AOT compiler, case-insensitively. - Unsanitised
[innerHTML]. Fix: it only renders markup wrapped intrustHtml(); any other value is inserted as text and logs a warning. Sanitise first (for example with DOMPurify), then wrap. [srcdoc]and[outerHTML]. Fix: compile error.- Reaching window from an expression. This was the sneaky one. A template could do
$el.ownerDocument.defaultView.fn(). Fix: filter by value, not by name. The global object and documents are never returned to a template, however they were reached ($el.ownerDocument,$event.view,getRootNode(),composedPath()[n], a component field). DOM methods that turn text into markup or attributes (insertAdjacentHTML,setAttribute,write) are also blocked by name. Test: 8 attack paths plus events. - Styles under a strict CSP. The UI package injected
<style>without a nonce. Fix: constructable stylesheets (adoptedStyleSheets) need no nonce; where unavailable, it falls back to a<style>carrying the nonce from<meta name="csp-nonce">.
Also added: compile errors for bindings and interpolation on <script>, <style>, <base>, <link> and <meta>; a single Trusted Types policy named angelite for templates and trustHtml(); a SECURITY.md documenting the trust model and how to report issues; CodeQL with extended queries, Dependabot and npm audit in CI.
flowchart LR
D[Untrusted data] --> T{"Binding type"}
T -- "{{ }} text" --> X[Inserted as text]
T -- "href / src / ..." --> U{"Scheme allow-list"}
U -- "allowed" --> OK[Applied]
U -- "other" --> UN["unsafe:... + warning"]
T -- "[innerHTML]" --> H{"trustHtml()?"}
H -- "yes" --> OK
H -- "no" --> X
T -- "on* / srcdoc" --> CE[Compile error]
Content-Security-Policy recipe
Because nothing uses eval or new Function (not the browser build, not the precompiled runtime), AngeLite works with a strict policy:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; require-trusted-types-for 'script'; trusted-types angelite
What it does NOT protect you from
This section matters more than the table above.
- The expression sandbox is not a security boundary. A template can still call methods of its own component and of DOM elements it can reach. It's defence in depth. Never compile a template that includes user input, which is the same advice Vue gives about its runtime compiler. In production, precompile with
tools/aot.mjsso nothing is compiled in the browser. - There's no built-in HTML sanitiser. Angular has one; AngeLite requires
trustHtml()and recommends DOMPurify before wrapping. Writing a sanitiser of your own costs several KB and is easy to get wrong. - Escape hatches are yours.
trustHtml()andtrustUrl()are explicit, like Angular'sbypassSecurityTrust*. Don't wrap user input. - No external audit, no track record. The others have years of review and disclosure processes. This one has tests, CodeQL and a policy.
Lessons I'd reuse on any project
- Test against attack payloads, not intent. The first run made the framework look worst in its peer group, which is exactly what you want to learn from a test.
- Prefer compile-time errors to runtime sanitisation when a construct has no legitimate use (
on*bindings,srcdoc). - Filter by value, not by path, when the dangerous object can be reached many ways.
- Document the non-goals. A security policy that says what it doesn't cover is more trustworthy than one that claims everything.
- Make the safe thing the default and the unsafe thing greppable:
trustHtml(is easy to search for in review.
Takeaways
- Treat templates as code and data as hostile; build everything from that.
- Six concrete holes were found by testing and closed with tests of their own.
- The remaining risk (untrusted templates, no sanitiser, no audit) is documented, not hidden.
Next: how a performance budget in CI keeps all of this from making the framework slower.
Comments
No comments yet. Start the discussion.