Report privately through GitHub: Open a security advisory
Please do not open a public issue for a vulnerability.
Include what you have: the input, the rendered output you expected, the output you got, and the crate version. A failing test is the fastest possible report.
You should get an acknowledgement within a few days. If a fix is warranted it ships in a patch release, and the advisory is published once users have had a chance to upgrade.
The latest released version. There are no long-term support branches. When 2.0.0 ships, 1.x
stops receiving fixes, as 0.1.x did at 1.0.0: MIGRATION.md is the way
forward, and several of the fixes below changed output or behaviour, so they are not
backported. The 2.0.0 ones are the ownership check in clean() and a Debug that no longer
aborts on a deep tree.
Molt generates strings and writes files, so most categories do not apply. The ones that do,
and where each is enforced (the full table of inputs is in
.claude/skills/molt/references/security-owasp.md):
| category | in Molt | enforced by |
|---|---|---|
| A01 Broken Access Control | path traversal and destructive operations in StaticSiteGenerator |
component whitelist, symlink refusal, a pure clean() guard, and the .molt-output marker clean() requires |
| A03 Injection | text, attribute values, names, comments, URLs, JSON and XML reaching the output | escaping on entry, write-time validation, the URL policy, escape_json_script, escape_xml |
| A04 Insecure Design | an unsafe call that is only discouraged in a comment | types that cannot be built wrongly (Markup, ChangeFreq, private indent) |
| A05 Security Misconfiguration | CI and release | pinned actions, read-only token, a release gated on main and green CI |
| A06 Vulnerable Components | the dependency graph | cargo deny, Cargo.lock committed, optional runtime dependencies only |
| A08 Integrity Failures | half-written files, the release pipeline | atomic writes with unique temporaries; verify and publish in separate jobs |
| A09 Logging Failures | not applicable: the library does not log | — |
| A02, A07, A10 | no cryptography, authentication or network access | — |
This crate's security surface is narrow and specific: it generates HTML strings, and its one safety guarantee is that text and attribute values you pass in cannot become markup.
These are vulnerabilities:
- Any input to
text(),Attribute::new,Element::attr, or the escaping functions that survives into the output as executable markup or as an attribute that breaks out of its quoting. An attribute name containing a space, quote,=,<,>,/or a control character is omitted rather than written. - A class, id, style or
data-*value that can inject an additional attribute. - An attribute value that ends a raw-text ancestor (
noscript,iframe,xmp,noembed,noframes,script,style,plaintext) early.escape_attributeleaves<and>alone, since neither can end a quoted value, but the renderer writes them as<and>for every attribute under one of those ancestors; the fixtures are unaffected. Node::commentcontent that can terminate the comment early.- A tag name that survives into the output with a space, quote,
<,>or/. A tag that is not a letter followed by letters, digits,-,_,.or:is not rendered, children included — seemolt::core::tags::is_valid_tag_name. This coversElement::new,new WElement(tag)andelement(tag)in WASM. StaticSiteGeneratorwriting outside its output directory (a..component, a root or drive prefix, or an existing symbolic link on the way), orclean()deleting outside it.clean()refuses., the working directory and its ancestors, the home directory, and top-level directories such as/usr. Since 2.0.0 it also refuses a symbolic link and any directory that has contents but no.molt-outputmarker, because a relative output path is resolved against the current directory of the process, which may belong to another project. A symlink created while a build runs is a race this crate does not close.
The writer walks an explicit work stack instead of recursing, and Element tears its
subtree down the same way, so nesting depth costs heap — bounded by a tree that already
fits in memory — rather than stack. A 100,000-level tree renders and is freed in the test
suite, in both render modes.
Before this, a deep enough tree exhausted the stack, and a stack overflow is a process
abort, not a catchable panic — catch_unwind would not have saved you. That made
nesting depth a denial-of-service vector anywhere it could be influenced by untrusted
input. Fixed before the first release under the name Molt.
What deep trees still cost is output size. Pretty mode writes one indent string per
level on every line, so output grows with the square of the depth: a 100,000-level tree
pretty-prints to tens of gigabytes. If depth comes from untrusted input, bound it, or
render compact, set an empty indent with
RenderOptions::pretty().with_indent(""), or bound it with
RenderOptions::with_max_depth(512), which drops elements nested deeper than that (browsers
flatten beyond roughly 512 anyway). The indent is also restricted: with_indent keeps only
spaces and tabs, at most 16, because it is written at the start of every line and a string
with markup in it (renderWithIndent(untrusted) from JavaScript) would put that markup on
every line.
Document::render, audit, Clone and PartialEq are iterative as well, so a document, an
accessibility audit, a copy or a comparison of a 100,000-level tree is safe. So is Debug
since 2.0.0: {:?} (and the failure message of assert_eq!) writes the first 128 levels and
... below them. Before 2.0.0 it recursed once per level and aborted the process on a tree
that deep. The text is for people to read and is not a format.
One residual, and it is not a stack limit on rendering: a bare Node::Fragment chain with
no element anywhere in it is still freed recursively. Giving Node its own Drop would
forbid match node { Node::Element(element) => element } — a partial move this crate's own
static_site example performs — so the iterative teardown lives on Element. Every
fragment reachable through an element, which is every fragment in a document, is freed
iteratively.
These are not vulnerabilities, because they are documented behaviour:
raw_text()andNode::Rawnot escaping their input. That is their entire purpose; they are named and documented so that an audit can grep for them.<script>and<style>content not being escaped by default. For JSON data in a<script>(JSON-LD, a data block) useseo::json_ldorseo::script_json, which write<,>and&as JSON escapes so a</script>in a value cannot end the element. Untrusted text in script code has no safe form; do not put it there.Attribute::rawnot escaping. It exists for library-controlled keys. It does not skip the URL check below: a rawhref="javascript:..."is still rendered ashref="#".
escape_xml drops the characters XML 1.0 cannot represent (U+0000–U+0008, U+000B, U+000C,
U+000E–U+001F, U+FFFE, U+FFFF), because one of them makes the whole document ill-formed and a
reader then rejects every entry. A sitemap <loc> and an RSS <link> that are not absolute
http(s) URLs are left out, since feed readers and crawlers present them as something to
open; isPermaLink="true" is written only for an http(s) guid. The sitemap protocol's
50,000-URL and 50 MiB limits are exposed as MAX_URLS/MAX_BYTES with
SitemapGenerator::chunks, not enforced silently.
href, src, action, formaction, poster, cite, background, xlink:href and
data are rendered as # when the value, after one character-reference decode and after
dropping characters at or below U+0020, starts with javascript:, vbscript: or
data:text/html (ASCII case-insensitive). data:image/... is left alone, and so is a
non-URL attribute such as title. Comparison is on the stored value, so a value that went
through Attribute::new has already turned & into & and is not decoded back into a
scheme.
Also checked: ping, manifest and longdesc, and srcset, whose comma-separated
candidates are each checked (a javascript: URL in any one blanks the attribute). On
iframe, frame, embed and object, a src or data that starts with data: is
blocked unless it is a raster image (png, jpeg, gif, webp, avif, bmp): those
elements load a document, and data:image/svg+xml or data:application/xhtml+xml run script
in it. The same SVG as an <img> source is inert and is left alone. srcdoc is not a URL but
a whole HTML document the frame runs; it is written empty for any non-empty value.
Attribute::trusted_url and Element::trusted_url (and trustedUrl on the WASM element)
skip that check, srcdoc included. The value is still attribute-escaped. Use them only for a
URL, or a srcdoc document, you wrote.
Not checked: <meta http-equiv="refresh" content="0;url=javascript:…">. The HTML
specification tells the browser to ignore a javascript: URL there, and checking it would
need the element's other attributes. Event-handler attributes (on*) and style set through
attr() are not filtered either: do not map untrusted keys into attr().
The design, and the work that is deliberately not in this crate, is in docs/plans/2026-10-17-seguranca-e-performance.md.
What the workflows do, and what only the repository owner can switch on.
- Every third-party action is pinned to a commit SHA with its version in a comment, and
Dependabot's
github-actionsecosystem keeps the pins current. A tag such as@v3can be moved by whoever controls the action's repository; a SHA cannot. ci.ymliscontents: read. Jobs have atimeout-minutes. Checkouts usepersist-credentials: false, so the token is not left in.git/configfor later steps.- The release workflow refuses a tag whose commit is not on
main, or on which the CI workflow has not already succeeded. The publish job names thereleaseenvironment. - Publishing uses crates.io Trusted Publishing: the publish job requests an OIDC token
from GitHub (
id-token: write), andrust-lang/crates-io-auth-actionexchanges it for a crates.io token that is valid for that job only and revoked when it ends. crates.io accepts it only from this repository, this workflow file and thereleaseenvironment. No long-lived registry token is stored anywhere. scripts/verify.shwrites its log to amktempfile, not a fixed path in/tmp.
Repository and registry settings, not code: crates.io is set to require Trusted
Publishing for new versions of molt-framework, so an API token cannot publish it, and the
repository holds no registry secret (CARGO_REGISTRY_TOKEN was deleted after 1.0.1, the first
release through this path). The release environment has no required reviewers: with one
maintainer that gate was a click, not a review, and the protection is the tag gate above plus
Trusted Publishing.
- Pass untrusted data through
text()andattr(), neverraw_text()orNode::Raw. - If you must inject markup you did not author, sanitize it with a real HTML sanitizer first — this crate escapes, it does not sanitize.
- Run
molt::accessibility::auditin development; several of its findings are also signs of markup being assembled by hand.