Stable release consolidating 1.0.6rc1–rc3. Headline: two P0 VDOM data-loss fixes for {% if %} inside {% for %} (#1826 relative-position move decision, #1832 unique per-iteration marker ids), a new stored-XSS system check (S007), WebSocket URL validation, the consumer-owned VDOM send-version, the checks.py package modularization, and a transitive-dependency security sweep.
Added
- System check S007 — unsafe
client_name|safestored-XSS detection (#1821). A new template static-analysis check (severity WARNING) flags{{ <expr>.client_name|safe }}patterns in template files. An upload entry'sclient_nameis the attacker-controlled original filename, stored without sanitisation; auto-escaping is the only default protection, and|safebypasses it — turning a<script>-bearing filename into a stored XSS vector. The matcher is anchored on the{{ ... }}variable form (not a bare substring), tolerates whitespace around the pipe, and word-boundary guards rejectnotclient_name/client_name_foo. HonoursDJUST_CONFIG['suppress_checks'](S007ordjust.S007). New cases inTestS007ClientNameSafeRegexandTestS007CheckIntegrationinpython/tests/test_checks.py.
Changed
- Modularized
checks.py(4,268 LOC) into achecks/package (#1822). Pure refactor, no behavior change: the monolithic system-checks module is split by check family intochecks/{utils,configuration,integrations,components,security,templates,accessibility,quality}.py, withchecks/__init__.pyfiring every@register("djust")for DjangoAppConfig.checksdiscovery and re-exporting every public + private symbol. All 13 registered checks, 72 check IDs, and the full public/private import surface are preserved; the entiretest_checks*suite passes untouched (zero test edits). The six helpers the suite monkeypatches by package path (_get_project_app_dirs,_has_asgi_server,_has_multiple_permission_groups,_check_tailwind_cdn_in_production,_check_missing_compiled_css,_check_manual_client_js) are referenced from their callers via the root module sopatch("djust.checks.<helper>")keeps working. New regression guard inpython/tests/test_checks_package_structure_1822.pypins the discovery, import-surface, and monkeypatch-by-path contracts.
Fixed
VDOM:
{% if %}inside{% for %}no longer drops a row on re-render (#1832, P0 data-loss). A conditional inside a loop was wrapped in<!--dj-if id="if-<hash>-N"-->markers whereNis the parser's compile-time ordinal, so every loop iteration reused the same id. A later re-render that repositioned one boundary emitted aMoveSubtreewhose id matched N identical markers — the client got N unpairable moves (close marker not found), most patches failed, and the recovery morph visibly dropped a row per toggle. Fix: the renderer now threads a per-iteration loop-index path through the render context (mirroring the existing{% cycle %}counter save/restore) and appends it to the marker id, so each rendered{% if %}boundary inside a loop gets a unique id (if-<hash>-N-<index>, composing for nested loops) that is stable across re-renders which don't change loop structure. Ids outside any loop are unchanged (if-<hash>-N); the id is treated as an opaque string by the strip regex, the Rust differ, and the JS client. Distinct from #1826/#1828 (relative-vs-absolute move decision). Rust + Python regression tests added; two pre-existing tests that asserted the duplicate-id behavior were corrected.dj-if
MoveSubtreekeyed on RELATIVE position, not absolute offset (#1826). A{% if %}-wrapped element inside a{% for %}loop dropped a table row per toggle (P0; latent since v1.0.0 — introduced with theMoveSubtreefeature in #1666, not new in 1.0.6rc1; the reporter happened to hit it on 1.0.6rc1).diff_html's matched-boundary move-decision incrates/djust_vdom/src/diff.rscompared ABSOLUTE child offsets (old_off + ob.openvsnew_off + nb.open). Filling an EARLIER empty dj-if body inserts nodes that shift the absolute index of every LATER boundary even though those boundaries did NOT move relative to their siblings — so the differ emitted spuriousMoveSubtree { id: "if-b-0" / "if-c-0" }ops the client could not pair (close marker not found), ~15/22 patches failed, and anhtml_recoverymorph dropped a row. The decision now keys on the boundary's position RELATIVE to its non-boundary siblings (a newnon_boundary_count_beforehelper over the existingexcludedmask) plus its ordinal among same-level boundaries; both are invariant to a sibling boundary's span-length change, so only a GENUINE reposition (a real element inserted/removed before the boundary, or a boundary reorder) emits a move. The move TARGET index is unchanged. The#text-flattening also seen in #1826 (Defect 1) is an html5ever foster-parenting artifact of a bare<tbody>fragment (no<table>ancestor); it does not reproduce in production and is scoped out to follow-up #1827. Covered by the Rust reproducercrates/djust_vdom/tests/test_dj_if_loop_spurious_move_1826.rs(loop-fill no-spurious-move + client-faithful round-trip + genuine-reposition guard; gate-off verified per #1468) and 4 regression cases inpython/djust/tests/test_diff_html_if_marker_rows_1826.py. The two #1666 guards and the proptest / torture dj-if round-trip nets stay green. The ORIGINAL #1826 symptom was CLIENT-side (applyMoveSubtree→_findDjIfCloseMarker→close marker not found), and the Rustapply_allharness is not faithful to the JS client's document-wide depth-countingTreeWalker; new cases in thedj-if MoveSubtree — JS-client apply (#1826 follow-up)describe block (tests/js/dj_if_movesubtree_client_apply_1826.test.js) exercise the REALsrc/12-vdom-patch.jsagainst the correcteddiff_htmlpatch streams for the all-fill, genuine-reposition, redundant-move, and nested-boundary shapes — closing the JS-apply coverage gap the server-only tests left open (the real client passes all four; no client change needed).Consumer-owned monotonic VDOM send-version (#1788). The WebSocket
versionstamped on every client-checked frame was the Rust view's internal counter, which resets on a mid-session VDOM baseline loss (e.g. the patch-compression_rust_view.reset()path). The resulting non-sequential version failed the client'sclientVdomVersion === data.version - 1check, forcing anhtml_update→request_htmlrecovery round-trip (a full page reload before #1785). The consumer now owns a monotonic per-connection counter (_next_version()) used as the single source of truth across every client-checked send path (events, async work, mount, server_push, db_notify, ticks, time-travel, hot-reload patches, andStreamingMixin.push_state), so a post-baseline-losshtml_updatestays in sequence and the client accepts it directly with no recovery round-trip. Recovery (html_recovery) now carries the consumer version of the frame it replaces. New regression tests inpython/djust/tests/test_ws_send_version_1788.pypin the monotonic sequence across the baseline-loss boundary plus the send-path call-site coverage.
Security
Validate client-supplied mount/redirect URL (#1819). The WebSocket
mountand sticky-childlive_redirectframes carry the current page URL, which the consumer fed straight intoRequestFactory.get(),resolve(), query-string concatenation, and log statements at two sites inpython/djust/websocket.pywithout validation.RequestFactorydoes not normalize..segments, so a craftedurlof../../admin/landed inrequest.pathas/..../admin/(path traversal; an auth/routing decision keyed onrequest.pathwould see the traversed path); absolute (https://evil.com/page) and protocol-relative (//evil.com/page) URLs were silently accepted as relative requests, and the raw value flowed into logs andurlencodeconcatenation (CRLF / log-injection surface). A shared module-level helper_validate_mount_url()is now applied at both mount sites (one helper, two call sites — the structural cure per #1646): it rejects any url that is empty / non-string / does not start with/, contains a carriage-return or line-feed, is absolute or protocol-relative, or contains a..path segment — falling back to/. Legitimate site-relative URLs (e.g./dashboard?q=1) pass through unchanged. New regression cases inpython/djust/tests/test_security_mount_validation.pypin the empirical Django behavior, the helper's reject/preserve contract, the validated-url-is-safe-for-RequestFactoryend-to-end property, and a both-sites-validate source guard.Bumped four transitive dependencies to clear 12 Dependabot advisories (4 high, 3 moderate, 5 low) (#1831). Lockfile-only (
uv.lock) — none are direct djust dependencies, so the published wheel's declared dependencies are unchanged; this secures djust's own resolved / CI environment.cryptography46.0.7 → 49.0.0 (GHSA-537c-gmf6-5ccf),pyjwt2.12.1 → 2.13.0 (GHSA-xgmm-8j9v-c9wx, GHSA-993g-76c3-p5m4, GHSA-w7vc-732c-9m39, GHSA-jq35-7prp-9v3f, GHSA-fhv5-28vv-h8m8),python-multipart0.0.29 → 0.0.32 (GHSA-5rvq-cxj2-64vf, GHSA-6jv3-5f52-599m, GHSA-vffw-93wf-4j4q, GHSA-v9pg-7xvm-68hf),starlette1.2.1 → 1.3.1 (GHSA-82w8-qh3p-5jfq, GHSA-jp82-jpqv-5vv3). Full suite green against the bumped versions.Audited event-handler type-coercion edge cases — no bypass found, behavior pinned (#1820).
validate_handler_params()coerces event params by default (coerce=True) because Templatedata-*attributes always arrive as strings. The audit empirically exercised the malformed/adversarial inputs from the issue against the real coercion code and confirmed the paths are safe by design — no code change was required: (int)page="999 OR 1=1"and hexid="0x41"makeint()raise, so the original string is kept and type validation rejects the event (valid is False, handler not invoked) — there is no silent truncation to999; (bool) the dangerous case —active="true; DROP TABLE"— coerces toFalsebecause bool coercion is an allowlist (value.lower() in {"true","1","yes","on"}), NOTbool(non_empty_string), so the falsy-but-non-empty"false"/"0"are alsoFalse(no truthiness logic-bypass); (float) malformed strings are rejected, while"1e309"/"inf"/"nan"are accepted as the valid Python floats they are (intentional, documented contract — handlers doing bound checks or arithmetic on a coercedfloatmust guard non-finite values themselves); (List[T]) a malformed element abandons the whole coercion (no partial[1,2]) and the subscripted generic is skipped by the type validator, so the handler receives the unmodified original string. The strictest posture remains@event_handler(coerce_types=False), which rejects any string for a typed param outright (so no separate@strict_typesdecorator was added). The audited contract is documented inSECURITY_AUDIT.md(Type Coercion Contract table) and pinned byTestCoercionSecurityEdgeCases(11 characterization cases) inpython/tests/test_validation.py; non-tautology was verified (#1468) by mutating the coercion to the unsafe variants and confirming 5 of the new tests fail with the exact dangerous symptoms.