GHSA-9395-2G46-RJ3F
Vulnerability from github – Published: 2026-09-17 20:31 – Updated: 2026-09-17 20:31Five independent defects in djust's template auto-escaping cause attacker-controlled input to be rendered as live markup where Django escapes it. All four are present in shipped 1.1.0 and are fixed in 1.1.1.
They share one shape: a filter or grant that escapes nothing itself and relies on the render-time auto-escape, which something downstream then removes. They are grouped into a single advisory because the mitigation is identical — upgrade to 1.1.1 — and because no single one of them is meaningfully actionable in isolation.
1. linenumbers never escaped its input (#2291)
{{ p|linenumbers|safe }} with p = '<img src=x onerror=alert(1)>'
djust '1. <img src=x onerror=alert(1)>' <- executes
django '1. <img src=x onerror=alert(1)>'
The filter deferred all escaping to render time; a trailing |safe suppressed exactly that. The exposure is wider than the |safe form: any downstream filter that reads the output as markup is affected, including {{ p|linenumbers|truncatechars_html:"5" }}, which contains no |safe at all.
2. escape was a no-op (#2281)
{{ p|escape|safe }}
djust '<img src=x onerror=alert(1)>' <- executes
django '&lt;img src=x onerror=alert(1)&gt;'
Django's escape is eager (conditional_escape, returning SafeString). djust's returned its input unchanged and let the render site escape it — indistinguishable for {{ p|escape }} alone, wrong for every chain. The security cell is {{ p|escape|safe }}: an idiom that reads as "escape it, then it is safe to emit" — which is what Django's semantics make true — was a bare |safe on attacker input. A sweep of every length-2 and length-3 chain containing escape found 104 live-markup cells.
3. unordered_list / safeseq handed a string back under a safe grant (#2274)
Both carry an unconditional "emit without escaping" grant, earned because they escape every item they emit. Given a string rather than a sequence they emitted nothing and returned the input verbatim under that same grant, making {{ hostile|safeseq }} an exact synonym for |safe with no mark_safe anywhere in the template.
4. A safety grant outlived the value it was granted for (#2300)
No filter chain and no |safe anywhere; a bare {{ p }} is the whole reproducer.
Safe context keys accumulated on the view and were never revoked, so a key marked safe once stayed safe for the lifetime of the view — which spans every event on a WebSocket connection:
render 1: p = mark_safe('<b>trusted</b>') -> '<b>trusted</b>' correct
render 2: p = '<img src=x onerror=alert(1)>' -> executes
A view that renders trusted markup into a variable and later renders user input into the same variable emits it live.
5. A custom tag handler's return was emitted raw — including djust's own {% render_slot %} (#2379)
Reachable with no |safe, no mark_safe, and no application code: using component slots is enough.
Django's SimpleNode.render runs conditional_escape over a simple_tag's return
unless it carries __html__. djust inserted the return verbatim, so a handler as
ordinary as return f"Hello {name}" emitted attacker markup live.
Of the 221 handlers djust registers, one echoes a context value unescaped —
render_slot, the framework's own function-component/slot tag:
{% render_slot p %} p = '<img src=x onerror=alert(1)>'
djust '<img src=x onerror=alert(1)>' <- executes
django '<img src=x onerror=alert(1)>'
Together with defect 3 this is one of the two classes reachable without the application writing anything unusual.
6. linebreaks / linebreaksbr — and |safe was the only spelling that worked (#2284)
linebreaks emits <p>/<br> but neither escaped its content nor reported its
output safe. The plain spelling therefore escaped the filter's own tags and
printed a literal <p> on the page, so |safe was the only form that rendered
at all — and that form emitted the content live:
{{ bio|linebreaks }} renders literal '<p>' text (visibly broken)
{{ bio|linebreaks|safe }} '<img src=x onerror=alert(1)>' <- executes
Because the broken spelling is the one a developer discards, the vulnerable spelling is the one that ships. Any application rendering user-entered text with paragraph breaks is written that way.
Impact
Stored or reflected XSS in any djust application that renders untrusted input through the affected filters, or that reuses a context variable which was previously marked safe. Exploitation requires no special configuration. Defects 3, 4 and 5 require no unusual template construct at all — defect 5 needs only that the application use component slots, and defect 6's vulnerable spelling is the only one that renders correctly.
Patches
Fixed in 1.1.1, and in 1.2.0 (main).
1.1.1 re-implements each fix against 1.1.0's own code rather than back-porting main's, which depends on a value-level safety model 1.1.0 does not have. Three consequences are documented in the 1.1.1 CHANGELOG and are all in the over-escaping direction: {{ p|escape|F }} double-escapes for a plain following filter F; {% render_slot slot.content %} over-escapes; and a mark_safed value passed through |escape is escaped rather than passed through.
Not fixed in 1.1.1, and tracked separately: application-written tag handlers that return attacker data as a plain str (only djust's own render_slot is covered by defect 5), and a {% with %}/{% for %} bind inheriting a safety grant it never earned. Both are fixed in 1.2.0.
Workarounds
None complete. Before upgrading, avoid |safe after any filter in a chain, avoid safeseq/unordered_list on values that may be strings, and avoid reusing a context variable for both mark_safe content and untrusted input.
Credit
Found during an internal Django-parity audit by a registry-wide differential that compares djust's escaping capabilities against Django's across every filter chain, rather than by inspection.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.1.0"
},
"package": {
"ecosystem": "PyPI",
"name": "djust"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.1.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-116",
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-17T20:31:43Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "Five independent defects in djust\u0027s template auto-escaping cause attacker-controlled input to be rendered as live markup where Django escapes it. All four are present in shipped 1.1.0 and are fixed in 1.1.1.\n\nThey share one shape: **a filter or grant that escapes nothing itself and relies on the render-time auto-escape, which something downstream then removes.** They are grouped into a single advisory because the mitigation is identical \u2014 upgrade to 1.1.1 \u2014 and because no single one of them is meaningfully actionable in isolation.\n\n## 1. `linenumbers` never escaped its input (#2291)\n\n```\n{{ p|linenumbers|safe }} with p = \u0027\u003cimg src=x onerror=alert(1)\u003e\u0027\n djust \u00271. \u003cimg src=x onerror=alert(1)\u003e\u0027 \u003c- executes\n django \u00271. \u0026lt;img src=x onerror=alert(1)\u0026gt;\u0027\n```\n\nThe filter deferred all escaping to render time; a trailing `|safe` suppressed exactly that. The exposure is wider than the `|safe` form: **any downstream filter that reads the output as markup is affected**, including `{{ p|linenumbers|truncatechars_html:\"5\" }}`, which contains no `|safe` at all.\n\n## 2. `escape` was a no-op (#2281)\n\n```\n{{ p|escape|safe }}\n djust \u0027\u003cimg src=x onerror=alert(1)\u003e\u0027 \u003c- executes\n django \u0027\u0026amp;lt;img src=x onerror=alert(1)\u0026amp;gt;\u0027\n```\n\nDjango\u0027s `escape` is eager (`conditional_escape`, returning `SafeString`). djust\u0027s returned its input unchanged and let the render site escape it \u2014 indistinguishable for `{{ p|escape }}` alone, wrong for every chain. The security cell is `{{ p|escape|safe }}`: an idiom that reads as \"escape it, then it is safe to emit\" \u2014 which is what Django\u0027s semantics make true \u2014 was a bare `|safe` on attacker input. A sweep of every length-2 and length-3 chain containing `escape` found **104** live-markup cells.\n\n## 3. `unordered_list` / `safeseq` handed a string back under a safe grant (#2274)\n\nBoth carry an unconditional \"emit without escaping\" grant, earned because they escape every item they emit. Given a **string** rather than a sequence they emitted nothing and returned the input verbatim under that same grant, making `{{ hostile|safeseq }}` an exact synonym for `|safe` with no `mark_safe` anywhere in the template.\n\n## 4. A safety grant outlived the value it was granted for (#2300)\n\n**No filter chain and no `|safe` anywhere; a bare `{{ p }}` is the whole reproducer.**\n\nSafe context keys accumulated on the view and were never revoked, so a key marked safe once stayed safe for the lifetime of the view \u2014 which spans **every event on a WebSocket connection**:\n\n```python\nrender 1: p = mark_safe(\u0027\u003cb\u003etrusted\u003c/b\u003e\u0027) -\u003e \u0027\u003cb\u003etrusted\u003c/b\u003e\u0027 correct\nrender 2: p = \u0027\u003cimg src=x onerror=alert(1)\u003e\u0027 -\u003e executes\n```\n\nA view that renders trusted markup into a variable and later renders user input into the same variable emits it live.\n\n## 5. A custom tag handler\u0027s return was emitted raw \u2014 including djust\u0027s own `{% render_slot %}` (#2379)\n\n**Reachable with no `|safe`, no `mark_safe`, and no application code: using component slots is enough.**\n\nDjango\u0027s `SimpleNode.render` runs `conditional_escape` over a `simple_tag`\u0027s return\nunless it carries `__html__`. djust inserted the return verbatim, so a handler as\nordinary as `return f\"Hello {name}\"` emitted attacker markup live.\n\nOf the 221 handlers djust registers, one echoes a context value unescaped \u2014\n`render_slot`, the framework\u0027s own function-component/slot tag:\n\n```\n{% render_slot p %} p = \u0027\u003cimg src=x onerror=alert(1)\u003e\u0027\n\n djust \u0027\u003cimg src=x onerror=alert(1)\u003e\u0027 \u003c- executes\n django \u0027\u0026lt;img src=x onerror=alert(1)\u0026gt;\u0027\n```\n\nTogether with defect 3 this is one of the two classes reachable without the\napplication writing anything unusual.\n\n## 6. `linebreaks` / `linebreaksbr` \u2014 and `|safe` was the only spelling that worked (#2284)\n\n`linebreaks` emits `\u003cp\u003e`/`\u003cbr\u003e` but neither escaped its content nor reported its\noutput safe. The plain spelling therefore escaped the filter\u0027s **own** tags and\nprinted a literal `\u003cp\u003e` on the page, so `|safe` was the only form that rendered\nat all \u2014 and that form emitted the content live:\n\n```\n{{ bio|linebreaks }} renders literal \u0027\u003cp\u003e\u0027 text (visibly broken)\n{{ bio|linebreaks|safe }} \u0027\u003cimg src=x onerror=alert(1)\u003e\u0027 \u003c- executes\n```\n\nBecause the broken spelling is the one a developer discards, the vulnerable\nspelling is the one that ships. Any application rendering user-entered text with\nparagraph breaks is written that way.\n\n## Impact\n\nStored or reflected XSS in any djust application that renders untrusted input through the affected filters, or that reuses a context variable which was previously marked safe. Exploitation requires no special configuration. Defects 3, 4 and 5 require no unusual template construct at all \u2014 defect 5 needs only that the application use component slots, and defect 6\u0027s vulnerable spelling is the only one that renders correctly.\n\n## Patches\n\nFixed in **1.1.1**, and in **1.2.0** (`main`).\n\n1.1.1 re-implements each fix against 1.1.0\u0027s own code rather than back-porting `main`\u0027s, which depends on a value-level safety model 1.1.0 does not have. Three consequences are documented in the 1.1.1 CHANGELOG and are all in the **over-escaping** direction: `{{ p|escape|F }}` double-escapes for a plain following filter `F`; `{% render_slot slot.content %}` over-escapes; and a `mark_safe`d value passed through `|escape` is escaped rather than passed through.\n\n**Not fixed in 1.1.1**, and tracked separately: application-written tag handlers that return attacker data as a plain `str` (only djust\u0027s own `render_slot` is covered by defect 5), and a `{% with %}`/`{% for %}` bind inheriting a safety grant it never earned. Both are fixed in 1.2.0.\n\n## Workarounds\n\nNone complete. Before upgrading, avoid `|safe` after any filter in a chain, avoid `safeseq`/`unordered_list` on values that may be strings, and avoid reusing a context variable for both `mark_safe` content and untrusted input.\n\n## Credit\n\nFound during an internal Django-parity audit by a registry-wide differential that compares djust\u0027s escaping capabilities against Django\u0027s across every filter chain, rather than by inspection.",
"id": "GHSA-9395-2g46-rj3f",
"modified": "2026-09-17T20:31:43Z",
"published": "2026-09-17T20:31:43Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/djust-org/djust/security/advisories/GHSA-9395-2g46-rj3f"
},
{
"type": "PACKAGE",
"url": "https://github.com/djust-org/djust"
},
{
"type": "WEB",
"url": "https://github.com/djust-org/djust/releases/tag/v1.1.1"
}
],
"schema_version": "1.4.0",
"severity": [],
"summary": "djust: Six template-layer defects emit attacker-controlled markup unescaped (XSS)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.