GHSA-9395-2g46-rj3f: djust Cross-Site Scripting Vulnerability

HIGH
CWE-116 CWE-79
Published
Advisory Summary

GHSA-9395-2g46-rj3f is a HIGH cross-site scripting vulnerability (CWE-116: Improper Encoding or Escaping of Output) affecting djust libraries in the PyPI ecosystem. The issue involves six template-layer defects emit attacker-controlled markup unescaped (xss). Remediation is available by upgrading affected packages to version 1.1.1.

GHSA-9395-2g46-rj3f at a Glance

CVE Identifier N/A
GHSA Identifier GHSA-9395-2g46-rj3f
Severity HIGH
CVSS v3.1 Base Score N/A
CWE Classification CWE-116: Improper Encoding or Escaping of Output
Affected Product djust
Package Ecosystem PyPI
Published Date
Last Updated

How to Fix GHSA-9395-2g46-rj3f

To remediate this vulnerability, upgrade all affected djust dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

djust fixed: 1.1.1
pip install djust>=1.1.1
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

pip show djust

How to Check if You Are Affected

To determine whether your Python environment is affected by GHSA-9395-2g46-rj3f, inspect your installed packages using pip:

pip show djust

Analyze the terminal output to locate any version of djust in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.1.1), your application is affected.

Note: The pip show djust command checks currently installed packages in your active Python environment.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-116: Improper Encoding or Escaping of Output, where the product prepares a structured message for communication with another component, but encoding or escaping of the data is either missing or done incorrectly. as a result, the intended structure of the message is not preserved. In djust, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

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**: ```python 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_safe`d 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.

GHSA-9395-2g46-rj3f Affected djust Versions

1 component(s)
Fixed Versions:
1.1.1
Product / Package Ecosystem Affected Versions Fixed Version
djust
PyPI
< 1.1.1
1.1.1

References

Frequently Asked Questions

What is GHSA-9395-2g46-rj3f?
GHSA-9395-2g46-rj3f is a security vulnerability in djust involving Six template-layer defects emit attacker-controlled markup unescaped (XSS) under CWE-116: Improper Encoding or Escaping of Output.
Which djust versions are affected?
Affected versions include releases prior to 1.1.1 across affected packages: djust.
What version fixes GHSA-9395-2g46-rj3f?
Upgrading to 1.1.1 (or the latest patched release for your respective branch) resolves the vulnerability.
Is djust affected?
Yes, djust is affected in releases prior to 1.1.1. Upgrade your dependency to 1.1.1 or later.
What CWE is associated with GHSA-9395-2g46-rj3f?
GHSA-9395-2g46-rj3f is classified under CWE-116: Improper Encoding or Escaping of Output (The product prepares a structured message for communication with another component, but encoding or escaping of the data is either missing or done incorrectly. As a result, the intended structure of the message is not preserved.).
How can I check PyPI dependencies for GHSA-9395-2g46-rj3f?
Run the dependency tree command: 'pip show djust'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-9395-2g46-rj3f",
  "modified" : "2026-09-17T20:31:43Z",
  "published" : "2026-09-17T20:31:43Z",
  "aliases" : [ ],
  "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.\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 — upgrade to 1.1.1 — 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 = '<img src=x onerror=alert(1)>'\n  djust   '1. <img src=x onerror=alert(1)>'      <- executes\n  django  '1. &lt;img src=x onerror=alert(1)&gt;'\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   '<img src=x onerror=alert(1)>'          <- executes\n  django  '&amp;lt;img src=x onerror=alert(1)&amp;gt;'\n```\n\nDjango'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.\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 — which spans **every event on a WebSocket connection**:\n\n```python\nrender 1:  p = mark_safe('<b>trusted</b>')     ->  '<b>trusted</b>'   correct\nrender 2:  p = '<img src=x onerror=alert(1)>'  ->  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's return was emitted raw — including djust's own `{% render_slot %}` (#2379)\n\n**Reachable with no `|safe`, no `mark_safe`, and no application code: using component slots is enough.**\n\nDjango's `SimpleNode.render` runs `conditional_escape` over a `simple_tag`'s 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 —\n`render_slot`, the framework's own function-component/slot tag:\n\n```\n{% render_slot p %}     p = '<img src=x onerror=alert(1)>'\n\n  djust   '<img src=x onerror=alert(1)>'          <- executes\n  django  '&lt;img src=x onerror=alert(1)&gt;'\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` — and `|safe` was the only spelling that worked (#2284)\n\n`linebreaks` emits `<p>`/`<br>` but neither escaped its content nor reported its\noutput safe. The plain spelling therefore escaped the filter's **own** tags and\nprinted a literal `<p>` on the page, so `|safe` was the only form that rendered\nat all — and that form emitted the content live:\n\n```\n{{ bio|linebreaks }}         renders literal '<p>' text        (visibly broken)\n{{ bio|linebreaks|safe }}    '<img src=x onerror=alert(1)>'    <- 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 — defect 5 needs only that the application use component slots, and defect 6's 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'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_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'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.\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's escaping capabilities against Django's across every filter chain, rather than by inspection.",
  "severity" : [ ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "PyPI",
      "name" : "djust"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "1.1.1"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 1.1.0"
    }
  } ],
  "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"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-116", "CWE-79" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-09-17T20:31:43Z",
    "nvd_published_at" : null
  }
}