Security-only patch release on the 1.1 maintenance branch. Six live XSS classes present in every release from 1.0.0 through 1.1.0, re-implemented against 1.1.0's own code rather than cherry-picked — every one of main's fixes conflicts at the tag, because they are built on an InputSafety / per-key-grant model this release does not have (filters.rs alone grew 2,536 → 7,204 lines on main).
Where a faithful port would have needed that machinery, these fixes over-escape instead, and each such divergence is named below. Over-escaping is a rendering bug; under-escaping is the vulnerability.
Security
{{ bio|unordered_list }}and{{ bio|safeseq }}emitted a hostile string LIVE, with no precondition whatsoever (#2283). No|safe, nomark_safe, no custom code —{{ user_bio|unordered_list }}is an ordinary thing to write. Both filter names sit in the renderer's name-basedSAFE_OUTPUT_FILTERSlist, which suppresses auto-escaping on the filter name alone, and both returned a non-sequence value unchanged: so the suppression applied to a value nothing had escaped, andbio = "<img src=x onerror=alert(1)>"rendered a real element. Both non-list arms now escape. Django reaches inert differently — it iterates a string into its characters (<li><</li><li>i</li>…) and escapes each — so the output differs from Django's for this input; inert is the property that matters, and no caller passes a scalar to a per-item filter on purpose.escapeseqwas already inert (it escapes, and is not on the name list) and is unchanged. The list arms are untouched:{{ items|unordered_list }}still renders<li>markup and still escapes each item.{% render_slot p %}— djust's OWN tag — emitted a hostile context string LIVE (#2379). The framework-reachable half of the unescaped-handler-return class: no|safe, nomark_safe, and no app-written handler, only using component slots. The Rust engine inserts a tag handler's return into the page verbatim, andRenderSlotTagHandlerhas two exits that merely echo a value straight out of the render context — the Shape-3 pre-resolved scalar passthrough, and_render_value's trailingstr(value)reached by the direct-Python-caller path. Both now escape. The third exit, a slot entry'scontent, deliberately stays raw: it is the pre-rendered block body the parent already escaped — the trust status Django gives{% include %}'s output — and escaping it again would render every function component's and named slot's own markup as visible text. Not fixed in 1.1.1: the general case, an app-written tag handler returning attacker data as a plainstr. That needs the escape-unless-__html__bridge plus an audit of all 221 registered handlers (main's #2379 → #2421 → #2423 → #2416 chain), which is not a patch-release change. Apps registering their own handlers viaregister_tag_handlershouldmark_safeonly what is genuinely markup and escape the rest.A
mark_safegrant outlived the value it was granted for, for the life of a WebSocket connection (#2300).RustLiveView::mark_safe_keysaccumulated into a set nothing ever cleared, so a key marked safe once stayed safe for the lifetime of the view — which spans every event on the connection. A view that rendered trusted markup intopat mount and later rendered an attacker-controlledpemitted it live from a bare{{ p }}, no filter chain anywhere.update_statenow revokes a key's grant when it replaces that key's value, so a grant lives exactly as long as the value it was granted for, whoever is driving the API. Scoped per key rather than wholesale, becauseupdate_stateis a partial merge: updatingpdropspand itsp.0/p.fielddescendants and leaves an untouchedq's still-valid grant alone. Revocation alone, withoutmain's companion replace-in-mark_safe_keys: the bridge callsmark_safe_keysonlyif safe_keys:, so the empty case never clears and a replace cannot fix it — and two mechanisms covering the same half is one fix plus one decoration, which no test can separate.{{ p|escape|safe }}performed no escaping at all and emitted a live element (#2281). Theescapefilter was a literal no-op, on the theory that render-time auto-escaping handles it — but|safesuppresses that auto-escape, so the chain escaped nothing anywhere. Django's filter returnsconditional_escape(value), an already-escapedSafeStringthat thesafemarker merely carries through. It now escapes, using the five-character escape (& < > " ') that matches Django'sescape()exactly, so it is attribute-safe as well.crates/djust_templates/src/filters.rs::test_escape_filter_is_noopasserted the defect as a contract; inverted in place totest_escape_filter_escapes.{{ p|linenumbers|safe }}emitted1. <img src=x onerror=alert(1)>live (#2291). Same shape asescape: the filter emitted raw lines inside markup it generates and leaned on the auto-escape a later|saferemoves. Django'slinenumbersisneeds_autoescape=True— it escapes each line itself andmark_safes the joined result — and so is this one now. Correction to the issue's framing, measured rather than reasoned: its claim that an html-aware consumer is a live cell with no|safeanywhere does not hold —{{ p|linenumbers|truncatewords_html:2 }}measures inert at 1.1.0.{{ bio|linebreaks|safe }}and{{ bio|linebreaksbr|safe }}emitted the content LIVE — and|safewas mandatory for the filter to work at all (#2284). Not one of the five in the advisory classification; found by sweeping every built-in filter against live Django and asking the only question that means anything — where is djust live and Django not — rather than "where is the output live", which flags every|safecell and is no measurement at all. It is the most reachable of the|safe-preconditioned cells. Both filters emit<p>/<br>and neither was reported safe, so the bare{{ bio|linebreaks }}escaped the filter's own tags and put literal<p>text on the page: the only spelling that rendered correctly was{{ bio|linebreaks|safe }}, and that spelling was the live XSS. Any 1.1.0 app rendering user-entered text with paragraph breaks is almost certainly written that way. Both now escape each line before wrapping it, mirroring Django'sneeds_autoescape=Trueversions, which fixes both spellings at once — the bare form now emits real tags around escaped content.
Changed
- Four built-in filters now report their output already-escaped, at the LAST-filter position (#2281, #2284, #2291).
escape,linenumbers,linebreaksandlinebreaksbrescape their own output, so the renderer must not escape it a second time. This is reported throughapply_filter_full_safe's runtime-safe channel rather than by adding the four names to the renderer'sSAFE_OUTPUT_FILTERSlist, because the two have different scopes: the runtime flag is overwritten per filter, so a later plain filter re-taints, while the name list matches any position in the chain — using it would have made{{ p|escape|<anything> }}emit the last filter's output raw, closing four holes and opening a fifth.
Known divergences from `main`, all in the safe direction
{{ p|escape|F }},{{ p|linenumbers|F }}and{{ p|linebreaks|F }}for a plainFdouble-escape (&lt;). Django propagates safeness per value throughis_safe=Truefilters; 1.1.0 has no per-value tracking, so the grant ends at the escaping filter's own position. The alternative — the any-position name list — under-escapes, which is not a trade this release makes.{% render_slot slot.content %}, the one spelling that reaches the Shape-3 exit, over-escapes. That exit cannot tell an already-escaped.contentfrom a hostile bare context string: the engine resolved both to an opaque string before the call. The slot-entry spellings the docs use ({% render_slot col %},{% render_slot slots.col.0 %}) reach_render_valueand are unaffected.- A
mark_safed value passed through|escapeis escaped rather than passed through, because 1.1.0 cannot see the marker at the filter boundary.
Not fixed in 1.1.1
- App-written tag handlers returning attacker data as a plain
str— see #2379 above. Only djust's ownrender_slotis fixed. - Five type-coercion cells where djust passes a malformed value through under an explicit
|safeand Django discards it first:{{ p|date|safe }},{{ p|time|safe }},{{ p|floatformat|safe }},{{ p|random|safe }},{{ p|filesizeformat|safe }}. These are not escaping defects — no filter here claims to escape — and closing them means implementing Django's coercion semantics, a parity change with real regression risk for apps relying on the passthrough. Recorded rather than fixed so the list is accurate.
Verification
All six classes reproduce on unmodified 1.1 and are closed by this release: 18 of 27 cases red on the base build, 0 red after. Gate-off across 10 mutations — mutation text asserted present and unique, source asserted changed, the Rust crate rebuilt and its .so hash asserted to differ each iteration, __pycache__ cleared, errors counted separately from failures — all 10 RED, no survivors and no INVALIDs, with each mechanism having at least one test that goes red for it alone. A differential against live Django over 268 comparable filter cells found 7 cells where djust was live and Django was not before this change and 5 after, all 5 the type-coercion class above. 10,282 Python tests across tests/, python/tests/ and python/djust/tests/, 0 failed; Rust workspace green; clippy, cargo fmt --check and ruff clean.
27 cases in python/djust/tests/test_security_1_1_1_backports.py, including a real WebsocketCommunicator round-trip for the stale-grant class — it is defined over a connection's lifetime, so no renderer-level test can confirm it. Liveness on the wire is asserted structurally (an element node carrying an on* attribute), not by scanning the serialized frame: a #text node is written with textContent and is inert whatever characters it carries, and conflating the two reports a correct fix as broken in one direction and a real XSS as inert in the other.