highSep 17, 2026

djust: mark_safe Context Key Inheritance XSS

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

djust's template engine tracks which variable names are HTML-safe by name rather than by value, so rebinding a safe name to attacker-controlled input causes raw HTML to be rendered without escaping.

Packagedjust
Ecosystempip
Affected<= 1.1.1
Fixed in1.1.2

The problem

djust's Rust template engine maintains safety grants in a set keyed by variable name, not by value. When a view marks a value safe under a name like p, that name is added to safe_keys.

Any template construct that rebinds that name ({% with %}, {% for %}, {% include ... with %}, assign tags) copies the new value in but leaves the grant in place. The renderer then treats the new, attacker-controlled value as trusted and skips escaping entirely.

Proof of concept

A working proof-of-concept for this issue in djust, with the exact payload below.

python
# View
from django.utils.safestring import mark_safe

def my_view(request):
    context = {
        "p": mark_safe("<b>trusted</b>"),
        "user_input": request.GET.get("q", ""),
    }
    return render(request, "page.html", context)

# Template: page.html
# {% with p=user_input %}{{ p }}{% endwith %}

# Exploit URL:
# /page/?q=<img src=x onerror=alert(document.cookie)>
#
# djust renders: <img src=x onerror=alert(document.cookie)>   <- executes
# Django renders: &lt;img src=x onerror=alert(document.cookie)&gt;  <- safe

The root cause is in context.rs: safe_keys is an AHashSet<String> populated by _collect_safe_keys in rust_bridge.py, which records variable names for any SafeString in the context. In renderer.rs, the runtime safety check calls context.is_safe(var_name), a string-set lookup that ignores the current value bound to that name.

When {% with p=user_input %} executes, the Rust renderer pushes a new scope with the attacker string bound to p, but safe_keys still contains "p" from the parent scope. The check context.is_safe("p") returns true, so the raw attacker string is emitted without escaping.

The fix in 1.1.2 changes the bind semantics so that any rebinding of a name replaces rather than inherits its safety grant. A new scope bind must positively re-earn the grant from the incoming value, not carry the parent grant forward by name.

The fix

Upgrade to djust 1.1.2 or later (pip install -U djust). As a temporary workaround before upgrading, do not reuse a context name for both mark_safe-marked content and untrusted user input, and avoid rebinding such a name in {% with %}, {% for %}, {% include ... with %}, or an assign tag.

Reporter not attributed.

References: [1][2][3]

Related research