Ammonia, the Rust HTML sanitizer, shipped 4.2.4 and 4.1.8 on 17 October 2026 to fix a bug where CSS url("...") got URL filtering and URL("...") didn't. It's the third round of CSS fixes in the library this month, two of the advisories carry 2021 IDs because their date field says 2021, and RustSec's records flag the 3.3 line, which never had the feature.
This post is for Rust teams that sanitize untrusted HTML with ammonia, and for anyone who triages cargo audit output. It covers how the case bug worked, the two fixes that came before it, which configurations are actually affected, why scanners disagree with the maintainer about version ranges, and a script that checks a workspace's lockfiles and builder settings.
The short answer
RUSTSEC-2026-0373 (GHSA-x43w-36pp-96g3) affects ammonia 4.1.0 to 4.1.7 and 4.2.0 to 4.2.3, but only when an application allows the style attribute and calls filter_style_properties. In that setup, a quoted CSS URL written as URL("...") skipped the scheme and relative-URL checks that url("...") received, which lets an attacker add tracking images. It doesn't allow XSS. Upgrade to 4.2.4 or 4.1.8. The default ammonia::clean() isn't affected by this or by the two CSS and SVG advisories fixed on 11 October. RustSec's records list every version before the fixes, including 3.3.x and 4.0.x, which have no CSS filtering at all.
The fixes since July, in order
Ammonia added sanitizing for the style attribute in 4.1.0, released on 21 April 2025. Before that, apps could allow or strip style, but not filter what was inside it. The fixes since July all touch that code or SVG animation:
| Date (2026) | Releases | Change | Advisory |
|---|---|---|---|
| 22 Jul | 3.3.3, 4.0.3, 4.1.4 | SVG animate and set could set an attribute to a javascript: URL | CVE-2026-102342 |
| 3 Oct | 4.1.5, 4.2.1 | URL rewrites applied inside CSS url(); allowing <style> together with style filtering now panics | none |
| 11 Oct | 4.1.7, 4.2.3, 3.3.4 | Quotes escaped in CSS URLs; three more SVG animation elements handled; 3.3 refuses all animation tags | GHSA-69v2-w9vg-4g83, GHSA-f2pc-rwv3-69mv |
| 17 Oct | 4.1.8, 4.2.4 | CSS url() matched without regard to case | GHSA-x43w-36pp-96g3 |
4.1.6 and 4.2.2 also came out on 11 October with an incomplete fix and were replaced the same day. The 3 October commit notes that its URL bug "can't be used for XSS, but it can be used to leak metadata". The changelog lists the stylesheet change as a security fix, but neither GitHub nor RustSec has an advisory for it.
The reporters were Uttej Badwane (quote escaping), ucsurf (SVG animation), and Louis Li (the URL() case bug). Maintainer Michael Howell opened the pull request for the case fix at 03:22 UTC on 17 October, and both releases were on crates.io by 03:46.
How URL() got past the filter
CSS treats function names as ASCII case-insensitive, and the tokenizer has a special rule for url(. Section 4.3.4 of CSS Syntax Level 3 says that when an identifier matches url in any case and is followed by (, the result depends on what comes next:
- an unquoted argument becomes a url token, which the parser already normalizes;
- a quoted argument becomes an ordinary function token whose name keeps the author's spelling.
Ammonia handled the first case correctly. For the second, it compared the function name to "url" exactly, so URL("https://tracker.example/p.gif") was treated as some other function and its argument was never checked. The code change, in src/style.rs, is one line:
- Function(name) if *name == "url" => {
+ Function(name) if name.eq_ignore_ascii_case("url") => {
A cut-down model of the tokenizer shows the same behavior. The allowed scheme is cid: only, as in the advisory's example:
#!/usr/bin/env python3
"""Why URL("...") slipped past a filter that caught url("...").
A cut-down model of CSS Syntax Level 3, section 4.3.4 (consume an
ident-like token): an identifier followed by "(" becomes a function token,
except that url( with an UNQUOTED argument becomes a url token. The
function token keeps the author's spelling, so the name check has to
ignore ASCII case."""
import re
IDENT_PAREN = re.compile(r"([A-Za-z-]+)\(\s*")
def tokens(css):
"""Yield (kind, name, argument) for each function-like value."""
for m in IDENT_PAREN.finditer(css):
name, rest = m.group(1), css[m.end():]
if name.lower() == "url" and rest[:1] not in ('"', "'"):
yield "url-token", "url", rest.split(")", 1)[0]
else:
yield "function", name, rest.split(")", 1)[0].strip("\"'")
def allowed(url, schemes=("cid",)):
return url.split(":", 1)[0].lower() in schemes
def filter_urls(css, case_sensitive):
verdicts = []
for kind, name, arg in tokens(css):
is_url = name == "url" if case_sensitive else name.lower() == "url"
if kind == "url-token" or is_url:
verdicts.append((kind, name, arg, "kept" if allowed(arg) else "removed"))
else:
verdicts.append((kind, name, arg, "not checked"))
return verdicts
SAMPLES = [
'background-image:url(https://tracker.example/p.gif)',
'background-image:URL(https://tracker.example/p.gif)',
'background-image:url("https://tracker.example/p.gif")',
'background-image:URL("https://tracker.example/p.gif")',
'background-image:url("cid:logo")',
]
for case_sensitive in (True, False):
print("case-sensitive name check" if case_sensitive else "ASCII case-insensitive name check")
for css in SAMPLES:
for kind, name, arg, verdict in filter_urls(css, case_sensitive):
print(f" {kind:9} {name}({arg}) -> {verdict}")
case-sensitive name check
url-token url(https://tracker.example/p.gif) -> removed
url-token url(https://tracker.example/p.gif) -> removed
function url(https://tracker.example/p.gif) -> removed
function URL(https://tracker.example/p.gif) -> not checked
function url(cid:logo) -> kept
ASCII case-insensitive name check
url-token url(https://tracker.example/p.gif) -> removed
url-token url(https://tracker.example/p.gif) -> removed
function url(https://tracker.example/p.gif) -> removed
function URL(https://tracker.example/p.gif) -> removed
function url(cid:logo) -> kept
Only the quoted, upper-case form gets through with the case-sensitive check. That matches the advisory, which says the bug only applies "when the inner URL is quoted".
The two fixes that came before it
Quote escaping (GHSA-69v2-w9vg-4g83). When ammonia rebuilt a filtered url("..."), it relied on the URL's own encoding to keep quotation marks out. http: URLs percent-encode a quote, but schemes like cid: don't, so a quote in the URL could close the CSS string and let the rest of the attribute through as new CSS. That's CSS injection inside the element's style. 4.1.7 and 4.2.3 escape the quote themselves.
SVG animation (GHSA-f2pc-rwv3-69mv). SVG animation elements can change another attribute through attributeName, including a link's href. July's fix checked the to, from and values of animate and set as if they were that attribute. animateMotion, animateTransform and animateColor were missed, so an app that allowed them could end up with a javascript: link. None of these tags is allowed by default.
On the 3.3 line, the maintainer took a different route. Version 3.3.3 made cleaning panic if animate or set was allowed, and 3.3.4 adds the other three elements to that list.
Who is actually affected
Each advisory needs a specific builder setup. If your code only calls ammonia::clean() or a builder that doesn't touch these settings, none of them applies:
| Advisory | Needs | Impact |
|---|---|---|
GHSA-x43w-36pp-96g3 (URL() case) | style allowed and filter_style_properties | URL filters bypassed: tracking images, other schemes |
| GHSA-69v2-w9vg-4g83 (quote escaping) | style allowed and filter_style_properties | CSS injection within the style attribute |
| GHSA-f2pc-rwv3-69mv (SVG animation) | animateMotion, animateTransform or animateColor allowed | XSS through a javascript: link the user clicks |
The maintainer rated all three medium. The common thread is that filtering CSS is a much larger job than filtering HTML attributes. CSS has its own tokenizer, escaping rules and case rules, and a sanitizer has to model them all to make an allow-list mean what it says.
Why scanners disagree on the version ranges
The maintainer's GitHub advisories say each bug affects 4.1.0 and later, which fits: the CSS code arrived in 4.1.0. RustSec's records list only the patched versions and no unaffected range, so every earlier version counts as affected. We asked OSV, which publishes RustSec's data, which advisories apply to each version on 19 October:
| ammonia version | Advisories OSV returns |
|---|---|
| 3.3.4 | RUSTSEC-2021-0157, RUSTSEC-2021-0158, RUSTSEC-2026-0373 |
| 4.0.3 | the same three |
| 4.1.6 | the same three |
| 4.2.3 | RUSTSEC-2026-0373 |
| 4.1.8, 4.2.4 | none |
For 3.3.4 that's three findings with no 3.3 fix to move to: two about CSS filtering the version doesn't do, and one about SVG tags 3.3.4 already refuses. Anyone on 3.3.0 to 3.3.3 should move to 3.3.4 first. 4.0.x is different. It also lacks the CSS filtering, but the maintainer stopped backporting security fixes to 4.0 with 4.2.0, so moving to 4.1 or 4.2 is the right answer there anyway.
Two more differences are worth knowing:
- Two of the IDs are dated 2021. RUSTSEC-2021-0157 and RUSTSEC-2021-0158 have
date = "2021-10-10"in the advisory files, although they were added to the database on 12 and 13 October 2026. RustSec IDs take their year from that date, and OSV shows both as published on 10 October 2021. A feed or dashboard that sorts advisories by publication date, or alerts only on recent ones, will file them five years in the past. - GitHub hasn't reviewed them yet. The three repository advisories are published on ammonia's GitHub page, but on 19 October none was in GitHub's reviewed Advisory Database, which is what Dependabot alerts read.
cargo auditand OSV-based tools report them; Dependabot doesn't. We covered that gap for crates.io in general in cargo audit vs Dependabot.
Check a workspace
This script finds every Cargo.lock under a directory, reads the ammonia versions, and looks through the .rs files for the settings each bug needs. It uses the maintainer's ranges and says when a finding comes only from RustSec's wider range. It also warns about the <style> combination that panics on newer versions. Standard library only:
#!/usr/bin/env python3
"""Check a Rust workspace against ammonia's October 2026 advisories.
Reads every Cargo.lock under the given directory for ammonia versions, and
the .rs files for the builder settings each bug needs. Ranges are the ones
in ammonia's own GitHub advisories; RustSec's records list every older
version too, so this script also says when a version is listed only there.
Usage: python3 ammonia_check.py path/to/workspace"""
import pathlib
import re
import sys
LOCK_ENTRY = re.compile(r'name = "ammonia"\nversion = "([^"]+)"')
STYLE_FILTER = re.compile(r"\bfilter_style_properties\s*\(")
STYLE_ALLOWED = re.compile(r'(generic_attributes|tag_attributes)\s*\([^;]*?"style"', re.S)
STYLE_TAG = re.compile(r'\b(add_tags|tags)\s*\([^;]*?"style"', re.S)
ANIMATION_TAGS = re.compile(r'"(animateMotion|animateTransform|animateColor)"')
# (RustSec ID, GHSA ID, what it is, ammonia's affected ranges, what the bug needs)
ADVISORIES = [
("RUSTSEC-2021-0157", "GHSA-69v2-w9vg-4g83", "quotes not escaped in CSS url()",
[((4, 1, 0), (4, 1, 7)), ((4, 2, 0), (4, 2, 3))], "css"),
("RUSTSEC-2021-0158", "GHSA-f2pc-rwv3-69mv", "SVG animateMotion/animateTransform/animateColor",
[((4, 1, 0), (4, 1, 7)), ((4, 2, 0), (4, 2, 3))], "svg"),
("RUSTSEC-2026-0373", "GHSA-x43w-36pp-96g3", "CSS URL() matched case-sensitively",
[((4, 1, 0), (4, 1, 8)), ((4, 2, 0), (4, 2, 4))], "css"),
]
def vtuple(v):
return tuple(int(x) for x in re.findall(r"\d+", v.split("-")[0])[:3])
def in_ranges(v, ranges):
return any(lo <= v < hi for lo, hi in ranges)
def settings(root):
found = {"css": [], "svg": [], "panic": []}
for path in root.rglob("*.rs"):
if "target" in path.parts:
continue
text = path.read_text(encoding="utf-8", errors="replace")
if STYLE_FILTER.search(text) and STYLE_ALLOWED.search(text):
found["css"].append(str(path.relative_to(root)))
if STYLE_FILTER.search(text) and STYLE_TAG.search(text):
found["panic"].append(str(path.relative_to(root)))
if ANIMATION_TAGS.search(text):
found["svg"].append(str(path.relative_to(root)))
return found
def main(root):
root = pathlib.Path(root)
uses = settings(root)
exit_code = 0
for lock in sorted(root.rglob("Cargo.lock")):
for version in sorted(set(LOCK_ENTRY.findall(lock.read_text(encoding="utf-8")))):
v = vtuple(version)
print(f"{lock.relative_to(root)}: ammonia {version}")
if (4, 0, 0) <= v < (4, 1, 0):
print(" 4.0 no longer gets security fixes (see the 4.2.0 changelog); move to 4.1.8 or 4.2.4")
continue
if v < (3, 3, 4):
print(" older than 3.3.4: move to at least 3.3.4, which refuses SVG animation tags (or to 4.2.4)")
for rustsec, ghsa, what, ranges, needs in ADVISORIES:
(_, fix41), (_, fix42) = ranges
fixed = f"{'.'.join(map(str, fix41))} or {'.'.join(map(str, fix42))}"
if v >= fix42 or fix41 <= v < (4, 2, 0):
continue
if not in_ranges(v, ranges):
why = ("CSS filtering arrived in 4.1.0" if needs == "css"
else "on 3.3, version 3.3.4 refuses these tags")
print(f" {rustsec}: listed by RustSec only; ammonia's {ghsa} starts at 4.1.0 ({why})")
continue
if uses[needs]:
exit_code = 1
print(f" {rustsec} ({what}): AFFECTED, settings in {', '.join(uses[needs])}; upgrade to {fixed}")
else:
print(f" {rustsec} ({what}): in range, settings not found; upgrade to {fixed} anyway")
if uses["panic"]:
print("Warning: <style> tags allowed together with filter_style_properties in "
f"{', '.join(uses['panic'])}. Stylesheets were never filtered, and 4.1.5 and "
"4.2.1 onward panic on this combination, so fix the builder before upgrading.")
if not uses["css"] and not uses["svg"]:
print("No style filtering or SVG animation tags found in the .rs files.")
return exit_code
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "."))
Run against four small example workspaces:
$ python3 ammonia_check.py webmail
Cargo.lock: ammonia 4.2.3
RUSTSEC-2026-0373 (CSS URL() matched case-sensitively): AFFECTED, settings in src/render.rs; upgrade to 4.1.8 or 4.2.4
exit=1
$ python3 ammonia_check.py docs-site
Cargo.lock: ammonia 4.1.6
RUSTSEC-2021-0157 (quotes not escaped in CSS url()): in range, settings not found; upgrade to 4.1.7 or 4.2.3 anyway
RUSTSEC-2021-0158 (SVG animateMotion/animateTransform/animateColor): in range, settings not found; upgrade to 4.1.7 or 4.2.3 anyway
RUSTSEC-2026-0373 (CSS URL() matched case-sensitively): in range, settings not found; upgrade to 4.1.8 or 4.2.4 anyway
No style filtering or SVG animation tags found in the .rs files.
exit=0
$ python3 ammonia_check.py legacy
Cargo.lock: ammonia 3.3.4
RUSTSEC-2021-0157: listed by RustSec only; ammonia's GHSA-69v2-w9vg-4g83 starts at 4.1.0 (CSS filtering arrived in 4.1.0)
RUSTSEC-2021-0158: listed by RustSec only; ammonia's GHSA-f2pc-rwv3-69mv starts at 4.1.0 (on 3.3, version 3.3.4 refuses these tags)
RUSTSEC-2026-0373: listed by RustSec only; ammonia's GHSA-x43w-36pp-96g3 starts at 4.1.0 (CSS filtering arrived in 4.1.0)
No style filtering or SVG animation tags found in the .rs files.
exit=0
$ python3 ammonia_check.py newsletter
Cargo.lock: ammonia 4.2.0
RUSTSEC-2021-0157 (quotes not escaped in CSS url()): AFFECTED, settings in src/template.rs; upgrade to 4.1.7 or 4.2.3
RUSTSEC-2021-0158 (SVG animateMotion/animateTransform/animateColor): in range, settings not found; upgrade to 4.1.7 or 4.2.3 anyway
RUSTSEC-2026-0373 (CSS URL() matched case-sensitively): AFFECTED, settings in src/template.rs; upgrade to 4.1.8 or 4.2.4
Warning: <style> tags allowed together with filter_style_properties in src/template.rs. Stylesheets were never filtered, and 4.1.5 and 4.2.1 onward panic on this combination, so fix the builder before upgrading.
exit=1
The settings search is a text match, so treat "not found" as a prompt to look at how the builder is assembled, especially if options come from configuration rather than code.
Working out whether your code can reach the vulnerable path is the same question for any finding. Here's Safeguard's interactive walkthrough of how reachability cuts scanner noise:
What to do
- Upgrade the supported lines. 4.2.x to 4.2.4, 4.1.x to 4.1.8. If you're still on 4.0, move to one of them.
- Check for the
<style>panic first. Coming from a version before 4.1.5 or 4.2.1, search for builders that allow thestyletag and also callfilter_style_properties. Stylesheets were never filtered in that setup, and newer versions panic on it. Drop the tag or drop the filter, then upgrade. - On 3.3, be on 3.3.4. It refuses all five SVG animation tags. If you don't use style filtering, which 3.3 doesn't have, the three RustSec findings don't apply. Record that with a reason, not a silent ignore. Our guide on when it's OK to ignore a cargo audit advisory covers how.
- Test your own CSS handling with mixed case. If you filter CSS anywhere else, compare function and property names case-insensitively, and include
URL(,Url(and quoted forms in your tests. - Watch the repository's advisories for the sanitizer you depend on. For these three, the repository advisories came first, and on 19 October they were still the only source besides RustSec.
The fair view
The maintainer handled this well. Every fix shipped on both 4.x lines, the advisories state exactly which builder settings are needed, and the reporters are credited. Turning a silent gap (stylesheets that were never filtered) into a panic is blunt, but it's honest: the API no longer promises something it doesn't do.
RustSec's wide ranges are the cautious default. Nobody added an unaffected field, and a database that guessed wrong in the other direction would hide real exposure. The 2021 dates are a small metadata fix. Until then, the cost lands on teams on 3.3, who get findings for code they don't have, and on anyone who trusts publication dates to tell them what's new.
Check yourself
Your app calls ammonia::clean() with no builder settings and uses 4.2.3. Is it affected by RUSTSEC-2026-0373?
No. The bug needs the style attribute to be allowed and filter_style_properties to be set, and neither is on by default. Upgrade to 4.2.4 anyway, so the finding clears and a later config change can't expose you.
Why did url("...") get filtered while URL("...") didn't?
In CSS, url( followed by a quote is tokenized as an ordinary function whose name keeps the author's spelling. Ammonia compared that name to "url" exactly, so the upper-case form looked like a different function and its argument was never checked. The fix compares case-insensitively.
cargo audit reports three ammonia advisories on 3.3.4. What's the right response?
Check whether you use features those advisories cover. 3.3 has no style filtering, and 3.3.4 refuses SVG animation tags, while the maintainer's own advisories start at 4.1.0. If that holds for your code, record each one as not affected with that reason, or plan a move to 4.2.4.