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 theexpose_apiattack 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.DEBUGis True, (2) the new opt-insettings.DJUST_API_OPENAPI_PUBLICis 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 missingrequest.user(noAuthenticationMiddleware) or an anonymous user falls through to the 404. New setting:DJUST_API_OPENAPI_PUBLIC(defaultFalse) — set toTrueonly if you intend the OpenAPI spec to be readable by unauthenticated clients. Default-behavior change (action required for some apps): withDEBUG=Falseand the setting unset, anonymousGET /djust/api/openapi.jsonnow returns 404 instead of the schema; authenticated developers/integrators and DEBUG/dev environments are unaffected. Regression: new cases inTestOpenAPIGateF29—python/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 WebSockethandle_mountandViewRuntime._build_requestreconstructed the request viaRequestFactory().get(...)with noHTTP_HOST, sorequest.get_host()defaulted to"testserver"on the live path. Host/subdomain resolvers (e.g.SubdomainResolver, which readsrequest.get_host()) therefore misresolved the tenant toNoneover WebSocket — while the HTTP (SSR) initial render, using the real request, resolved the correct tenant. WithSTRICT_MODE=Falsethe tenant-scoped managers then returned unscoped rows (cross-tenant disclosure) in WS event handlers; with the defaultSTRICT_MODE=Truethey returned.none()(broken tenancy, and wrong/no tenant stamped on WS writes). The validated clientHost(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 theHostagainstsettings.ALLOWED_HOSTSusing 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_HOSTSHostfalls back to the prior default (non-browser clients keep working; a spoofedHostgains no tenant authority beyond HTTP). Covered byTestHttpWsTenantParity,TestAllowedHostsBound,TestNoHostFallback,TestSchemePropagation,TestMalformedHostRejected, andTestGateOffinpython/djust/tests/test_ws_host_tenant_f26.py.Unified the per-handler
@rate_limitinto 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_security→rate_limiter.check_handler), so opening N WebSocket connections gave N× 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_keykeyed unauthenticated callers by rawREMOTE_ADDRinstead of the #5-hardenedresolve_client_ip(which honorsDJUST_TRUSTED_PROXY_COUNTand is already used by WS/SSE) — so behind a reverse proxy (standard prod topology) all unauthenticated callers collapsed to oneip:<proxy>bucket (ineffective per-client limiting + mutual DoS), and under a naive XFF→REMOTE_ADDRmiddleware 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 indjust.rate_limit(handler_rate_check(caller_key, handler_name, settings)over anOrderedDictofTokenBuckets keyed(caller_key, handler_name)) is now the sole enforcement point for the per-handler@rate_limit, used by all three transports. A sharedcaller_key(request, client_ip)mirrors the SSE owner-principal identity model (Findings #24/#25):user:<pk>when authenticated, elsesession:<session_key>, elseip:<resolved_ip>— and its IP fallback resolves throughresolve_client_ip(closing F28: per-real-client buckets behind a trusted proxy, no XFF spoof). The WS/SSE/runtime chokepoint_validate_event_securityandapi/dispatch._rate_limit_checkboth route through the shared store;api/dispatch._caller_keynow resolves the IP viaresolve_client_ipand its misleading "REMOTE_ADDR should already reflect the caller via their proxy middleware" comment is removed. The GLOBAL per-message abuse-disconnect (#17) is untouched —ConnectionRateLimiter.check/check_upload/should_disconnect(the connection-floodclose(4429)+ IP cooldown inwebsocket.py:receive) stays per-connection, which is correct for connection-flood control; only the per-handler@rate_limitis unified. Net invariant: a given caller has ONE@rate_limitbudget per handler regardless of connection count or transport. Regression: 11 regression cases inpython/djust/tests/test_ratelimit_per_caller_f27_f28.py(test_f27_*multi-connection/multi-context/cross-transport/two-WebsocketCommunicatorend-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_keyto rawREMOTE_ADDRmakes 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 (
viewdotted path and pageurl) are reached over three transports — WebSockethandle_mount, SSE_sse_mount_view, and the genericViewRuntime.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].ClassNameof valid identifiers — rejecting.., leading/trailing dots, and bad characters), checks an allowlist before importing anything, imports viaimportlib.import_module+ avars(module).get(name)__dict__lookup (nevergetattr, so a client class name cannot trigger a PEP 562__getattr__submodule import — GHSA-7prp-2623-8g45 follow-up), and only then runs theLiveView-subclass check as defense-in-depth. The allowlist now uses module-segment-boundary matching (path == entryorpath.startswith(entry + ".")) instead of the old boundary-lessstartswith(which let["myapp"]admitmyapp_evil.views.Pwn). F23 (SSE traversal): the #1819/#1825 mount-URL validator (validate_mount_url, moved here verbatim;websocket._validate_mount_urlis now a thin alias) is applied inViewRuntime.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 toLIVEVIEW_ALLOWED_MODULESwhen 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 eachINSTALLED_APPSentry plus"djust". This blocks arbitraryos/antigravity/site-packages imports out of the box (the prior default allowed any already-imported module, whichosalways defeats) without breaking the common case of an app that never setLIVEVIEW_ALLOWED_MODULES— legitimateLiveViewclasses live inside installed apps. Apps that mount a lazily-imported view living outside an installed app must now add its module toLIVEVIEW_ALLOWED_MODULES. Regression: 17 regression tests inpython/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 fortype:"server_push"messages, sent bypush_to_view/apush_to_view) applied the messagestatedict via rawsetattr(self.view_instance, key, value)in a loop — while the siblinghandlerfield 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). Rawsetattrlet 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 throughsafe_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 byte0x01/0x02/0x03, len ≥ 17) were dispatched to_handle_upload_frameandreturned inreceive()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 onConnectionRateLimiter(check_upload(); defaultsupload_rate=200/s,upload_burst=400, configurable viaLIVEVIEW_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 tripsshould_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-chunksend_jsonresponse 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_exemptand 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/withcredentials: includeto fire state-changing event handlers as the victim. The@csrf_exemptjustification was false on three counts: the URLsession_idis client-chosen (DjustSSEStreamView.getvalidates only its UUID format, never that the server issued it), so it is not a CSRF token; and a JSON body sent withContent-Type: text/plainis a CORS simple request accepted by the handlers (whichjson.loadsthe 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 requestOriginagainstsettings.ALLOWED_HOSTS(reusingdjust.websocket._is_allowed_origin) and reject cross-origin requests with 403 before any session create/mount or event dispatch — a browser always sendsOriginon a cross-origin request, so an attacker page's origin won't match; same-origin requests pass; non-browser clients (noOrigin) still work. As defense-in-depth, the POST endpoints now requireContent-Type: application/json(415 otherwise), closing thetext/plainsimple-request bypass and forcing a CORS preflight cross-origin.@csrf_exemptis 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-originOriginheader (or noOrigin, for non-browser clients) andContent-Type: application/json. The bundled djust client already sends both. Custom SSE clients that POST withtext/plainor from a different origin must change to sendapplication/jsonand a same-originOrigin(or noOriginfor 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.tenantsisolation was enforced only on the HTTP path. The current tenant was stored inthreading.local()and set exclusively byTenantMiddleware(HTTP-only), so on the live (WebSocket/SSE) pathget_current_tenant()was alwaysNoneduring mount + every event handler — and the tenant-aware managers failed OPEN:TenantQuerySet._filter_by_tenantreturned the unfiltered queryset and never consultedSTRICT_MODE, disclosing every tenant's rows to whoever held the socket.TenantQuerySetadditionallyRecursionError'd whenever a tenant was set (its_chainoverride re-enteredfilter()→_chain→ ...), andModel.objects.all()was unfiltered even with a tenant bound. Fixed four ways: (A) tenant storage is now acontextvars.ContextVar(per async-task), notthreading.local()—threading.localis shared across all connections on asgiref'sthread_sensitivesync_to_asyncexecutor thread (a cross-tenant clobber), whereasContextVaris copied per-call into the executor so each connection stays isolated; the public API (get_current_tenant/set_current_tenant) is preserved and a newtenant_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 (WShandle_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 sharedget_queryset()helper (no recursion;.all()is scoped too) and fail CLOSED — with no tenant bound they return.none()underSTRICT_MODE(the default), matchingTenantManager; unfiltered only whenSTRICT_MODEis explicitlyFalse. (D) New system check S006 warns whenDJUST_TENANTS['STRICT_MODE']is explicitlyFalse(disables fail-closed isolation; risks cross-tenant disclosure). Migration note: storage moved fromthreading.local→ContextVar(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, useModel.objects.unscoped(reason=...)for deliberate cross-tenant reads, or setDJUST_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 theLiveView-subclass check and per-view auth, and theLIVEVIEW_ALLOWED_MODULESguard was fail-open (skipped when unset, the default) with loosestartswithmatching. 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 matchesLIVEVIEW_ALLOWED_MODULESon a module-segment boundary (no longerstartswith). The class is resolved viaimportlib.import_module(nofromlist) +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'sView.dispatch()chain — so standard Django authorization (LoginRequiredMixin,UserPassesTestMixin,@method_decorator(login_required, name="dispatch"), customdispatch()guards) and djust's own admin extension (gated only by the HTTPas_viewwrapper) 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_authnow honors the DjangoAccessMixinfamily (LoginRequiredMixin/PermissionRequiredMixin/UserPassesTestMixin), mirroringhandle_no_permission()semantics and leaving the view'srequestattribute 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 — aLiveViewsubclass with@method_decorator(<auth>, name="dispatch")or an overriddendispatch()that performs authorization itself — pointing the developer at the djustlogin_required/permission_required/check_permissionsattributes or a supported mixin; (3)admin_ext.AdminBaseMixinnow declareslogin_required = True+ acheck_permissionsactive-staff gate so the admin extension is staff-gated on every transport. TheAccessMixinfamily is honored automatically; the decorator/overridden-dispatchforms are surfaced by S004 rather than auto-honored (they're HTTP-only and cannot be replayed without producing anHttpResponse). Regression + detection-canary tests intest_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), SPAurl_changenavigation (ViewRuntime.dispatch_url_change), or{% live_render %}embedded children — so an object-scoped view rendered a denied object on those paths. A shared chokepointdjust.auth.core.enforce_object_permission(no-op for views without a customget_object; raisesPermissionDeniedon denial; fail-closed on aNonerequest or any non-PermissionDeniedexception) is now called from all three: the HTTP render returns 403,url_changesends apermission_deniederror 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-Forvalue unconditionally and used it for per-IP connection limiting + cooldown/ban — a client-controlled identity. An attacker could rotateX-Forwarded-Forto 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 shareddjust._client_ip.resolve_client_ip: by default the real socket peer (REMOTE_ADDR/ ASGIscope["client"]) is used andX-Forwarded-Foris ignored; behind a trusted reverse proxy set the newDJUST_TRUSTED_PROXY_COUNT = Nsetting 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/actionsinks (CWE-79). Built-in component tags rendered a developer/user-supplied URL into anhref/actionattribute withconditional_escape(HTML-entity escaping) but no scheme validation — so ajavascript:URI (which contains no escapable characters) landed verbatim and executed on click. A newdjust.components.templatetags._registry.safe_urlhelper HTML-escapes and neutralizes dangerous schemes (javascript:/vbscript:/data:, including control-char/whitespace-obfuscated variants) to#, while preservinghttp(s)/mailto/tel/ftpand relative/anchor/query URLs. Applied at all 11 navigation-context URL sinks acrossdjust_components.py(breadcrumb,dj_navlink/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.dumpsdoes 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, whereget_debug_info()includesrepr()s of user-controlled public view attributes) andjs.JSChain.__html__(whose docstring also falsely claimed JSON escapes</>). A newdjust.security.escape_json_for_scripthelper translates<,>,&(andU+2028/U+2029) to\uXXXX— matchingdjango.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).ModelBindingMixinis in theLiveViewbase MRO, so every LiveView exposes a default@event_handler update_model(field, value)thatsetattrs a view attribute whose name is client-supplied. The only gates were: reject_-prefixed names, reject the 14-entryFORBIDDEN_MODEL_FIELDSdenylist, optionally require membership inallowed_model_fields(which defaulted toNone= allow ALL public attrs), and requirehasattr. So a client could set any public, existing view attribute —is_admin,account_id,total_price, … — not just the fields actually bound withdj-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 staticdj-model="<field>"binding fromNode::Textliterals (covering{% extends %}and{% include %}), exposed to Python asRustLiveView.dj_model_fields()(and a module-leveldj_model_fields_from_template(source, dirs)for embedded children). This is recorded onself._dj_model_fieldson every render viaModelBindingMixin._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::Variablesubstitution 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|safecontent carrying<input dj-model=…>) cannot widen the allowlist.update_modelis fail-closed — a field is bindable iff it is inself._dj_model_fields(auto-allowlist) OR in an explicitallowed_model_fields(union semantics); the_-prefix /FORBIDDEN_MODEL_FIELDS/hasattrchecks 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_diffis the dominant one (HTTP-GET baseline + every WS mount + every WS event)._dj_model_fieldsis assigned before the_framework_attrssnapshot so it is a framework slot (recomputed each render, never persisted). Migration note: the auto-allowlist now covers staticdj-model=bindings in templates (including{% extends %}/{% include %}). A dynamic bindingdj-model="{{ var }}"(resolved at render time) and any field written purely programmatically are not auto-allowed — add those names toallowed_model_fields. Existing staticdj-model="x"bindings keep working with zero config. Regression:test_update_model_allowlist.py(the three poisoning vectors verified end-to-end throughrender_with_diff, reproduce-first + gate-off verified, plus a real-render integration test) and Rust unit tests incrates/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 thelive_redirect_mountback-navigation path, in lieu of callingmount(). 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'sstate_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 bysafe_setattr's attribute-name regex (which permits ordinary public names). Fixed by signing the snapshot server-side with Django'sTimestampSigner(keyed onSECRET_KEY, salt"djust.state_snapshot") in a new shared helperdjust.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 plaintextpublic_statedict; 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 throughunsign_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 normalmount()withmounted_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 plaintextstate_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 tomount(). The bundled djust client handles the new opaque blob automatically. Any custom client or any view that overrides_capture_snapshot_state/_restore_snapshotmust round-trip the signed blob verbatim (do not re-serialize it) — the single field namestate_jsonstill carries the blob end-to-end. Configure the TTL viaDJUST_STATE_SNAPSHOT_MAX_AGE(seconds, default 3600). Regression:python/djust/tests/test_state_snapshot_signing.py(realWebsocketCommunicatorforge-rejection + signed round-trip, plus tamper / expiry / cross-view / cross-session / anonymous; reproduce-first + gate-off verified) andtests/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 theeval_handlermethod-invocation endpoint) gated onsettings.DEBUGin every view, but the localhost check lived only in the opt-inLocalhostOnlyObservabilityMiddleware— omitted from the documented setup and not auto-installed — so withDEBUG=Trueand the middleware absent (e.g. a0.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_handlernow 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 theMIDDLEWAREentry. 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_safelyserialized every concrete field of a Django Model, so assigning a Model with sensitive fields to a public LiveView attribute (the naturalself.user = request.userpattern that djust's own_private/publicconvention encourages) sent fields such as thepasswordhash and theis_superuser/is_staffprivilege flags to the browser — across all client-bound serialization paths (the JIT full-dump fallback, the opt-in state snapshot, andget_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 dropspassword,is_superuser, andis_staff;settings.DJUST_SENSITIVE_FIELDS(any iterable) unions project-wide additions on top of the floor; a per-modeldjust_exclude_fieldsiterable drops additional fields; a per-modeldjust_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-levelto_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, andis_staffare no longer serialized for any Model by default; add field names tosettings.DJUST_SENSITIVE_FIELDSor per-modeldjust_exclude_fieldsto drop more, use a per-modeldjust_serializable_fieldsallowlist to restrict to an explicit set, or define a modelto_dict()to fully control the payload. Regression:python/djust/tests/test_serializer_field_exposure_f19.py— classesTestSensitiveFieldDenylist,TestSettingsOverride,TestPerModelControls,TestToDictOverride,TestJitEmptyPathsFallback, and the gate-off sentinelTestGateOff(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_nameis the raw, attacker-controlled original filename, and theUploadEntry/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. OnFileSystemStoragea value like../../../etc/xraisesSuspiciousFileOperation(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 propertyUploadEntry.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, andU+202Ebidi-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, soevil.png.would otherwise collide withevil.png), and falls back to"upload"if nothing safe remains — mirroring the intent of Django'sStorage.get_valid_name/ werkzeug'ssecure_filenamewhile preserving ordinary names (my report (1).pngstays readable). The class docstring now usessafe_client_namein thedefault_storage.save(...)example and documentsclient_nameas the raw injection-prone field.client_nameitself 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 rawclient_nameinterpolated into a storage.save(...)/os.path.join(...)path, ignoringsafe_client_nameand plain display interpolation (zero false positives against the demo project). Migration note: useentry.safe_client_name(notentry.client_name) when building any storage path or object-store key;client_nameremains the raw original filename for display only and must go through HTML auto-escaping (orescape()) — 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"]), andvalidate_magic_bytesis 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 commonaccept="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 theaccept/image/*wildcard:UploadManager.register_entry(primary gate) andUploadManager.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 helperdjust.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-8andimage/svg+xml;are matched asimage/svg+xml, since browsers ignore the parameter when choosing the renderer), and the filename is run through the same_safe_basenamecanonicaliser thatUploadEntry.safe_client_nameuses (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 rawPath(...).suffixmiss (.svg≠.svg) while the storage normaliser still wrote it to disk asevil.svg.validate_magic_byteskeeps its permissive-for-unknown default so legitimatetxt/csv/json/png/jpg/pdfuploads 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 anaccept="image/*"slot. To accept them, setallow_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, andContent-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— classesTestActiveContentRejectedByDefault,TestActiveContentOptIn,TestBenignUploadsStillAccepted,TestFinalizeDefenseInDepth,TestMimeParameterBypass,TestFilenameCanonicalisationBypass,TestSvgzExtension,TestGateAgreesWithSafeClientName,TestSafeBasenameSharedHelper,TestIsActiveContentHelper, and the gate-off sentinelTestGateOffSentinel(reproduce-first + gate-off verified: neuteringis_active_contentmakes 25 security cases fail).Escaped + DEBUG-gated the embedded-child render-error path (CWE-79 / CWE-209).
LiveViewConsumer._render_embedded_childrolled its own error string in itsexceptblock and returned it as the embedded child's subtree HTML (delivered to the client as anembedded_childfull-HTML update), bypassing the framework's central, DEBUG-gatedcreate_safe_error_responsepath. 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 throughdjango.utils.html.escape(neutralising the comment-breakout and any tag injection in both modes —-->becomes-->,<script>/<imgbecome inert entities) and gating it onsettings.DEBUG: production now emits a detail-free<!-- Error rendering embedded child -->, mirroringsimple_live_view.render_template's DEBUG gate. As defense-in-depth, the{% live_input %}unknown-field_typeerror comment intemplatetags/live_tags.py(a template-author literal, not attacker input) was switched frommark_safe(f"... {field_type!r} ...")toformat_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.hrefsink (CWE-601 / CWE-79). The client applied navigation/redirect targets pushed over the wire by assigningwindow.location.href, but the guard was applied inconsistently across transports (parallel-path drift). The WebSocketnavigatehandler had an inline same-origin guard, while the SSEnavigatehandler (static/djust/src/03b-sse.js) assignedwindow.location.href = data.toraw, and the sharedlive_patch/live_redirectcross-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. Ajavascript:ordata:target parses to an opaque origin ("null"), which is!== location.origin, so it passed the cross-origin routing test and was assigned straight towindow.location.href, where the browser executes it — an open-redirect (CWE-601) andjavascript:/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 helperwindow.djust.safeNavigationTarget(value)(newstatic/djust/src/02b-safe-nav.js) returns a sanitized string ornull— for same-origin absolute paths (/foo,/foo?x=1#h) it re-resolves the candidate throughnew URL(value, window.location.origin)and accepts ONLY the canonicalizedpathname+search+hashif it genuinely resolves same-origin (a rawcharAt(1) !== '/'prefix check is NOT enough: the WHATWG URL parser normalizes\→/and strips ASCII tab/newline, so/\evil.com,/\/evil.com,/\t/evil,/\n//evilall resolve cross-origin despite starting/x— these are now rejected), accepts absolutehttp:/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 underglobalThis.djustDebug). It is now applied at everylocation.hrefnavigation sink: the SSEnavigatehandler, the WSnavigate/nav.tofallback (replacing the inline same-origin guard so the WS and SSE paths share one implementation and cannot drift apart again), thehandleLivePatch/handleLiveRedirectcross-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 tolive_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//evilall rejected) and a query+hash-preserved accept case proving the re-resolve path; the SSE andlive_patch/live_redirectsinks driven with both safe andjavascript:/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.getcreated anSSESessionkeyed by a client-chosensession_idand stored only the client IP, whileDjustSSEEventView.postandDjustSSEMessageView.postdispatched on it with no check that the POSTer owns the session — handlers ran with the mounter's capturedrequest.user. So anyone who learned a leakedsession_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_pkfor an authenticated mounter; a Django session key for an anonymous mounter, forced non-None viarequest.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 onlen(_sse_sessions)(503), both module constants overridable viaDJUST_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_sessionsonly 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
### Addedentry.
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 — WebSockethandle_mount, genericViewRuntime.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-levelpython/djust/*.pymodules with an explicit, comment-justified expected-site allowlist: it fails if a NEW unsanctionedimport_module(view_path)/__import__(<non-literal>),setattr(view, <non-literal key>, …), orfactory.get(<client URL>)(outside avalidate_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.getsite, makes the corresponding case FAIL.