Security-only patch release on the 1.1 maintenance branch, closing one XSS class 1.1.1 left open: a template binding carried a safety grant it had not earned. Present in every release from 1.0.0 through 1.1.1.
Security
A
{% with %},{% for %},{% include … with %}or{% … as x %}bind that shadowed amark_safed name emitted the NEW, attacker-controlled value LIVE (#2361, #2363). djust's context safety channel is keyed BY NAME —Context::safe_keysholds dotted paths written byrust_bridge._collect_safe_keys, andContext::is_safeanswers by looking a NAME up in it. Every construct that binds a resolved value to a name copied the VALUE and left the GRANT attached, so withpmarked safe anywhere in the view's context,{% with p=hostile %}{{ p }}{% endwith %}rendered<img src=x onerror=alert(1)>as a real element where Django escapes it. No|safe, no custom filter: one priormark_safeon a name, and any template that later binds that name. Eight bind shapes reproduce, each measured on unmodified1.1against its ownsafe_keys=Nonecontrol:{% with %}rebinding the granted name and itsp.adescendant,{% with %}binding any other name the context had marked, the{% for %}loop variable and itsp.adescendant,{% for k, v in … %}tuple unpacking,{% include … with %}, and an assign tag's{% … as x %}merge.{% include … with … only %}was already inert (the fresh context carries no grants) and is pinned so a future change cannot quietly reintroduce it.The cure is a rule about the operation, not the values: a bind REPLACES the grant.
Context::bindrevokesnameand everyname.…beneath it before attaching the new value; the{% for %}arm hoists that same revoke out of its iteration — identical in effect, since every iteration binds the same names, andO(len(safe_keys))once instead of per item. The descendants go because they described the value being SHADOWED: withp.amarked, leaving them makes{% with p=hostile %}{{ p.a }}{% endwith %}emit raw. Stating the rule the other way round — "a bind also CARRIES a grant", which is what the two issues above asked for, both being over-escapes — would have left this leak open. Both directions are the same rule, andmainstates it the same way (PR #2378).Both directions are tested. The two channels that legitimately carry a grant across a bind on this branch are asserted still live: the
{% for %}positional loop mapping (x→items.<index>, including dottedr.bodypaths), and a plain{% include %}'s inherited parent context. So are unrelated and prefix-sharing sibling names (ppis not beneathp), and the parent context's own grant after the block ends.
Known divergences from Django, unchanged by this release
{% with q=p %}over a markedpbinds a NEW name and stays escaped where Django renders it live. This arm resolves its operand with a bareContext::get, so there is no runtime-safe bool to attach and the honest replacement grant is "none". Over-escaping, andmainbehaves the same way — itsContext::bindtakes asafebool fromget_value_safe, which returnsfalsefor a bare context name there too. Pinned rather than silently accepted.{% with q=p|filter %}does not resolve the filter at all on 1.1.x; it binds the literal tokenp|filter. A pre-existing resolution gap (main's #2325), not an escaping defect. Pinned so the fix cannot be read as having caused it.
Not fixed in 1.1.2
- The over-escape half of this defect —
main's #2363 ({% with body=post.text|linebreaks %}renders escaped tag text) and #2361 (amark_safevalue reached throughd.values/d.itemsloses its mark). Both need machinery this branch does not have: a filter pipeline in the{% with %}arm, and theValue::DictViewnormalisation the{% for %}arm gained onmain. Backporting either is a feature change, not a patch. Neither is a leak — both escape more than Django, which is the direction to fail in.
Verification
All eight leaking shapes reproduce on unmodified 1.1 and are closed here, with the two legitimate-grant shapes and the only case unchanged. Gate-off across 7 mutations — mutation text asserted present at its declared count, source asserted changed, the Rust crate rebuilt and its .so mtime asserted to advance each iteration, __pycache__ cleared, N error counted separately from N failed — all 7 RED, 0 survivors, 0 INVALIDs, and every mechanism has at least one test that goes red for it alone: neutering only the descendant sweep fails exactly the two p.a cases and nothing else. Both sources restored byte-identical. 10,302 Python tests across tests/, python/tests/ and python/djust/tests/, 0 failed; make test-rust green across 44 test binaries; clippy, cargo fmt --check and ruff clean.
New cases in TestABindRevokesAStaleGrant, TestALegitimateGrantStillReachesItsValue, TestTheAcceptedDivergences and TestEveryAssignTagMergeSiteUsesTheBind, plus context::tests in crates/djust_core/src/context.rs. Every Python case runs through _rust.render_template_with_dirs — the only entry point carrying safe_keys — with keys from the production collector, renders the same template twice (channel off, then on) so a green cell cannot mean the channel was never engaged, and asserts liveness structurally (an <img> opening tag carrying an onerror attribute) rather than by substring, paired with a positive assertion that the payload arrived escaped so no case can pass by the payload never reaching the output. The three assign-tag merge sites are pinned as a set, not a floor, so a fourth added with a bare set fails.