Safeguard
Vulnerability Analysis•

Ammonia RUSTSEC-2026-0373: How CSS URL() Slipped Past a Rust HTML Sanitizer

Ammonia 4.2.4 and 4.1.8 fix a bug where quoted CSS URL("...") skipped the URL filtering that url("...") received. How it worked, October's two earlier CSS and SVG fixes, which builder settings are affected, why RustSec flags 3.3 anyway, and a workspace checker.

Safeguard Security Team
Security Research
15 min read

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)ReleasesChangeAdvisory
22 Jul3.3.3, 4.0.3, 4.1.4SVG animate and set could set an attribute to a javascript: URLCVE-2026-102342
3 Oct4.1.5, 4.2.1URL rewrites applied inside CSS url(); allowing <style> together with style filtering now panicsnone
11 Oct4.1.7, 4.2.3, 3.3.4Quotes escaped in CSS URLs; three more SVG animation elements handled; 3.3 refuses all animation tagsGHSA-69v2-w9vg-4g83, GHSA-f2pc-rwv3-69mv
17 Oct4.1.8, 4.2.4CSS url() matched without regard to caseGHSA-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:

AdvisoryNeedsImpact
GHSA-x43w-36pp-96g3 (URL() case)style allowed and filter_style_propertiesURL filters bypassed: tracking images, other schemes
GHSA-69v2-w9vg-4g83 (quote escaping)style allowed and filter_style_propertiesCSS injection within the style attribute
GHSA-f2pc-rwv3-69mv (SVG animation)animateMotion, animateTransform or animateColor allowedXSS 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 versionAdvisories OSV returns
3.3.4RUSTSEC-2021-0157, RUSTSEC-2021-0158, RUSTSEC-2026-0373
4.0.3the same three
4.1.6the same three
4.2.3RUSTSEC-2026-0373
4.1.8, 4.2.4none

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 audit and 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

  1. 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.
  2. Check for the <style> panic first. Coming from a version before 4.1.5 or 4.2.1, search for builders that allow the style tag and also call filter_style_properties. Stylesheets were never filtered in that setup, and newer versions panic on it. Drop the tag or drop the filter, then upgrade.
  3. 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.
  4. 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.
  5. 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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.