GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-9395-2G46-RJ3F

Vulnerability from github – Published: 2026-09-17 20:31 – Updated: 2026-09-17 20:31
VLAI
Summary
djust: Six template-layer defects emit attacker-controlled markup unescaped (XSS)
Details

Five 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. &lt;img src=x onerror=alert(1)&gt;'

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  '&amp;lt;img src=x onerror=alert(1)&amp;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  '&lt;img src=x onerror=alert(1)&gt;'

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.

Show details on source website

{
  "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)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

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.


Loading…