djust 1.0.7

StableSecurityReleased
Install
pip install djust==1.0.7

djust 1.0.7 was stabilized through 1 pre-release, and most of its changes are recorded under them: 1.0.7rc1.

Security

  • Gated the OpenAPI schema endpoint against unauthenticated API-surface enumeration (F29 — CWE-200/CWE-651).OpenAPISchemaView (/djust/api/openapi.json) served the auto-generated OpenAPI 3.1 document to any anonymous client with no DEBUG or auth gate, handing out a complete machine-readable map of the expose_api attack surface: every endpoint URL, internal view-class + handler names, every parameter name/type, and handler docstrings. This was inconsistent with the framework's own posture — observability introspection is DEBUG+localhost-gated (F9) and API dispatch requires authentication, yet the schema describing those endpoints was wide open. The view is now secure-by-default: a new _openapi_gate(request) helper serves the schema only when (1) settings.DEBUG is True, (2) the new opt-in settings.DJUST_API_OPENAPI_PUBLIC is True (operator explicitly publishes the spec), or (3) the request is authenticated (request.user.is_authenticated); otherwise it returns a non-disclosing 404 (not 403, mirroring the observability gate so a gated client cannot confirm the endpoint exists). The auth check is fail-closed — a missing request.user (no AuthenticationMiddleware) or an anonymous user falls through to the 404. New setting:DJUST_API_OPENAPI_PUBLIC (default False) — set to True only if you intend the OpenAPI spec to be readable by unauthenticated clients. Default-behavior change (action required for some apps): with DEBUG=False and the setting unset, anonymous GET /djust/api/openapi.json now returns 404 instead of the schema; authenticated developers/integrators and DEBUG/dev environments are unaffected. Regression: new cases in TestOpenAPIGateF29python/djust/tests/test_openapi_gate_f29.py (anonymous default-deny non-disclosing 404, fail-closed missing-request.user, DEBUG-serves, opt-in-serves, authenticated-serves, helper-level precedence, and a gate-off self-test (#1468) proving the deny tests are non-tautological).

  • Multi-tenant isolation: the live (WebSocket) path now resolves the same tenant as the HTTP path for host/subdomain TenantResolvers (F26 — CWE-639/CWE-348). The WebSocket handle_mount and ViewRuntime._build_request reconstructed the request via RequestFactory().get(...) with no HTTP_HOST, so request.get_host() defaulted to "testserver" on the live path. Host/subdomain resolvers (e.g. SubdomainResolver, which reads request.get_host()) therefore misresolved the tenant to None over WebSocket — while the HTTP (SSR) initial render, using the real request, resolved the correct tenant. With STRICT_MODE=False the tenant-scoped managers then returned unscoped rows (cross-tenant disclosure) in WS event handlers; with the default STRICT_MODE=True they returned .none() (broken tenancy, and wrong/no tenant stamped on WS writes). The validated client Host (and the TLS scheme) is now propagated from the handshake scope into the reconstructed request through a single shared helper, djust.websocket.validated_host_from_scope, which validates the Host against settings.ALLOWED_HOSTS using the same logic as the CSWSH Origin gate — so host/subdomain resolution on the live path matches HTTP exactly: no weaker, no stronger than the HTTP layer. An absent or non-ALLOWED_HOSTSHost falls back to the prior default (non-browser clients keep working; a spoofed Host gains no tenant authority beyond HTTP). Covered by TestHttpWsTenantParity, TestAllowedHostsBound, TestNoHostFallback, TestSchemePropagation, TestMalformedHostRejected, and TestGateOff in python/djust/tests/test_ws_host_tenant_f26.py.

  • Unified the per-handler @rate_limit into one shared per-caller bucket across all three transports, and hardened the HTTP-API caller key against proxy collapse / XFF spoofing (F27 + F28 — CWE-770/CWE-799/CWE-400 + CWE-348). The @rate_limit(rate, burst) decorator is meant to throttle a caller's invocation rate of a specific handler (OTP/verification-email senders, expensive compute, brute-forceable actions). It was instead enforced against independent bucket stores that summed across two axes (parallel-path-drift, #1646). F27 (per-connection / per-transport multiplication): the WebSocket path enforced it via the per-connectionConnectionRateLimiter (websocket_utils._validate_event_securityrate_limiter.check_handler), so opening N WebSocket connections gave the configured limit (no concurrent-connection cap exists); SSE used a per-session limiter and the HTTP API a separate process-level dict, so a caller hitting the same handler over WS and the API consumed from both budgets independently. F28 (mis-keyed API limiter):api/dispatch._caller_key keyed unauthenticated callers by raw REMOTE_ADDR instead of the #5-hardened resolve_client_ip (which honors DJUST_TRUSTED_PROXY_COUNT and is already used by WS/SSE) — so behind a reverse proxy (standard prod topology) all unauthenticated callers collapsed to one ip:<proxy> bucket (ineffective per-client limiting + mutual DoS), and under a naive XFF→REMOTE_ADDR middleware the key became attacker-controlled (rate-limit bypass). Fixed once: a single process-level, LRU-capped (_HANDLER_BUCKET_CAP = 10_000) per-caller bucket store in djust.rate_limit (handler_rate_check(caller_key, handler_name, settings) over an OrderedDict of TokenBuckets keyed (caller_key, handler_name)) is now the sole enforcement point for the per-handler @rate_limit, used by all three transports. A shared caller_key(request, client_ip) mirrors the SSE owner-principal identity model (Findings #24/#25): user:<pk> when authenticated, else session:<session_key>, else ip:<resolved_ip> — and its IP fallback resolves through resolve_client_ip (closing F28: per-real-client buckets behind a trusted proxy, no XFF spoof). The WS/SSE/runtime chokepoint _validate_event_security and api/dispatch._rate_limit_check both route through the shared store; api/dispatch._caller_key now resolves the IP via resolve_client_ip and its misleading "REMOTE_ADDR should already reflect the caller via their proxy middleware" comment is removed. The GLOBAL per-message abuse-disconnect (#17) is untouchedConnectionRateLimiter.check/check_upload/should_disconnect (the connection-flood close(4429) + IP cooldown in websocket.py:receive) stays per-connection, which is correct for connection-flood control; only the per-handler@rate_limit is unified. Net invariant: a given caller has ONE @rate_limit budget per handler regardless of connection count or transport. Regression: 11 regression cases in python/djust/tests/test_ratelimit_per_caller_f27_f28.py (test_f27_* multi-connection/multi-context/cross-transport/two-WebsocketCommunicator end-to-end, test_f28_* trusted-proxy peel vs. peer-keyed XFF-ignored, test_global_* per-message abuse-disconnect still trips per connection, test_caller_key_precedence_*; reproduce-first + gate-off verified — reverting per-handler enforcement to the per-connection limiter makes the combined-count test allow 10 not 5, and reverting _caller_key to raw REMOTE_ADDR makes the F28 trusted-proxy test collapse both clients to the proxy bucket).

  • Consolidated and hardened LiveView mount-path resolution against unsafe reflection and SSE URL traversal (F22 + F23 — CWE-470/CWE-1188/CWE-209 + CWE-22). The client-supplied mount inputs (view dotted path and page url) are reached over three transports — WebSocket handle_mount, SSE _sse_mount_view, and the generic ViewRuntime.dispatch_mount/_instantiate_view — which had drifted: each carried its own copy of the view-import gate and only the WebSocket paths validated the mount URL. Both findings are now fixed once in a single shared module, djust.security.mount, that all three entry points call (parallel-path-drift cure, #1646). F22 (unsafe reflection):resolve_view_class(view_path) validates the dotted-path shape (module[.sub].ClassName of valid identifiers — rejecting .., leading/trailing dots, and bad characters), checks an allowlist before importing anything, imports via importlib.import_module + a vars(module).get(name)__dict__ lookup (never getattr, so a client class name cannot trigger a PEP 562 __getattr__ submodule import — GHSA-7prp-2623-8g45 follow-up), and only then runs the LiveView-subclass check as defense-in-depth. The allowlist now uses module-segment-boundary matching (path == entry or path.startswith(entry + ".")) instead of the old boundary-less startswith (which let ["myapp"] admit myapp_evil.views.Pwn). F23 (SSE traversal): the #1819/#1825 mount-URL validator (validate_mount_url, moved here verbatim; websocket._validate_mount_url is now a thin alias) is applied in ViewRuntime.dispatch_mount (and defensively in _build_request), so the SSE/runtime path neutralises /%2e%2e/admin/ identically to the WebSocket path — closing the traversal that had reopened on SSE because the validator ran only on the WS mount paths. Default-behavior change (action required for some apps): view resolution is now restricted to LIVEVIEW_ALLOWED_MODULES when that setting is configured; when it is unset/empty, the allowlist falls back to a non-breaking set derived from the project itself — the top-level package root of each INSTALLED_APPS entry plus "djust". This blocks arbitrary os/antigravity/site-packages imports out of the box (the prior default allowed any already-imported module, which os always defeats) without breaking the common case of an app that never set LIVEVIEW_ALLOWED_MODULES — legitimate LiveView classes live inside installed apps. Apps that mount a lazily-imported view living outside an installed app must now add its module to LIVEVIEW_ALLOWED_MODULES. Regression: 17 regression tests in python/djust/tests/test_mount_consolidation_f22_f23.py (TestF22ArbitraryImportRefused, TestF22Allowlist, TestF22Shape, TestF23Traversal, TestViewResolution — arbitrary-import-refused on WS + runtime paths, boundary match, INSTALLED_APPS-fallback non-breaking guard, shape rejection, WS↔runtime traversal parity; reproduce-first + gate-off verified).

  • Hardened the WebSocket transport against channel-layer mass assignment and an upload-frame rate-limit bypass (CWE-915/CWE-913 + CWE-770/CWE-400). Two defects in LiveViewConsumer. (1)server_push (the channel-layer handler for type:"server_push" messages, sent by push_to_view/apush_to_view) applied the message state dict via raw setattr(self.view_instance, key, value) in a loop — while the sibling handler field was already restricted against the framework's own stated threat model ("an attacker who gains access to the channel-layer backend", e.g. a shared Redis on a multi-tenant deployment). Raw setattr let such an attacker overwrite __class__ (type confusion), __init__, framework internals (_framework_attrs/_components/_rust_view), and arbitrary private _ state on a live view. Fixed by routing the loop through safe_setattr(..., allow_private=False) (djust.security.attribute_guard) — the same guard every other state-restore sink already uses (snapshot restore, time-travel, HTTP state restore) — which blocks dunders, the framework blocklist, and private names while still applying legitimate public state. (2) Binary upload frames (first byte 0x01/0x02/0x03, len ≥ 17) were dispatched to _handle_upload_frame and returned in receive()before the global per-connection rate-limit gate whose comment states it "applies to ALL message types (#107)" — so the highest-volume message class was unthrottled and never tripped the abuse-disconnect (close(4429) + IP cooldown), making an upload-frame flood (cheap, with a 1-in/1-out response amplification) the one frame type an attacker could send without ever being evicted. Fixed by routing binary upload frames through a dedicated, higher-ceiling upload token bucket on ConnectionRateLimiter (check_upload(); defaults upload_rate=200/s, upload_burst=400, configurable via LIVEVIEW_CONFIG['rate_limit']['upload_rate'|'upload_burst']) before dispatch — sized so a legitimate full single-file upload (~157 64 KB chunks for a 10 MB file) lands as a burst without throttling, while a sustained flood depletes the bucket, increments the shared warning counter, and trips should_disconnect()close(4429) + cooldown exactly like the text path. The per-frame 64 KB size cap is unchanged. (Out of scope, filed as follow-up: the per-dropped-chunk send_json response amplification / #824 stop-sending mechanism — the rate-accounting is the fix here.) Regression: test_ws_transport_hardening_f21_f17.py (TestServerPushStateMassAssignment, TestUploadFrameRateLimit, TestUploadBucketAccounting, TestReceiveRateGateParity; reproduce-first + gate-off verified for both findings).

  • Fixed CSRF on the SSE transport (CWE-352). The SSE client→server POST endpoints (DjustSSEEventView, DjustSSEMessageView) are @csrf_exempt and the SSE GET stream endpoint (DjustSSEStreamView) had no Origin check, so a cross-origin page could drive a victim-cookie-authenticated SSE session: force the victim's browser to GET /djust/sse/<attacker-uuid>/?view=... (which creates and mounts a LiveView as the victim via the victim's cookies) and then POST to /djust/sse/<attacker-uuid>/message/ with credentials: include to fire state-changing event handlers as the victim. The @csrf_exempt justification was false on three counts: the URL session_id is client-chosen (DjustSSEStreamView.get validates only its UUID format, never that the server issued it), so it is not a CSRF token; and a JSON body sent with Content-Type: text/plain is a CORS simple request accepted by the handlers (which json.loads the body regardless of content type) with no preflight — there was no Origin check at all (the WebSocket transport already has one). Fixed by mirroring the WS transport's CSWSH defense (#653): all three SSE endpoints now validate the request Origin against settings.ALLOWED_HOSTS (reusing djust.websocket._is_allowed_origin) and reject cross-origin requests with 403 before any session create/mount or event dispatch — a browser always sends Origin on a cross-origin request, so an attacker page's origin won't match; same-origin requests pass; non-browser clients (no Origin) still work. As defense-in-depth, the POST endpoints now require Content-Type: application/json (415 otherwise), closing the text/plain simple-request bypass and forcing a CORS preflight cross-origin. @csrf_exempt is retained but its docstrings are corrected to state the real CSRF defense is the Origin allowlist. Migration note: SSE client→server requests now require a same-origin Origin header (or noOrigin, for non-browser clients) andContent-Type: application/json. The bundled djust client already sends both. Custom SSE clients that POST with text/plain or from a different origin must change to send application/json and a same-origin Origin (or no Origin for server-to-server / native clients). Regression: test_sse_csrf_origin.py (11 cases: foreign-origin GET/POST → 403 with no session/dispatch, same-origin → not 403, missing Origin → allowed, text/plain → 415, application/json → accepted; reproduce-first + gate-off verified).

  • Fixed multi-tenant isolation failing open on the WebSocket/SSE path (CWE-862 / CWE-636).djust.tenants isolation was enforced only on the HTTP path. The current tenant was stored in threading.local() and set exclusively by TenantMiddleware (HTTP-only), so on the live (WebSocket/SSE) path get_current_tenant() was always None during mount + every event handler — and the tenant-aware managers failed OPEN: TenantQuerySet._filter_by_tenant returned the unfiltered queryset and never consulted STRICT_MODE, disclosing every tenant's rows to whoever held the socket. TenantQuerySet additionally RecursionError'd whenever a tenant was set (its _chain override re-entered filter()_chain → ...), and Model.objects.all() was unfiltered even with a tenant bound. Fixed four ways: (A) tenant storage is now a contextvars.ContextVar (per async-task), not threading.local()threading.local is shared across all connections on asgiref's thread_sensitivesync_to_async executor thread (a cross-tenant clobber), whereas ContextVar is copied per-call into the executor so each connection stays isolated; the public API (get_current_tenant/set_current_tenant) is preserved and a new tenant_context() context manager is added for drift-free set/clear. (B) The live path now binds the resolved tenant into the ContextVar around WS/SSE mount and every event/url-change dispatch, cleared after (WS handle_mount/handle_event/disconnect, ViewRuntime.dispatch_{mount,event,url_change}, and the legacy _sse_mount_view/_sse_handle_event). (C) Both tenant managers now scope the base queryset once in a shared get_queryset() helper (no recursion; .all() is scoped too) and fail CLOSED — with no tenant bound they return .none() under STRICT_MODE (the default), matching TenantManager; unfiltered only when STRICT_MODE is explicitly False. (D) New system check S006 warns when DJUST_TENANTS['STRICT_MODE'] is explicitly False (disables fail-closed isolation; risks cross-tenant disclosure). Migration note: storage moved from threading.localContextVar (transparent — same public API). Tenant managers are now fail-closed by default: a tenant-scoped query that runs with no tenant in context now returns an empty queryset instead of all rows. Apps that relied on the old fail-open behaviour must either keep a tenant bound on every query path, use Model.objects.unscoped(reason=...) for deliberate cross-tenant reads, or set DJUST_TENANTS['STRICT_MODE'] = False — which is now flagged as dangerous by S006. Regression: test_tenant_isolation_contextvar.py (16 cases covering both manager variants, fail-closed/lax/recursion/.all()-scoping, ContextVar isolation across interleaved async contexts, live-path set/clear, and S006).

  • Fixed unauthenticated arbitrary module import via the WebSocket/SSE view-mount path (GHSA-7prp-2623-8g45, CWE-470). The live transport resolved the LiveView to mount from a client-supplied dotted path via __import__(module_path) — executing that module's top-level code — before the LiveView-subclass check and per-view auth, and the LIVEVIEW_ALLOWED_MODULES guard was fail-open (skipped when unset, the default) with loose startswith matching. An unauthenticated client could cause the server to import (and run the import-time side effects of) any importable module by name. Fixed with a fail-closed gate (djust._view_resolution.is_view_import_allowed) applied before __import__ at all three sinks (handle_mount, ViewRuntime.dispatch_mount/_instantiate_view, SSE): a client view path resolves only if its module is already loaded (so resolving runs no new code — URL-routed views keep working with zero config) or it matches LIVEVIEW_ALLOWED_MODULES on a module-segment boundary (no longer startswith). The class is resolved via importlib.import_module (no fromlist) + vars(module).get(class_name) so a client-controlled class name cannot trigger a module-level __getattr__ (PEP 562) submodule import. Regression: test_security_view_import_failclosed.py.

  • Enforced view-level authorization on the WebSocket/SSE mount path (CWE-862). The live transport authorizes a mount via check_view_auth, not Django's View.dispatch() chain — so standard Django authorization (LoginRequiredMixin, UserPassesTestMixin, @method_decorator(login_required, name="dispatch"), custom dispatch() guards) and djust's own admin extension (gated only by the HTTP as_view wrapper) were enforced on the initial HTTP GET but silently bypassed over WebSocket, where all events and state flow. An anonymous/under-privileged client could open a WebSocket and mount such a view. Fixed three ways: (1) check_view_auth now honors the Django AccessMixin family (LoginRequiredMixin / PermissionRequiredMixin / UserPassesTestMixin), mirroring handle_no_permission() semantics and leaving the view's request attribute unchanged; (2) a new system check S004 (djust.S004) fails loud at startup on the auth patterns the runtime cannot safely re-run in the WS context — a LiveView subclass with @method_decorator(<auth>, name="dispatch")or an overridden dispatch() that performs authorization itself — pointing the developer at the djust login_required/permission_required/check_permissions attributes or a supported mixin; (3) admin_ext.AdminBaseMixin now declares login_required = True + a check_permissions active-staff gate so the admin extension is staff-gated on every transport. The AccessMixin family is honored automatically; the decorator/overridden-dispatch forms are surfaced by S004 rather than auto-honored (they're HTTP-only and cannot be replayed without producing an HttpResponse). Regression + detection-canary tests in test_ws_django_auth_bypass.py.

  • Enforced object-permission (ADR-017) on every render path (CWE-862 / IDOR). The post-mount object-permission check (get_object + has_object_permission) was enforced on the WebSocket mount + event paths but not on the initial HTTP GET render (RequestMixin.get/aget), SPA url_change navigation (ViewRuntime.dispatch_url_change), or {% live_render %} embedded children — so an object-scoped view rendered a denied object on those paths. A shared chokepoint djust.auth.core.enforce_object_permission (no-op for views without a custom get_object; raises PermissionDenied on denial; fail-closed on a None request or any non-PermissionDenied exception) is now called from all three: the HTTP render returns 403, url_change sends a permission_denied error frame and skips the render, and {% live_render %} (eager + lazy) refuses the embed. Object-level authorization is now uniform across every mount/render entry point. Regression: test_object_perm_render_paths.py.

  • Resolve the client IP from the socket peer, not a spoofable X-Forwarded-For (CWE-348). The WebSocket (_get_client_ip) and SSE (_client_ip_from_request) paths took the leftmostX-Forwarded-For value unconditionally and used it for per-IP connection limiting + cooldown/ban — a client-controlled identity. An attacker could rotate X-Forwarded-For to bypass the per-IP cap entirely, or spoof a victim's IP to drive it into cooldown and lock legitimate users out. Both transports now resolve the IP via the shared djust._client_ip.resolve_client_ip: by default the real socket peer (REMOTE_ADDR / ASGI scope["client"]) is used and X-Forwarded-For is ignored; behind a trusted reverse proxy set the new DJUST_TRUSTED_PROXY_COUNT = N setting and the client is taken as the Nth entry from the right of the chain (peeling N trusted hops; the spoofable left side is never trusted), falling back to the peer if the chain is shorter than N. Regression: test_client_ip_trusted_proxy.py.

  • Validate the URL scheme in built-in component href/action sinks (CWE-79). Built-in component tags rendered a developer/user-supplied URL into an href/action attribute with conditional_escape (HTML-entity escaping) but no scheme validation — so a javascript: URI (which contains no escapable characters) landed verbatim and executed on click. A new djust.components.templatetags._registry.safe_url helper HTML-escapes and neutralizes dangerous schemes (javascript:/vbscript:/data:, including control-char/whitespace-obfuscated variants) to #, while preserving http(s)/mailto/tel/ftp and relative/anchor/query URLs. Applied at all 11 navigation-context URL sinks across djust_components.py (breadcrumb, dj_nav link/dropdown/brand, citation, cookie-consent), _advanced.py (error-page action, advanced breadcrumb ×2), and _forms.py (form action). <img src> thumbnail sinks and a non-navigation status-text interpolation are intentionally excluded (javascript: doesn't execute via <img src>; data: images are legitimate; status text is not an href/action context). Regression: test_component_safe_url.py.

  • Script-safe JSON encoding for inline <script> JSON sinks (CWE-79).json.dumps does not escape <, >, or &, so interpolating its output directly into an inline <script> block let a string value containing </script> close the element and inject markup. Two sinks were affected: the DEBUG-only debug-panel injection (post_processing._inject_client_script, where get_debug_info() includes repr()s of user-controlled public view attributes) and js.JSChain.__html__ (whose docstring also falsely claimed JSON escapes </>). A new djust.security.escape_json_for_script helper translates <, >, & (and U+2028/U+2029) to \uXXXX — matching django.utils.html.json_script — and both sinks now route through it. Regression: test_debug_json_script_escape.py.

  • Restricted the default update_model (dj-model) handler to template-bound fields (CWE-915 mass assignment).ModelBindingMixin is in the LiveView base MRO, so every LiveView exposes a default @event_handler update_model(field, value) that setattrs a view attribute whose name is client-supplied. The only gates were: reject _-prefixed names, reject the 14-entry FORBIDDEN_MODEL_FIELDS denylist, optionally require membership in allowed_model_fields (which defaulted to None = allow ALL public attrs), and require hasattr. So a client could set any public, existing view attribute — is_admin, account_id, total_price, … — not just the fields actually bound with dj-model="…" in the template, an IDOR / authz-flag / price-tampering surface on the standard djust state pattern. Fixed with a secure-by-default auto-allowlist derived from the TEMPLATE SOURCE: the Rust template engine walks the parsed template AST and collects every static dj-model="<field>" binding from Node::Text literals (covering {% extends %} and {% include %}), exposed to Python as RustLiveView.dj_model_fields() (and a module-level dj_model_fields_from_template(source, dirs) for embedded children). This is recorded on self._dj_model_fields on every render via ModelBindingMixin._record_dj_model_fields_from_rust (LiveView paths) / _record_dj_model_fields_from_source (embedded children). Deriving the set from the template source — not the rendered HTML — is the load-bearing security property: the source is developer-authored text that attacker data can never reach (it flows only through {{ }}Node::Variable substitution at render time), so the three rendered-output poisoning vectors that defeated an earlier rendered-HTML approach (attacker text nodes, unquoted-interpolated attributes <div x={{ v }}>, and |safe content carrying <input dj-model=…>) cannot widen the allowlist. update_model is fail-closed — a field is bindable iff it is in self._dj_model_fields (auto-allowlist) OR in an explicit allowed_model_fields (union semantics); the _-prefix / FORBIDDEN_MODEL_FIELDS / hasattr checks remain as defense-in-depth. Collection runs at all three full-HTML render chokepoints (render, _render_full_template_inner, render_with_diff) plus the embedded-child render paths (websocket._render_embedded_child, live_render) via a single shared helper to avoid parallel-path drift; render_with_diff is the dominant one (HTTP-GET baseline + every WS mount + every WS event). _dj_model_fields is assigned before the _framework_attrs snapshot so it is a framework slot (recomputed each render, never persisted). Migration note: the auto-allowlist now covers static dj-model= bindings in templates (including {% extends %}/{% include %}). A dynamic binding dj-model="{{ var }}" (resolved at render time) and any field written purely programmatically are not auto-allowed — add those names to allowed_model_fields. Existing static dj-model="x" bindings keep working with zero config. Regression: test_update_model_allowlist.py (the three poisoning vectors verified end-to-end through render_with_diff, reproduce-first + gate-off verified, plus a real-render integration test) and Rust unit tests in crates/djust_templates/src/parser.rs.

  • Signed state snapshots — rejected unsigned/forged client snapshots on the back-navigation restore path (CWE-345 → CWE-915). The opt-in state-snapshot feature (LiveView.enable_state_snapshot = True) restored a view's public state from a client-supplied snapshot on the live_redirect_mount back-navigation path, in lieu of calling mount(). The snapshot was embedded in the page and round-tripped through the client UNSIGNED: the server sent the public state, the client re-serialized it (JSON.stringify) and echoed it back, and the server trusted it — LiveView._restore_snapshotsafe_setattrs every public key from the client's state_json. Because the payload carried no authenticity proof, a client could forge an arbitrary snapshot (e.g. {"is_admin": true, "account_id": 7}) and inject arbitrary public state, a state-injection / mass-assignment vulnerability gated only by safe_setattr's attribute-name regex (which permits ordinary public names). Fixed by signing the snapshot server-side with Django's TimestampSigner (keyed on SECRET_KEY, salt "djust.state_snapshot") in a new shared helper djust.security.state_snapshot (sign_snapshot / unsign_snapshot — single source of truth for both the emit and restore halves, no parallel-path drift). The mount frame now carries an opaque signed blob (state_snapshot_signed) instead of the plaintext public_state dict; the client stores it verbatim and echoes it back unchanged (the client no longer re-serializes — re-serializing would strip the signature). On restore the server runs the inbound blob through unsign_snapshotbefore applying any state, verifying: the HMAC signature (rejecting unsigned/forged/tampered payloads), a configurable TTL (DJUST_STATE_SNAPSHOT_MAX_AGE, default 3600 s — rejecting expired snapshots), and an identity binding to the view slug + Django session key (rejecting cross-view and cross-session replay). Any rejection drops the snapshot and falls back to a normal mount() with mounted_from_snapshot = False. The existing 64 KB size cap, 256-key keyset cap, dict-type check, slug-match, and per-view opt-in gates are retained as defense-in-depth, now applied to the verified inner JSON. There is no bypass via the legacy plaintext state_json — unsigned input fails the signature check. Migration note: state snapshots are now HMAC-signed (TimestampSigner/SECRET_KEY) with a TTL; unsigned, forged, tampered, expired, or cross-view/cross-session snapshots are rejected and the view falls back to mount(). The bundled djust client handles the new opaque blob automatically. Any custom client or any view that overrides _capture_snapshot_state / _restore_snapshot must round-trip the signed blob verbatim (do not re-serialize it) — the single field name state_json still carries the blob end-to-end. Configure the TTL via DJUST_STATE_SNAPSHOT_MAX_AGE (seconds, default 3600). Regression: python/djust/tests/test_state_snapshot_signing.py (real WebsocketCommunicator forge-rejection + signed round-trip, plus tamper / expiry / cross-view / cross-session / anonymous; reproduce-first + gate-off verified) and tests/js/state_snapshot_signed.test.js (client stores + echoes the opaque blob verbatim, no re-serialize).

  • Enforced localhost in-view on the observability endpoints + restricted eval_handler (CWE-668 / CWE-306). The _djust/observability/ endpoints (live cross-session state, tracebacks, logs, and the eval_handler method-invocation endpoint) gated on settings.DEBUG in every view, but the localhost check lived only in the opt-in LocalhostOnlyObservabilityMiddleware — omitted from the documented setup and not auto-installed — so with DEBUG=True and the middleware absent (e.g. a 0.0.0.0-bound staging server) they were reachable from any host. Localhost is now enforced in every view (observability.views._gate, returning a non-disclosing 404), so the boundary holds regardless of middleware; eval_handler now only invokes @event_handler-decorated methods (the same allowlist the WebSocket event path uses), not arbitrary public methods; a new system check A031 warns when the observability URLs are wired without the middleware; and the setup docs now show the MIDDLEWARE entry. Regression: test_observability_localhost_gate.py.

  • Added a secure-by-default sensitive-field denylist to Django model serialization (CWE-200 / CWE-359).DjangoJSONEncoder._serialize_model_safely serialized every concrete field of a Django Model, so assigning a Model with sensitive fields to a public LiveView attribute (the natural self.user = request.user pattern that djust's own _private/public convention encourages) sent fields such as the password hash and the is_superuser/is_staff privilege flags to the browser — across all client-bound serialization paths (the JIT full-dump fallback, the opt-in state snapshot, and get_state()). Fixed with a denylist applied inside _serialize_model_safely (and its @property / get_* additions), so it covers every path: the built-in floor always drops password, is_superuser, and is_staff; settings.DJUST_SENSITIVE_FIELDS (any iterable) unions project-wide additions on top of the floor; a per-model djust_exclude_fields iterable drops additional fields; a per-model djust_serializable_fieldsallowlist, when present, restricts output to exactly those fields plus the identity keys (pk/id/__str__/__model__) and can opt a floored field back in; and a model-level to_dict() is the full opt-out (the developer takes ownership of the client-bound payload). Setting resolution is defensive — a missing setting or unconfigured Django degrades to the built-in floor and never raises during serialization. As defense-in-depth, the JIT serializer's empty-paths fallback (a whole-object {{ user }} reference, or a public attr never field-referenced in the template) now emits only the identity subset rather than a full field dump — least-exposure, since no field of the object is actually referenced. Template-referenced serialization is unchanged (it flows through the compiled JIT serializer, which already emits only referenced paths). Migration note:password, is_superuser, and is_staff are no longer serialized for any Model by default; add field names to settings.DJUST_SENSITIVE_FIELDS or per-model djust_exclude_fields to drop more, use a per-model djust_serializable_fields allowlist to restrict to an explicit set, or define a model to_dict() to fully control the payload. Regression: python/djust/tests/test_serializer_field_exposure_f19.py — classes TestSensitiveFieldDenylist, TestSettingsOverride, TestPerModelControls, TestToDictOverride, TestJitEmptyPathsFallback, and the gate-off sentinel TestGateOff (reproduce-first + gate-off verified).

  • Added a path-safe accessor for the upload original filename and stopped teaching the raw filename in storage paths (CWE-22 / CWE-73).UploadEntry.client_name is the raw, attacker-controlled original filename, and the UploadEntry/upload-mixin usage docstring taught interpolating it directly into a storage destination key — default_storage.save(f'avatars/{entry.client_name}', entry.file) — a path / object-key injection sink. On FileSystemStorage a value like ../../../etc/x raises SuspiciousFileOperation (500 / DoS); on object stores (S3/GCS/Azure) ../ is a valid key, so the attacker controls the destination object key (overwrite / mis-place of arbitrary objects). The framework already knew the field was dangerous (the internal S3 chunk-writer sanitizes the same value, and system check S007 flags {{ ...client_name|safe }} as a stored-XSS sink), but the developer-facing field had no safe accessor and the docstring taught the unsafe use. Fixed by adding a sanitized property UploadEntry.safe_client_name — Unicode-normalises (NFKC) compatibility lookalikes first (so a fullwidth solidus or fullwidth full stop can't survive as a latent ../../ traversal that a downstream normaliser re-expands), drops every Unicode control/format char (NUL, C0/C1 controls, zero-width, and U+202E bidi-override Trojan-source spoofing — not just ASCII), basename only (strips directory components, including backslash-separated Windows paths), strips leading dots so the result can't become .././a dotfile, strips trailing dots/spaces (Windows strips these at the FS layer, so evil.png. would otherwise collide with evil.png), and falls back to "upload" if nothing safe remains — mirroring the intent of Django's Storage.get_valid_name / werkzeug's secure_filename while preserving ordinary names (my report (1).png stays readable). The class docstring now uses safe_client_name in the default_storage.save(...) example and documents client_name as the raw injection-prone field. client_name itself is unchanged (developers still need the original for display / Content-Disposition). A new Python-AST system check S008 (the path-sink sibling of the template-side S007) flags raw client_name interpolated into a storage .save(...) / os.path.join(...) path, ignoring safe_client_name and plain display interpolation (zero false positives against the demo project). Migration note: use entry.safe_client_name (not entry.client_name) when building any storage path or object-store key; client_name remains the raw original filename for display only and must go through HTML auto-escaping (or escape()) — never |safe (S007). Regression: python/djust/tests/test_upload_safe_client_name.py (33 cases incl. the documented-path-use reproduce, Unicode-lookalike / trailing-dot / bidi-override hardening, a gate-off invariant, and an S008 empirical canary).

  • Rejected browser-executable "active content" uploads by default (CWE-79 / CWE-434). The upload content-validation chain allowlisted SVG as a benign image (MAGIC_BYTES["image/svg+xml"] + EXT_TO_MIME[".svg"]), and validate_magic_bytes is permissive for any MIME it has no signature for. So a script-bearing SVG (<svg onload=…><script>…</script>) — or an HTML/JS payload — passed every check, including the magic-byte step, which actively confirmed it as a valid image. Combined with the common accept="image/*" slot and inline serving, this is a stored-XSS / dangerous-file-upload vector, made worse because the framework's validation gave developers false assurance. Fixed with a fail-closed active-content denylist that is independent of the accept/image/* wildcard: UploadManager.register_entry (primary gate) and UploadManager.complete_upload (finalize defense-in-depth, so a chunked upload can't bypass via a path that skips register) now reject any upload whose declared MIME is in _ACTIVE_CONTENT_MIMES (image/svg+xml, text/html, application/xhtml+xml, application/javascript, text/javascript) or whose filename extension is in _ACTIVE_CONTENT_EXTENSIONS (.svg, .svgz, .html, .htm, .xhtml, .js) — matching on either axis because the attacker controls both. The check is the single public metadata-only helper djust.uploads.is_active_content(client_name, client_type), used by both gates (so it also covers the writer and disk-buffer finalize variants), and it normalises both inputs before the membership check so neither axis can be bypassed: the declared MIME has its parameters stripped (image/svg+xml; charset=utf-8 and image/svg+xml; are matched as image/svg+xml, since browsers ignore the parameter when choosing the renderer), and the filename is run through the same _safe_basename canonicaliser that UploadEntry.safe_client_name uses (extracted into one shared module-level helper) before its suffix is taken — so the gate inspects exactly the basename the storage layer will persist. This closes a self-inconsistent bypass where a trailing-space/dot name (evil.svg / evil.svg.) made the raw Path(...).suffix miss (.svg .svg) while the storage normaliser still wrote it to disk as evil.svg. validate_magic_bytes keeps its permissive-for-unknown default so legitimate txt/csv/json/png/jpg/pdf uploads are unaffected (including with MIME parameters, e.g. text/csv; charset=utf-8) — its docstring now notes it is best-effort format validation and that active content is gated separately. Migration note: uploads of SVG/SVGZ/HTML/XHTML/JS (by MIME or extension, including parameterised MIME types and trailing-dot/space filenames) are now rejected by default, even under an accept="image/*" slot. To accept them, set allow_upload(..., allow_active_content=True) on the slot — at which point you take ownership of the risk: djust does not sanitize the content; sanitize it yourself and/or serve it with a hardened Content-Security-Policy, X-Content-Type-Options: nosniff, and Content-Disposition: attachment (ideally from a separate origin) so a malicious upload cannot execute in the app's origin. Regression: python/djust/tests/test_upload_active_content_f20.py — classes TestActiveContentRejectedByDefault, TestActiveContentOptIn, TestBenignUploadsStillAccepted, TestFinalizeDefenseInDepth, TestMimeParameterBypass, TestFilenameCanonicalisationBypass, TestSvgzExtension, TestGateAgreesWithSafeClientName, TestSafeBasenameSharedHelper, TestIsActiveContentHelper, and the gate-off sentinel TestGateOffSentinel (reproduce-first + gate-off verified: neutering is_active_content makes 25 security cases fail).

  • Escaped + DEBUG-gated the embedded-child render-error path (CWE-79 / CWE-209).LiveViewConsumer._render_embedded_child rolled its own error string in its except block and returned it as the embedded child's subtree HTML (delivered to the client as an embedded_child full-HTML update), bypassing the framework's central, DEBUG-gated create_safe_error_response path. The returned <!-- Error rendering embedded child: {e} --> interpolated the raw exception message unescaped and was not DEBUG-gated, so (a) the exception detail leaked into the live production page (CWE-209) and (b) a message carrying attacker-influenced data containing --> (common in built-in exceptions that echo the offending value, e.g. int()/float()/KeyError) broke out of the HTML comment into live DOM (CWE-79 DOM XSS). Fixed by routing the detail through django.utils.html.escape (neutralising the comment-breakout and any tag injection in both modes — --> becomes --&gt;, <script>/<img become inert entities) and gating it on settings.DEBUG: production now emits a detail-free <!-- Error rendering embedded child -->, mirroring simple_live_view.render_template's DEBUG gate. As defense-in-depth, the {% live_input %} unknown-field_type error comment in templatetags/live_tags.py (a template-author literal, not attacker input) was switched from mark_safe(f"... {field_type!r} ...") to format_html, so the interpolated value is escaped and can't break out of the comment regardless of source. Regression: python/djust/tests/test_embedded_child_error_escape_f18.py (TestEmbeddedChildErrorEscapeF18 — no comment-breakout / no live tags in both DEBUG and prod, prod leaks no exception detail; reproduce-first + gate-off verified).

  • Validated the scheme/origin of server- and data-derived client navigation targets at every window.location.href sink (CWE-601 / CWE-79). The client applied navigation/redirect targets pushed over the wire by assigning window.location.href, but the guard was applied inconsistently across transports (parallel-path drift). The WebSocketnavigate handler had an inline same-origin guard, while the SSEnavigate handler (static/djust/src/03b-sse.js) assigned window.location.href = data.toraw, and the shared live_patch/live_redirect cross-origin fallbacks (static/djust/src/18-navigation.js) — added in #1599 to support legitimate absolute sister-site URLs — performed an origin check (new URL(path, origin).origin !== location.origin) but no scheme check. A javascript: or data: target parses to an opaque origin ("null"), which is !== location.origin, so it passed the cross-origin routing test and was assigned straight to window.location.href, where the browser executes it — an open-redirect (CWE-601) and javascript:/data: DOM-XSS (CWE-79). A natural developer redirect-after-action pattern (self.live_redirect(request.GET.get("next"))) flows attacker-controlled input into the sink. Retired the class structurally (#1646) rather than copying the WS inline guard to N sites: a single shared helper window.djust.safeNavigationTarget(value) (new static/djust/src/02b-safe-nav.js) returns a sanitized string or null — for same-origin absolute paths (/foo, /foo?x=1#h) it re-resolves the candidate through new URL(value, window.location.origin) and accepts ONLY the canonicalized pathname+search+hash if it genuinely resolves same-origin (a raw charAt(1) !== '/' prefix check is NOT enough: the WHATWG URL parser normalizes \/ and strips ASCII tab/newline, so /\evil.com, /\/evil.com, /\t/evil, /\n//evil all resolve cross-origin despite starting /x — these are now rejected), accepts absolute http:/https: URLs (the legitimate #1599 sister-site case), and rejectsjavascript:/data:/vbscript:/blob:/file: schemes, opaque-origin results, protocol-relative (//evil.com) and backslash/control-char off-origin tricks, and unparseable/empty input (warning under globalThis.djustDebug). It is now applied at everylocation.href navigation sink: the SSE navigate handler, the WS navigate/nav.to fallback (replacing the inline same-origin guard so the WS and SSE paths share one implementation and cannot drift apart again), the handleLivePatch/handleLiveRedirect cross-origin fallbacks, and the unresolved-view, dj-patch-reload, and auto-navigate-link fallbacks. As belt-and-suspenders, developers should not pass unvalidated user input to live_redirect(path=...)/live_patch(path=...); the client guard now neutralizes the worst case regardless. Regression: tests/js/safe_nav.test.js (33 cases — the helper across same-origin/http(s)///evil/javascript:/data:/vbscript:/blob:/file:/empty/garbage, plus a backslash/control-char open-redirect battery (/\evil.com, /\/evil.com, /\\evil.com, /\t/evil, /\n//evil all rejected) and a query+hash-preserved accept case proving the re-resolve path; the SSE and live_patch/live_redirect sinks driven with both safe and javascript:/data:///evil/backslash targets; a WS↔SSE same-helper parity pin; and a gate-off proof that removing the guard fails 23 rejection tests, #1468).

  • Bound SSE sessions to their owner and capped SSE session creation (CWE-639 / CWE-862 / CWE-770 / CWE-400). The SSE transport dropped two authorization/abuse controls the WebSocket transport already has. (1) No user-binding (session hijack / missing authorization).DjustSSEStreamView.get created an SSESession keyed by a client-chosensession_id and stored only the client IP, while DjustSSEEventView.post and DjustSSEMessageView.post dispatched on it with no check that the POSTer owns the session — handlers ran with the mounter's captured request.user. So anyone who learned a leaked session_id (URLs routinely leak via access logs, referrers, APM traces) could drive a victim-authenticated SSE view and perform state-changing actions as the victim, without the victim's cookie. The WS transport binds every event to the authenticated connection; SSE dropped that binding. (2) Unbounded session creation (unauthenticated DoS). The stream GET registered the session in the process-global _sse_sessionsbefore mounting, never removed it on mount failure, and had no per-client/global cap and no rate-limit — so a scripted client could allocate unbounded long-lived sessions (each a registered session + queue + view), and sessions accumulated even for auth-required/failed mounts. Fixed by (a) owner-binding: the stream GET captures the creating principal on the session (_owner_user_pk for an authenticated mounter; a Django session key for an anonymous mounter, forced non-None via request.session.save() before reading so anonymous sessions are tied to the browser session cookie), and both POST endpoints re-verify ownership through one shared _request_owns_session(request, session) helper (authenticated → match user pk; anonymous → match session key) before dispatch, returning 403 with no dispatch on mismatch — a single helper used by both endpoints so the two paths cannot drift (#1646); and (b) resource caps: a per-principal/IP concurrency cap (429) keyed by the same owner identity and a global cap on len(_sse_sessions) (503), both module constants overridable via DJUST_SSE_MAX_SESSIONS_PER_CLIENT (default 20) / DJUST_SSE_MAX_SESSIONS_TOTAL (default 10000) and checked before any allocation, plus register-after-mount — the session enters _sse_sessions only after a successful mount, so a failed/unauthorized/redirecting mount leaves no live POST-routable session (the stream still delivers the queued error/navigate frame, then closes). Regression: python/djust/tests/test_sse_session_binding_f24_f25.py (TestF24OwnerBinding, TestF24OwnerCapture, TestF25Caps — cross-user/cross-anonymous POST → 403 + no dispatch, same-user/same-session allowed, GET owner capture + forced anon session key, per-client 429 / global 503 / failed- and unauthorized-mount leave no registered session; reproduce-first + gate-off verified per #1468).

  • Locked the consolidated mount-path security (F22 + F23, above) against parallel-path-drift regression with a transport-parity + chokepoint-structural net (#1646). No production behavior change — this adds the enforcement net that makes the "one transport has a security control the other lacks" mount-path drift class mechanically detectable. See the matching ### Added entry.

Added

  • Added a transport-parity + chokepoint-structural regression net so the WS↔SSE↔runtime "one transport has a control the other lacks" mount-path drift class cannot silently recur (#1646 enforcement half). Two new test modules pin the mount-path consolidation in place. python/djust/tests/test_transport_parity_security.py (TestImportAllowlistParity, TestMountUrlTraversalParity) parametrizes the same attacker payload over every mount entry point — WebSocket handle_mount, generic ViewRuntime.dispatch_mount/_instantiate_view, and SSE _sse_mount_view — and asserts an identical security verdict on each: arbitrary-module view paths (os.system, antigravity.X, subprocess.Popen, plus a side-effecting sentinel module) are rejected without importing on all three; a legit INSTALLED_APPS LiveView mounts on all three; and a mount-URL traversal (/%2e%2e/%2e%2e/admin/, /../../admin/) is neutralised to / with /items/42/ preserved on every request-building transport, which must agree. python/djust/tests/test_mount_chokepoint_structural.py (TestDynamicImportChokepoint, TestSetattrChokepoint, TestFactoryGetChokepoint) is an AST scan of the top-level python/djust/*.py modules with an explicit, comment-justified expected-site allowlist: it fails if a NEW unsanctioned import_module(view_path)/__import__(<non-literal>), setattr(view, <non-literal key>, …), or factory.get(<client URL>) (outside a validate_mount_url-calling function) site appears. Adding a 4th transport is one adapter-registry entry; a regression on an existing one fails a parametrized case loudly. Both suites are non-tautological (gate-off verified per #1468): bypassing the shared resolver/validator on any one transport, or injecting a dummy unsanctioned import/setattr/factory.get site, makes the corresponding case FAIL.

All releases · Atom feed