# GHSA-9395-2g46-rj3f: djust: Six template-layer defects emit attacker-controlled markup unescaped (XSS)

> Attacker-controlled input is rendered as live markup due to template escaping defects, allowing cross-site scripting attacks.

- **Severity:** Not scored
- **Exploited in the wild:** Not known
- **Login needed:** Not stated
- **Attackable:** Over the internet
- **Victim action needed:** A user has to click or open something
- **Fix available:** Yes
- **Published:** 17 Sep 2026

## What is GHSA-9395-2g46-rj3f?

GHSA-9395-2g46-rj3f is a cross-site scripting vulnerability in djust, published on 17 Sep 2026. It can be exploited over the network, and only if a user opens a file or follows a link.

### Official description

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.

## Which versions are affected by GHSA-9395-2g46-rj3f?

2 open source packages ship an affected version, including djust.


| Package | Ecosystem | Affected | Fixed in |
| --- | --- | --- | --- |
| `djust` | PyPI | All versions | 1.1.1 |
| `djust` | PyPI | All versions | 1.1.1 |

## How do I fix GHSA-9395-2g46-rj3f?

Upgrade djust to 1.1.1 or later.

## Is GHSA-9395-2g46-rj3f being exploited?

There is no known exploitation of GHSA-9395-2g46-rj3f in the wild and no public exploit code.

## Fix prompt for AI coding agents

```text
Check this codebase for GHSA-9395-2g46-rj3f and fix it if it is affected.

What it is: GHSA-9395-2g46-rj3f is a cross-site scripting vulnerability in djust, published on 17 Sep 2026. It can be exploited over the network, and only if a user opens a file or follows a link.
Exploitation: There is no known exploitation of GHSA-9395-2g46-rj3f in the wild and no public exploit code.

Affected packages and fixed versions:
- djust (PyPI): affected all versions; fixed in 1.1.1
- djust (PyPI): affected all versions; fixed in 1.1.1

Task:
1. Search the dependency manifests and lockfiles (for example package.json, package-lock.json, requirements.txt, poetry.lock, uv.lock, pom.xml, build.gradle, go.mod, Cargo.lock, Gemfile.lock) and any Dockerfiles for the affected packages, including transitive dependencies.
2. If an affected version is in use, upgrade it to the fixed version for its release line with the project's package manager, so the lockfile is regenerated. Prefer the smallest upgrade that fixes it.
3. If there is no fix, or the upgrade would break the build, do not guess: explain the risk and the safest workaround instead.
4. Run the tests and summarise what changed and why.
5. If nothing in the project is affected, say so and change nothing.

Reference: https://insights.sbomify.com/GHSA-9395-2g46-rj3f/ (Markdown: https://insights.sbomify.com/GHSA-9395-2g46-rj3f/index.md)
```

## Sources

- OSV: https://osv.dev/vulnerability/GHSA-9395-2g46-rj3f

---
From sbomify Insights: https://insights.sbomify.com/GHSA-9395-2g46-rj3f/
Summaries are generated and may contain mistakes; the official description above is the record.
