Safeguard
Application Security

CVE-2024-22195: how Jinja2's xmlattr filter opened an XSS hole

A single filter in Jinja, xmlattr, could inject arbitrary HTML attributes and slip past autoescaping entirely — fixed in Jinja 3.1.3, tracked as CVE-2024-22195.

Safeguard Research Team
Research
6 min read

On January 10, 2024, GitHub published GHSA-h5c8-rqwp-cp95, disclosing a cross-site scripting vulnerability in Jinja, the templating engine that underpins Flask, Ansible, and a large share of the Python web ecosystem. Tracked as CVE-2024-22195, the flaw lives in the xmlattr filter — a small, unglamorous utility for rendering a dictionary as a string of HTML/XML attributes — and it affects every version of Jinja before 3.1.3. The bug earned a CWE-79 classification (improper neutralization of input during web page generation) and split scorers: NIST rated it 6.1 (medium) with a changed-scope vector, while GitHub's own advisory scored it 5.4 (medium) with unchanged scope. That disagreement is itself instructive — it reflects genuine ambiguity about how much an attacker can pivot once they control an attribute value inside a template Jinja assumes is already safe. The fix shipped in Jinja 3.1.3, and Debian and Fedora both pushed downstream advisories once the maintainers merged it. What makes this CVE worth a full writeup, rather than a one-line changelog entry, is what it exposes about autoescaping itself: Jinja's escaping model was never a single uniform shield, and xmlattr sat in a blind spot that autoescaping was never designed to cover. This post walks through the mechanism, the fix, and the broader lesson for anyone shipping a templating library.

What does the xmlattr filter actually do?

The xmlattr filter takes a Python dictionary and renders it as a sequence of HTML or XML attributes — {{ {"class": "btn", "id": "submit"}|xmlattr }} produces class="btn" id="submit". It exists because Jinja's default autoescaping, enabled by Flask and most web frameworks out of the box, is built to protect text content and attribute values when they're written into a template the normal way, not to police an entire attribute string a filter assembles on the fly. The filter is common in dynamic form-rendering helpers, admin dashboards, and any code that builds HTML tags from a dictionary of user-influenced key-value pairs rather than hardcoded markup. That flexibility is exactly the surface CVE-2024-22195 exploited: because xmlattr was responsible for constructing the raw attribute syntax itself — the quotes, the equals signs, the spacing — a value that reached it without being fully neutralized could break out of the attribute it was meant to sit inside, rather than merely polluting the value of one.

Why did autoescaping fail to catch it?

Jinja's autoescaping works by treating output as HTML-unsafe by default and running it through markupsafe.escape() before insertion, but that model assumes escaping happens at the boundary where a value is placed into a fixed template structure — <div class="{{ value }}"> — where the surrounding quotes and attribute name are already static, safe markup the developer wrote. xmlattr inverts that assumption: it is generating the attribute-context syntax itself from dictionary keys and values, so escaping has to happen correctly inside the filter's own logic, not at the template's normal escape boundary. According to GitHub's advisory, the filter did not consistently neutralize input in a way that prevented arbitrary attribute injection, meaning a value routed through xmlattr could still land in the rendered HTML in a form that let it define new attributes — including event handlers — rather than staying confined to the value of the one attribute the developer intended. That's a textbook attribute-context escaping failure: the same class of gap responsible for a long history of XSS bugs across unrelated templating engines and frameworks, precisely because attribute context is the escaping mode developers most often assume "autoescaping" covers uniformly and templating engines most often implement inconsistently.

Who was actually at risk?

Any application passing user-influenced dictionary data into xmlattr — directly or through a helper library that wraps it, such as form-rendering utilities common in Flask admin panels and CMS-style tools — was exposed regardless of whether the surrounding template used {% autoescape true %} or Flask's default-on autoescaping. That's the detail that made this CVE more than a footnote: teams who had done the responsible thing, verifying autoescaping was enabled and never using |safe or Markup() carelessly, could still ship the vulnerability, because the failure was inside a filter Jinja itself provided, not in a developer's misuse of the API. The realistic exploitation path required an application to feed at least partially attacker-controlled dictionary values into xmlattr — for example, letting a user supply custom form-field attributes or metadata that later got rendered as HTML attributes — a pattern common enough in admin tooling and dynamic form generators that the CVSS score reflects meaningful, if not universal, exposure across the Jinja-using ecosystem, which includes Flask, Ansible, and countless internal Python web tools.

How was it fixed, and what should teams do now?

The Pallets team fixed the issue in Jinja 3.1.3, released alongside GHSA-h5c8-rqwp-cp95 on January 10, 2024, hardening xmlattr's internal escaping so injected values can no longer break out of the attribute syntax the filter constructs. The remediation for consumers is unglamorous but complete: upgrade to Jinja 3.1.3 or later. There's no partial mitigation worth pursuing — disabling xmlattr usage app-wide is impractical for most codebases that rely on it for dynamic attribute rendering, and there's no safe wrapping pattern documented by the advisory that avoids the bug short of the patched filter itself. Because Jinja is a transitive dependency for Flask, Ansible playbooks, and dozens of code-generation tools, many teams that never call xmlattr directly still needed to bump the pin in requirements.txt or Pipfile.lock simply because something in their dependency tree did. This is a case where a software composition analysis scan matching installed versions against the NVD record is sufficient on its own — no reachability analysis is required to know you're affected, because the fix is a version bump, not a call-graph decision about whether your code exercises the vulnerable path.

What's the broader lesson for templating engines?

The broader lesson is that "autoescaping is on" is not a single fact about an application — it's a claim scoped to specific output contexts, and every filter or helper that constructs markup syntax itself, rather than filling a value into markup the developer already wrote, needs its own escaping guarantee independently verified. This is the same category of gap that has recurred across templating ecosystems for years: HTML has at least half a dozen distinct escaping contexts — text, attribute value, attribute name, URL, JavaScript, and CSS — and a single "escape on output" toggle rarely covers all of them uniformly. Jinja's own documentation is explicit that autoescaping protects against injecting values into fixed structures, not against filters that generate structure. For engineering teams, the practical takeaway is to treat any filter, macro, or helper that builds raw markup — attribute strings, inline styles, dynamically assembled tags — as a distinct trust boundary requiring its own review, rather than assuming a framework-level autoescape flag makes every code path uniformly safe.

Never miss an update

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