This is a pre-release. djust 1.3.0 has shipped since.
Part of djust 1.3 — read the 1.3 release notes.
Before you upgrade, read the upgrade guide.
Added
LIVEVIEW_CONFIG["http_status_of_state"]sets the not-found/gone status map for the whole project, and V025 warns about aget_http_status()override that would replace it (#3706).http_status_of_state(#3688) was a class attribute only, so a project with several base views repeated the map on each. A view that declares none now reads the config key; the view's own attribute wins, then the config, and the two are never merged (a view that declares{}opts out of the project's map; neither set answers 200 as before).LiveView.http_status_of_stateis now an empty read-only mapping that compares equal to{}, so a view's own declaration (even{}) is told from "declared nothing" by identity. The key is validated like the form-data limits: when the config loads and on everyconfig.set()/config.update(), it must map state names (str) to statuses (int, 100 to 599); anything else is logged and replaced by no map. V025 (Warning,manage.py check) reports a class that definesget_http_status()while a project-wide map is configured, once per defining class, so per-view copies of the logic do not creep back; suppress it withDJUST_CONFIG = {"suppress_checks": ["V025"]}for an override that callssuper(). The other items of #3706 (V023params_formcheck, the test client drivinghandle_params, the reserved names) shipped in #3722. Not included: lettingmount()raiseHttp404/PermissionDeniedand render the view's own template under that status on every transport, and a per-field fallback forhandle_invalid_params(#3874).self.server_push(),self.live_transportandtick_intervalover SSE (#3809). A view can now push from its own code on any live transport:await self.server_push("handle_x", {...})runs the samepush/server_pushturn the WebSocket runs forpush_to_view()(turn chain, render lock, busy deferral in arrival order, one render, asource="broadcast"frame) on a WebSocket or an SSE session, and is a documented no-op returningFalseon the HTTP render, an HTTP fallback POST's fresh instance,LiveViewTestClient, and a closed or replaced view.self.live_transport("websocket","sse"orNone) answers "is a live transport attached?" and is already set insidemount().tick_interval+handle_tick()now tick over SSE (they stood still), stopped by a close, a navigation or a failed mount.push_to_view()groups, push scopes anddb_notifystay WebSocket-only over SSE; documented inserver-push.mdand thesse.pylimits.live_transportis a reserved name (a read-only property; assigning it in a view raisesAttributeError). Over SSE a tick or push re-checksreauth_on_eventagainst the stored session and user, and server-driven frames are bounded (a tick is skipped and a push deferred while 128 frames are queued unread). Slice 1 of #3809.params_form_fallback = "fields"keeps the fields that validated whenparams_formrejects the URL (#3874). The defaulthandle_invalid_params()handshandle_params()the initial value of every field, so?page=abc&sort=namelost the validsort=namewith the invalidpage. A view that setsparams_form_fallback = "fields"keeps every field that validated (it is not inform.errorsand is inform.cleaned_data, filled frominitialwhere the URL left it out, exactly as on a valid URL) and replaces only the failed ones with their initial value; a failed field's own entry is always its initial value ("initial"is the mode that never hands over a raw request value; aclean()that copies raw request data into a kept field hands it over through that field, as on a valid URL for a field the URL supplied). A form-level error (clean()raising without naming a field) and a URL past the form-data bounds keep nothing, because the fields that passed one by one are the combination the form refused (aclean()that names the field withadd_error()is a field error and keeps the siblings, provided the merged result still passesclean(): it is run once more on a copy of the form with the kept values next to the replaced fields' initial values, and the view gets the plain initial values ifclean()rejects it, so the result is always a combinationclean()accepts, or the plain initials; the fields' ownclean_<field>()validators are not run again on the merged values).params_errorsis unchanged. The default does not change:"initial"stays what a view gets unless it opts in. A value other than"initial"or"fields"raisesImproperlyConfiguredon every request of a view that declaresparams_form, andmanage.py checkreports it as V023, so a typo such as"field"cannot silently mean the default. Guide: Navigation, "Keeping what validated".clean()must be idempotent to use"fields": it is re-run on values it already cleaned (aclean()that multiplies a price by 100 handshandle_params()50000 where a valid URL hands 500), and its output is adopted only for the fields that were kept, so a replaced field is always its initial value.scripts/gate_off_batch.py/make gate-off-batch: a gate-off over many sites in one full test run per batch, not per site (#3905). It applies every mutation of a batch at once, runs the whole test command once, and attributes each failing test to a mutation by the text of its failure (a declarednamessubstring or atest_idsregex). A test claimed by two mutations, a failure nobody claims, a batch that does not collect, hangs or runs away is never guessed onto a member: the affected mutations are re-run alone. It reusesscripts/gate_off.pyfor the bounded run (HUNG and RUNAWAY-MEMORY areCAUGHTwith the measurement), the directory lock and journal, the SHA-256-verified restore and pytest classification (errors counted; a module that did not import isINVALID), and adds a clean-tree guard,--survivors-only,--verify-survivorsand--jobs 2. Documented indocs/testing-strategy.md; the tool is itself gate-off'd inscripts/gate-off-batch-selfcheck.json.
Changed
A Django
Form,BoundFieldor formset crossing to the Rust engine as an object is no longer rendered until the template prints it (#3769). The conversion of a form object calledstr(form)(theas_divrender of every field) andstr(bf)for every field a loop produced, on every render, for every form in the context, whether or not the template read it; a template that never touched the form paid 4-5x the cost of the flattened dict it replaced (#3755).Encoded.displayis now aDisplayText: eager for every other object, and for a form /BoundField/ formset an object that is asked forstr()the first time a sink reads it ({{ form }}, a string filter,|json_script, a loop-cache hash), with the answer shared by every clone of the value. A form printed N times is rendered N times, as under Django; the eager conversion rendered it once before the template printed anything (throughDjustTemplateBackend.from_string(..).render({"form": f}):hellocalledstr(form)once and{{ form }}twice, now 0 and 1) and once more per occurrence.display_safe,str_raisedand (for every opaque carrier, where it was a copy ofdisplay)jsonare derived from that one reading and are methods (Encoded::display(),display_safe(),str_raised(),json()), so a new reader of the text cannot get a half-built answer; the walk floor is unchanged (the asked object is still the floored handle), and the teardown that clears a view's live handles (clear_live_handles) releases the text's own handle too, without running the object's__str__: a text nothing had asked keeps itsrepras the stand-in, so a render of the state AFTER that teardown prints thereprwhere the eager text printed the form (the teardown runs at disconnect and a reconnect re-converts from Python first, so no connected view renders through it; pinned bytest_a_render_after_the_clear_shows_the_stand_in_not_the_form), while a text that was asked keeps its answer. A form whose__str__raises is now an ordinary carrier whose text raises at{{ form }}and nowhere else, as under Django. Behaviour change, towards Django:str(form)validates the form (full_clean()), so reading it at conversion validated every form the engine was handed. Spellings that depend on that accident now print what Django prints for a form nothing validated:{{ form.cleaned_data }}is empty (it was the cleaned data),{{ form|pprint }}saysvalid=Unknown(it saidvalid=True/False), and on a boundModelForm{{ form.instance.x }}is the unmodified instance (it carried the submitted value). A view that callsform.is_valid(), or a template that readsform.errors/form.is_valid, is unaffected. Cost, measured on a release build (CPU time per render,DjustTemplateBackend, a bound invalid form of NCharField(help_text=...)): a template that never reads the form 734 -> 33 us at N=4, 3,240 -> 39 us at N=20, 30,035 -> 97 us at N=200;{{ form.f0 }}796 -> 131 / 3,401 -> 141 / 30,021 -> 312 us;{{ form }}is now Django's own cost (1.9x faster than before, the same as Django's engine). Not removed: a loop over a large form still pays, perBoundField, the walk floor's re-derivation of the form's password-widget fields (serialization._model_form_floor), which is O(fields) and so O(fields^2) over a loop; it is floor code and was left as it is. Known, never worse than before: a form (or a list/dict of forms) passed through|join,|unordered_list,|safeseq,forms|joinor{% regroup forms %}is still read eagerly where Django does not read it; those five spellings render the form's text, as they did.A template path asks "is this a password field?" by NAME, so a loop over a wide form is no longer quadratic (#3816). The password-widget floor of #3798 built the form's whole set of password names, scanning every field, for every
BoundFielda template walked: a{% for f in form %}over a 100-field form was 100 walked fields x 100 scanned fields per pass, +35-50% of a loop-heavy render (5 fields +5-10%, 30 fields +2-7%). A bound field of a plain form is now decided by_password_name_held(fields, name), which is one dict lookup per way of splitting the name into<field><suffix>(so aMultiWidget'spair_0/pair_1keys are still held), and never builds the form's floor; aModelForm's floor carries the names as a read-through view of the form's livefieldsinstead of an eager set. Same answers (a randomized sweep against the scan covers plain, subclass, drawn-as-password andMultiWidgetforms with indexed, keyed and empty-keyed parts), with nothing remembered: it is not a memo, so a widget swapped on a live form (text -> password -> text), a field added, replaced or removed, or a widget'sinput_typechanged in place is followed on the very next step, and no answer can be shared between forms, requests or threads. Release build, loop-heavy templates, interleaved A/B (minimum of 4 rounds each): 100 fields 30.3 -> 17.4 ms (one loop) and 89.2 -> 37.7 ms (three loops), 30 fields 6.5 -> 5.4 ms and 15.9 -> 11.8 ms, 5 fields 1.15 -> 1.13 ms and 2.50 -> 2.40 ms (within noise). A formset's floor, which unions the password names of every form it built, is unchanged (20/100/300 forms showed no measurable difference).Long keys.
form.datais keyed by what the client posted, and the by-name check is a lookup per way of splitting the name, so on its own it is quadratic in a key's length (and cubic whenform.datais filtered, which asks about every--delimited tail of every key): an 8 KB key of dashes pinned a worker for about 30 s. Two guards keep a key's length from deciding how often the form is asked:_without_floor_keysreads the names as a SET once per mapping (as before this change, linear in the key), and_password_name_heldanswers a name past 256 characters with one scan of the form. Measured on a boundUserChangeForm,{{ form.data.username }}with one extra POST key (release):"-" * 30006.2 ms (1,726 ms without the guards, 6.5 ms before #3816);"-" * 800020.9 ms (29,576 ms; 20.7 ms);"a-" * 400012.3 ms (15,038 ms; 12.2 ms).Regression cases in
TestAKeyAClientChoseCostsLinearInItsLength(a count of lookups and scans on 10,000-character plain, dashed and prefixed keys, with exact agreement with the old set),TestAStepDoesNotScanTheForm(afieldsmapping that counts every whole-form read: a step of a 100-field form scans nothing, and the scans of a native loop are the same for 10 fields and for 100),TestAWidgetSwapOnALiveFormIsFollowed,TestNothingIsSharedBetweenFormsandTestSameAnswerAsTheScaninpython/tests/test_password_names_by_name_3816.py.The form-data size limit (
max_form_data_size) is now exact, no member is free, and an event weighs the form's stored data in C passes rather than a Python step per member (#3841). The size is the characters of the keys and values the data would hold after the event, with no extra character for the data itself, and a size equal to the limit is accepted (before, each check added one for its own container, so an event could count two too many and a payload of exactly the limit was refused when nothing else was kept). Behaviour change: an empty string,bytesorbytearraynow counts as one character, not zero. Before,[""] * 15000(about 60 KB of JSON, under the default frame limit) weighed a single character, so 150 suchvalidate_fieldevents stored 2,250,000 empty members and made every later event take over a second; now the size limit bounds how many are kept (a form that declares N fields holds N more characters for their empty initial values). A flat list of containers is no longer pulled whole before it is charged. With the form filled to the cap an event cost 174-279 ms to weigh; it is now 4-30 ms (a list of 320,000 integers: 148 ms to 5 ms; 300,000 integer-valued fields: 200 ms to 13 ms), and an event that replaces a field no longer copies the rest of the data to find out what is left. The verdict is unchanged for every other input (checked against the previous walker on generated values and event sequences).A native component handler uses an explicitly passed argument as given, even when it is empty.
data_table(rows,columns),breadcrumb,stepper,dj_select,dj_radio,autocompleteanddependent_selectnow let an explicit argument win over a context variable of the same name; the render context is consulted only when the argument is absent (the handlers' documented convenience, unchanged). An argument that resolves to nothing counts as passed:{% breadcrumb items=nope %}draws empty, as the Python tag does, where it used to fall back tobreadcrumb_items. A structural test scans the handler modules for the previous shape.
Fixed
The native handlers of nine component tags dropped or invented
dj-*event attributes the Python tag emits (#3669). Under the socket render a click or input handler wired by the first, Django-engine render was missing or renamed. The nine tags, in six families:dj_button;color_picker;pagination;data_table;nav_item;filter_barwithfilter_select,filter_date_rangeandfilter_search.dj_buttonkeptdj-clickonly while enabled, never emitteddj-confirm, and ignoredpreset=(a preset can carryevent/confirm);color_pickerignoredswatches=;paginationdrew no numbered page buttons (each firesnext_eventwith the page asdata-value);data_tableignoredrow_click_eventand theprev_event/next_eventpager shown whentotal_pages > 1withoutpaginate;nav_itemwith aneventand a text body became a dropdown with nodj-click;filter_bartook each control's owneventand never drew the "Clear filters" button. The Python tag is the reference. Forfilter_bar, a native block handler is handed only the rendered children, so eachfilter_*child now leaves its arguments in a comment that the bar reads, removes and draws with the Python tag's own renderer (render_filter_bar), which ownseventandclear_event; a child outside a bar leaves an invisible comment where Django renders nothing; the default id staysfilter-barrather than a per-render random one. Whether a value wires an event is decided on the VALUE, not on its escaped text, soconfirm=None/False/0/[]androw_click_event=Nonewire nothing, as in Python.python/djust/tests/test_component_dj_attr_parity_3669.pyrenders about 95 argument combinations of the twelve tags in the issue through both engines and compares the(element, dj-attribute, value)multiset, plus every falsy shape (None,False,0,0.0,"",[],{}) for every event-bearing parameter, a hostile event-name sweep (quotes,<,&,</script>, U+2028) and a strict-CSP check on both engines.comboboxandtoast_containeralready agreed and are now pinned. Behaviour that stays different, and why:tree_view/kanban_boarddiffer only for aSafeStringoperand (native escapes it, the safe side); the nativedata_tablealso wires about a dozen features the Python template never renders; the native bar and nav item see rendered children, so controls and sub-items inside{% if %}/{% for %}, a bar nested in a bar, and other content in a bar are drawn natively (as before) where the Python tag drops them; the string"0"and a lone space reach a handler as the number 0 and nothing (the engine hands handlers text); a block tag's unresolved-variable argument; anav_itemnested three levels deep; and the Pythondata_tabletemplate's ownsortable: Falsequirk. Markup outsidedj-*(classes, text, ids, ARIA) is #3663.The Rust
Avatarread a URL with a leading control character as having no scheme (#3748).url_scheme()incrates/djust_components/src/simple/avatar.rsremoved only tab, CR and LF before it looked for the scheme, so"\x01blob:...","\x00file:..."and"\x1fintent:..."failed the leading-letter test and reached<img src>as written, whereurl_attrreturns#. It now strips leading C0 controls and space first, as Python'surlsplitand a browser do. The recorded gap (KNOWN_RUST_MIRROR_GAPS) in the sweep test is gone, the sweep corpus gained leading-controlblob:/file:/intent:payloads, andpython/tests/test_avatar_rust_scheme_parity_3748.pycompares the Rust avatar withurl_attrover every C0 control in every position and a seeded random sweep. The markdown renderer's three-scheme denylist is unchanged; the measurement is on the issue.A Django
Form/BoundFieldbound by{% for %}or{% with %}renders under the Rust engine as it does under Django (#3755).{% for f in form %}{% field_error field=f %}{% endfor %},{% with f=form.name %}{% field_error field=f %}{% endwith %}and{% for f in form %}{{ f.errors }}{% endfor %}rendered nothing under the Rust engine and the errors under Django. The plain-backend render path flattened a form to{name: SafeString(widget HTML)}before the engine saw it, which keeps{{ form.name }}working and loses what a form is: iterating it yielded the field names ({{ f.label }}was empty),f.errors/f.field.requiredread attributes a string does not have,form|lengthwas the number of fields (Django: 0) and{{ form }}printed a dict. Withfor_render=Truethe form now crosses as itself (template.serialization.serialize_value); the JSON-state paths (normalize_django_value, the LiveView bridge) still flatten. Four engine rules came with it, each measured against Django: (1) a bridged Python tag receives a liveBoundFieldalthough aBoundFieldis iterable (context::live_handle_is_form_object, decided bydjust.serialization._is_form_object). This is a new spelling of a reach that already existed, not a new reach: a bridged tag already got the live form under its own name, and the bridge has no serialization floor, so{% some_bridged_tag x=f.form.instance.password %}now prints whatx=form.instance.passwordalready printed (#3771; the PR's first version of this line said "adds no exposure", which was wrong forjson_viewer, since fixed by keeping it on the floored native path, #3756). (2) Alistsubclass that overrides__str__/__repr__(Django'sErrorList) crosses as the carrier whose{{ v }}isstr(o)(list_subclass_spells_itself, the list half of #2899), so{{ f.errors }}is<ul class="errorlist">…</ul>and not['This field is required.']; for|json_scriptthe carrier is the array, as the dict-subclass one is the object (Encoded::live_sequence_value), so{{ form.errors|json_script:'e' }}is the errors as JSON. (3) A top-levelErrorList/ErrorDictin the context is no longer rebuilt as a plain container byserialize_value(limited to subclasses that define__html__; a top-levelOrderedDictis rebuilt as before). (4) An object whosestr()/iter()raised at conversion stays iterable by TYPE, so{% for f in form %}over a form with one broken widget renders the loop and the real exception appears where the broken field is rendered, as in Django, instead of'Form' object is not iterable. AForm/BoundFieldis also carried like a one-shot iterator (handle live, items read at the sink), which removes the eager subwidget enumeration. Behaviour change:{{ errors }}for a top-levelErrorList/ErrorDict, and anylistsubclass with its own__str__/__repr__reached through an object, now print what Django prints (the errorlist markup) instead of a list / dict display. Cost: a form in the context of a template rendered throughDjustTemplateBackendcosts more per render than the flattened dict did (0.86 ms against 0.31 ms for four fields, 3.5 ms against 1.3 ms for twenty, release build), because the engine converts an object eagerly,str(form)included; the LiveView path is unaffected; tracked at #3769. Not covered here: a form held as LiveView state still reached the template as{name: widget HTML}, because LiveView state is JSON (fixed in #3767).{% json_viewer data=v %}renders adatetime/date/Decimalas Django does (#3756). The Python tag dispatches onisinstance(value, ...)and spells every type it does not know asstr(value)with no type modifier (<span class="dj-json__value">2024-03-15</span>); the native handler was handed the engine's stand-in for a value it cannot carry (str(datetime), afloatfor aDecimal) and dispatched on that, printingdj-json__value--stringwith quotes and--number. The same went for aUUID, anEnum, aPath,bytes, asetand atuple, and for adatetime/date/Decimal/UUIDnested in a dict or a list. A handler can now declareTYPED_ARGS = True(rust_handlers.TYPED_ARGUMENT_TAGS, read byregistry.rsat registration): the engine resolves eachkey=<expression>operand through the same floored walk{{ }}uses (get_value, which re-protects every segment of a dotted path) and hands the typed Python value to the handler in the context under a reserved key;json_viewerreads itsdatafrom there, with the operand's runtime-safe flag ({% with p=x|safe %}, amark_safed value) carried through the channel so a safe operand is raw as in Django (typed_operandsusesget_value_safe; without it the flag was dropped and the operand over-escaped; a hostile unsafe value stays escaped, tree and raw<script>copy, byte for byte against Django,python/tests/test_typed_args_provenance_3770.py). It deliberately stays native. A first version of this change moved it onto the{% load %}bridge so Django's own node could dispatch on the type, and the bridge walks raw objects with no serialization floor:{% json_viewer data=form.instance.password %},data=f.form.instance.passwordin a form loop,data=form.fields.owner.queryset.first.password,data=obj.user.password,data=obj.users.first.passwordanddata=view.user.get_session_auth_hashall printed the hash, which{{ }}and the native handler refuse. Every one of those is a test now (python/tests/test_typed_args_floor_3756.py). The tags that depend on a value's type are measured, not guessed (scripts/lib/template_library_parity/typed_values.py, 208 tags x 15 types): the Python tag is run on a value and on the stand-in the engine would hand a handler, and a tag whose output differs depends on the type. Of the 781 type-sensitive cells, the tags still wrong for a type a template realistically passes (datetime,date,time,timedelta,Decimal,Enum,Path) are four, all a date or path in a CSS-class / URL slot (theme_mode_toggle,theme_preset_selector,theme_switcher,map_picker); they are recorded inKNOWN_TYPE_LOSS, which the test compares with the measurement in both directions (an entry that stops being wrong fails; a new loss fails until it is looked at), and the remaining isolated cells (a tuple, UUID, bytes or set in a text or number slot, and a tuple holding anOrderedDict) are tracked at #3768. A tag rendered through the bridge still has no floor (#3771).A Django
Formheld as LiveView state reaches the template as the form, not as{field_name: widget HTML}(#3767).LiveViewstate is JSON, so_sync_state_to_rustflattened aBaseFormheld on the view to{name: SafeString(str(form[name]))}(serialization.render_form_value) and left it out of the raw-Python sidecar. That keeps{{ form.name }}and loses what a form is:{{ form.name.errors }},{% field_error field=form.name %}and{% for f in form %}{% field_error field=f %}{% endfor %}rendered nothing,{% for f in form %}{{ f.label }}iterated the field names,{{ form.is_bound }}and{{ form|length }}read a dict, and{{ form }}printed it, whileDjustTemplateBackend(since #3755) and Django rendered all of them. The cure is not one of the three options the issue lists, and it needs no Rust change. All three assume the form must also be in the Rust state (flattened, or as a handle-bearing value that is diffed, re-sent and serialised on every event). The engine already has a channel for a live object that is not state: the raw-Python sidecar, resolved lazily at the lookup, per segment, under the serialization floor. So a context name for whichdjust.serialization.holds_formis true — aForm, aBoundField, a formset, or adict/list/tupleholding one (up to four containers deep: a wizard'ssteps,{"login": ..., "signup": ...}) — is kept out of the state and rides the sidecar only, so the value stack has nothing to answer first. The same decision is read at every entry that hands the engine a state dict and the raw objects separately:_sync_state_to_rust(HTTP GET, WebSocket mount and event, SSE and the runtime all call it), the page shell (render_full_template, both branches) and atemplate_namecomponent. The set ofupdate_statecall sites is pinned from the source. Cost, measured on a release build (an event that assigns a new form and renders; min of 5 batches of 150; the figures are for 4, 20 and 60 fields): a template that reads a field ({{ form.f0 }},{{ form.f0.errors }}) goes from 1.2 / 2.1 / 4.6 ms to 1.0 / 1.2 / 1.4 ms, and one that never touches the form from 1.2 / 2.3 / 4.9 ms to 0.9 / 1.0 / 1.4 ms, because only what the template touches is converted and the per-field flatten is gone; an event that does not change the form costs the same as before (0.7-0.9 ms either way) for those templates only. The one thing that got slower is the whole-form spelling:{% for f in form %}{{ f }}{% endfor %}goes from 1.1 / 2.1 / 4.5 ms to 2.0 / 6.0 / 15.2 ms and{{ form }}from 1.1 / 2.3 / 5.1 ms to 1.7 / 4.5 / 10.4 ms. That is the engine's eager conversion of a form object (str(form), theas_divrender), the cost #3769 measures onDjustTemplateBackend(6.3 ms for the loop at 20 fields), and those spellings printed the wrong thing before (the loop yielded the field names,{{ form }}a dict). It is also paid by an event that does NOT change the form: the form is a sidecar object, so the engine cannot see that it is the same one and converts it again on every render that evaluates the whole-form node (measured by the review:{% for f in form %}{{ f }}0.8 / 0.8 / 0.9 ms before against 1.8 / 5.6 / 15.9 ms after at 4 / 20 / 60 fields,{{ form }}0.8 / 0.9 / 0.9 against 1.4 / 3.4 / 8.1; a probe of mine through the partial-render entry (render_with_patches) did not show it, a fullrender()did, so it depends on whether the node is re-evaluated). #3769 (a lazyEncoded.display, its PR in progress) is the cure and is unchanged by this fix. The floor holds on every route: the form crosses as the already-floored proxy (#3790, #3798), andpython/tests/test_form_floor_differential_3790.pynow runs its whole grammar (4998 templates in 213 groups) against a root form held as an attribute of aLiveView, asserting both that no part of the secret appears and that the render equals the plain backend's. That also closes a flattened-copy exposure the old shape had for theDJUST_SENSITIVE_FIELDStext field:{{ form|pprint }}and{% for k, x in form.items %}{{ x }}{% endfor %}printed the control'svalue="…"for a held form and a held formset, which the plain backend never did. What does not change: JSON state.get_state(), the session save and the signed snapshot usenormalize_django_value, which still flattens (state_roundtrip=True) and still treats a form attribute as the non-serialisable value it always was; a form restored from the session (enable_state_snapshot, the HTTP POST fallback) is therefore still the JSON dict. Behaviour change: a view that keepsformset.management_formdirectly as an attribute used to flatten it (no data readable); it now reaches the template as the form, a plainFormwith no pinned floor (#3799), so{{ mf.data }}prints the formset's whole submitted data, as on the plain backend (docs/SECURE_DEFAULTS.md, pinned by a test). Not covered: a form nested more than four containers deep, or held by a container that is not adict/list/tuple(adeque, aset, aQuerySet), stays flattened as before; the Tokio-actor transport's MOUNT frame hands the enginestr(form)(identical before this change; the render after an event is correct), pinned as a strictxfail.A native component tag gets a tuple, a UUID and a Path as the value it is, not the engine's stand-in (#3768). The Rust engine hands a native handler its operands as text or JSON, so
{% approval_gate reject_label=v %}overv = (1, 2)printed[1, 2]where Django's tag prints(1, 2), and{% live_counter value=v %}over aUUIDshowed0where Django countsint(uuid).scripts/lib/template_library_parity/typed_values.pymeasured 495 such cells (cells where the Python tag renders a value differently from its stand-in, and the engines agree on the stand-in) over 208 tags, 15 value types and 21,390 cells; the count is now 45, 450 fewer. Over ALL 21,390 cells (Rust output equal to Django's, exact status and text, base against this change) 517 more now agree, 0 newly disagree, 15,341 agreed before and still do, and 5,532 still differ for reasons unrelated to a stand-in. Every inline and block component handler now declaresTYPED_ARGS(one wrapper at registration, not a class attribute on ~180 of them) and_parse_args, the one place they read their operands, takes back aUUID, aPathand atuple. A tuple comes back asTupleValue, alistsubclass that prints like the tuple: ~55 handlers read a list slot withisinstance(x, list), and a realtupleturnedrows=my_tuple_of_dictsinto an empty table (the first version of this fix did that todependent_select; a measurement over 13 probe shapes found it). Both the conversion and the printing walk an explicit stack: a tuple nested 500 deep rendered before this change, and a recursive walk raisedRecursionErrorfor it. (Past ~1000 levels the engine's own context serializer raises on the unchanged code, before any handler runs.) The block tag needed the engine's twin of the inline channel (add_typed_operands, the one function both call sites use). A wrapped handler also declaresTYPED_ARGS_STANDINS_ONLY, so the engine hands over only a tuple or an object operand and a large list or dict is not materialised a second time as Python objects. Behaviour change: aUUIDorPathgiven to a parameter that takes a list or text used to render (the handler iterated the value's text as characters) and now raisesTypeError, as Django's tag does, foranimated_number separator=,carousel images=,data_grid columns=/rows=,multi_select options=/selected=,notification_center notifications=,table_of_contents items=,tag_input suggestions=/tags=andtree_view nodes=(11 parameters of 8 tags, each for both types;test_both_engines_raise_the_same_error). Cost, release build, base against this change, one process per case, a loaded machine: a 100,000-rowdata_table2,301 / 2,273 ms to 2,311 / 2,319 ms (+0.4% / +2.0%), peak RSS 746 / 747 MB to 772 / 772 MB (+3.5%); a 1,000-pointbar_chart+0.8% / +1.5%, RSS unchanged; a scalar tag about +3 to +5 µs per call (badge33 / 34 µs to 36 µs,modal38 µs to 41 / 43 µs). A 2,000,000-element tuple operand, the case the recovery exists for, is materialised as Python objects again: 5,027 / 5,022 ms to 5,409 / 5,416 ms (+7.6% / +7.8%), peak RSS 1,122 / 1,235 MB to 2,220 / 1,670 MB (the two rounds disagree; linear in the tuple). The first version handed every operand over and cost +25% RSS on the 100,000-row table; the narrow ask removed it. Recorded, not fixed, and pinned exactly so the number cannot grow (RESIDUAL_STANDIN_LOSS): a tuple in a slot that takes a list (the native handler stays more lenient than Django's strict tag),set/frozenset/bytes(an iterable crosses to a handler as the engine's wrapper, so its type cannot be recovered), a date in thetheme_*CSS-class slots, and aUUIDin an argumentnotification_centerignores.python/tests/test_standin_types_3768.py, gate-off verified.{% for g in u.groups.all %}{{ g.name }}{% endfor %}over a QuerySet renders the related rows on a LiveView and underDjustTemplateBackend, as Django and_rust.render_templatedo (#3777). The Rust queryset serializer (serialize_object_with_pathsincrates/djust_live/src/lib.rs) walked the extracted pathsgroups.all/groups.all.namewithgetattr:groupsis the related manager andallis its bound method, which has noname, so it shipped{"groups": {"all": {}}}and the loop ran over an empty dict. The Python codegen twin (python/djust/optimization/codegen.py) has always iterated.all(); the Rust one never did, and the key-count fallback to that codegen cannot fire because thegroupskey is present. Only a QuerySet was affected, so alist[Model]of the same rows rendered the names. Sibling spellings of the same missing step were wrong too and are fixed with it:{{ u.groups.all|length }}counted the characters of the QuerySet'srepr(41 for a two-row relation),{% if u.groups.all %}was true for an empty relation, andu.groups.all.0.namewas empty. This holds for a custom QuerySet class (SomeQS.as_manager(), which a related manager'sall()hands back) as well as a plain one: the check is anisinstance, not a class-name test. Every row reached through.allgoes through the serialization floor exactly as a row reached any other way does (password,is_superuser,is_staff, a model'sdjust_exclude_fieldsanddjust_serializable_fields), at every depth of a chain. A calledallstill goes through the onetemplate_call_guard(#3772), so a related manager whoseallis markedalters_dataordo_not_call_in_templatesis never run and its name is omitted. A raisingall()is no longer swallowed: only the two exceptions the codegen catches (TypeError,AttributeError) become an empty list; a database error propagates to the caller's existing[JIT ERROR]handler, which logs it and falls back, instead of rendering a user with no groups. Rows that are plaindict/list/tupledata (a model method namedall) keep their data. Pinned throughDjustTemplateBackend, a LiveView's HTTP GET, its WebSocket mount and the serializer itself, with Django's engine as the control and real rows (python/tests/test_queryset_all_traversal_3777.py,python/tests/test_queryset_all_floor_and_errors_3777.py). Same-symptom paths this does not reach (a QuerySet nested in a dict value,.all.count/.all.first, no row cap) are tracked at #3788.A QuerySet inside a dict or a list of dicts renders its related rows on a LiveView,
{{ u.groups.all.count }}/.all.exists/.all.first.namerender instead of empty, and a calledallhas a row cap (#3788). Three same-symptom paths #3785 (the #3777 fix) did not reach, each re-measured against the real render path before the fix. (1){"d": {"users": qs}}with{% for g in x.groups.all %}rendered empty while{{ x.username }}worked:get_context_dataonly read the template when a TOP-LEVEL value was a Model / QuerySet, and the JIT looked its paths up by the dotted named.users, which the extraction map (keyed by root name) does not have, so the value fell to the flat field-dict serializer. The template source is now read on the first nested DB value (a context with none pays onetype()lookup per value, not a template read), a dotted name resolves to its root's paths without the leading segments (_paths_for_variable, one resolver for the QuerySet, model and model-list producers), a list of dicts is walked like a dict, and the generated serializer's function name is sanitised (serialize_d.users_…did not compile). The same lookup fixed a Model and a model list inside a dict beside a top-level QuerySet, which rendered{{ d.user.username }}empty from the identity-only map. (2)allread only through QuerySet methods is shipped as{"count": n, "exists": b, "first": {...}, "last": {...}}, each method run on the QuerySetall()returned (COUNT,EXISTS,LIMIT 1) under the one call guard, afirst/lastrow through the serialization floor (and, when the row is also read whole,{{ u.groups.all.first }}beside.first.name, carrying the__str__a serialized model carries, so it prints its text rather than a dict). The tree cannot tellu.groups.all.countfrom{% for i in o.items.all %}{{ i.count }}, so the method reading is used only when the template does not read the relation as rows (a whole-path mark,with_whole_marks/ALL_WHOLE_KEY), every child is a method name, and the row model has no attribute of that name. Rust (serialize_all_methods) and the Python codegen twin (all_methods_value) are compared intest_rust_and_codegen_ship_the_same_methods; the codegenallstep now also honoursalters_data/do_not_call_in_templates, as the Rust one does. (3)LIVEVIEW_CONFIG["queryset_all_row_cap"](default 100,000;0, or any value of 2**62 or more, removes it;Noneis the same as unset, i.e. the default; a non-integer is logged and replaced by the default) bounds the rows read through a calledall(qs[:cap + 1]: a SQLLIMITon an unevaluated relation) in both serializers, logs a warning when a relation is cut, and never applies to.count/.exists/.first/.last. A container is searched four containers deep; below that the QuerySet is left as the view built it, a flat field dict, so its relations render empty with no error. The cap bounds the work the serializer does, not memory: the optimizer still prefetches the whole relation first. Not fixed, tracked at #3801: a template that reads the same.allas rows AND through a method still renders the method empty on a LiveView (a loop next to.all.count,{{ u.groups.all|length }}/{{ u.groups.all.count }},{% if u.groups.all %}{{ u.groups.all.count }},{% with gs=u.groups.all %}{{ gs.count }},|sliceplus.count); a model with acount/exists/first/lastfield keepsalla list of rows, so.all.countstays empty there, and on alist[Model]the Python serializer drops a row's integercount/existsfield (pre-existing);{{ g }}beside{{ g.name }}inside a loop over.allprints a dict (pre-existing, the loop's iterable cannot be told from a whole read); the extractor erases the index of.all.0.name; the relation prefetch loads all rows regardless of what the serializer ships; and a leaf-onlyallrunsstr(row)for every row. Pinned throughDjustTemplateBackend, a LiveView mount, an event re-render, an HTTP GET and a WebSocket mount, with Django's engine as the control (python/tests/test_queryset_all_followups_3788.py).A relation read as rows AND through a QuerySet method renders the method on a LiveView, a row's integer
countfield survives the Python codegen, and a Model past the nesting bound is reported (#3801). (1){% for g in u.groups.all %}beside{{ u.groups.all.count }}rendered[:staff,ops,]where Django renders[2:staff,ops,]: both extract to onegroups.allnode and one serialized value cannot be both the list the loop iterates and the object.countreads. The same held for|lengthplus.count,{% if %}plus.count,{% with gs=u.groups.all %}{{ gs.count }},|sliceplus.count,.first/.last/.existsbeside a loop, and a row model with acount/firstfield. The evaluated rows of exactly such a name (a top-level QuerySet orlist[Model];codegen.all_methods_need_rows) now ride the render sidecar and the engine's guarded getattr walk answers the method from them, the walkDjustTemplateBackendalready used: a method markedalters_dataordo_not_call_in_templatesis still never called, and the serialization floor still governs every row. A plain loop and a method-only read keep no rows. (4) The Python codegen's leaf branch called a leaf namedcount/exists/allas a method even when it was an integer field, so alist[Model]row lost itscount; only a callable is called now. (7) A Model or QuerySet directly inside the first container past_NESTED_DB_DEPTHis left as a flat field dict (relations render empty); that is now logged once per process, when the container is reached from a list-rooted value (a dict-rooted shape is not reported). Not done, filed as #3862: the erased index of.all.0.nameand the relation prefetch (2),str(row)per row in the leaf shape (3),{{ g }}beside{{ g.name }}printing a dict (6), and the mixed read on a QuerySet nested in a dict._rust.serialize_contextno longer overflows the stack on a symmetrical self-referential relation, a container that holds itself, or a very long chain of related rows (#3814). WithPerson.friends = ManyToManyField("self")and A <-> B,_rust.serialize_context({"p": a})ended the interpreter withFatal Python error: Segmentation fault: the all-fields model arm builds a model's dict withmodel_to_dictand recurses into every value, and a relation hands back a FRESH instance of the row it came from, so no identity check ended it.guard_paniccannot catch that (it is the process's stack, not a Rust panic), and a list or dict that contains itself did the same. Every recursive step now goes through one function,serialize_nested, which fails CLOSED tostr(obj): into a container or a model row (class + primary key) the walk is already inside of, or past a depth ceiling (128 levels, for a chain of DISTINCT rows). The row check runs beforemodel_to_dict, so the query that would fetch the next ring of the cycle is not made. A row reached from two different parents is not a cycle: the path is popped on the way out. Before, in a bounded child process, the model, list and dict cases exit with SIGSEGV and the non-strrender()raises; after, all exit normally with bounded output.A
render()that returns a non-str(anint,None) no longer raisesTypeErrorout ofserialize_context; it falls back tostr(obj)exactly as arender()that raises does.The production serializers were checked for the same shape and do not have it, so none was changed.
serialize_object_with_paths/serialize_querysetwalk the template's field-path tree, which is finite whatever the data is (andqueryset_template_valuecarries its own 64-level cap), and the Python encoder is capped atserialization_max_depth. Run against the same relation throughserialize_queryset, theDjustTemplateBackend({{ p.friends.all.0.friends.all.0.friends.all.0.name }}, a{% for %}overp.friends.all, a bare{{ p }}), a LiveView attribute and the encoder, each exits normally on the unchanged build as well.serialize_contexthas no production caller (#3778).Behaviour changes to know about.
- Nesting past 128 levels now crosses as
str(). A level is a container, a model, or a model's dict, so a chain of related rows is cut after about 42 rows (three levels per row) and a list nested 129 deep is cut at the 129th. Before, such a structure was returned whole up to the Python recursion limit (or crashed the process if it was cyclic or long enough). The ceiling is nesting, not width: a flat list of any length is untouched. - The fallback is
str(obj), so an object whose__str__raises now makesserialize_contextraise at a cycle or at the ceiling (before: a segfault), and a__str__that embeds a field value puts that value in the output. A model's__str__is public text by contract (#3806), as it is everywhere else it is rendered. - Stack: the worst chain (a model per three levels, up to the ceiling) needs about 112 KB of native stack in a release build (measured in the review of this change, where 96 KB crashed with SIGBUS). That is a large margin on the default thread stacks (8-16 MiB), but a thread started with a very small stack would have less.
Regression cases in
python/tests/test_serialize_context_cycles_3814.py, each run in a child process watched on the clock and on memory (a segfault is exit-11), with a control that proves the harness sees a crash. The row check is pinned to run beforemodel_to_dictby a query count (6 for A <-> B, 9 if the third row were read), a flat list of 1,000 and two classes sharing a primary key pin that the ceiling and the row key are not over-eager. Each mechanism (container check, model-row check, ordering beforemodel_to_dict, depth ceiling and its decrement, the (class, pk) key, the shared chokepoint, the pop, the non-strfallback) is gate-off tested separately. Tuples are not tracked (a tuple cannot hold itself without a list or dict between, and those are).- Nesting past 128 levels now crosses as
A dict
lazy=on{% live_render %}works under the native (Rust) engine (#3838).{% live_render "app.views.Child" lazy=cfg %}withcfg = {"placeholder": ..., "trigger": "visible"}rendered under the Django engine but raisedTemplateSyntaxError: lazy= accepts True, 'visible', or a dict - got '{"placeholder":...}'under the native one, because the engine hands a handler a structured operand as a JSON string.LiveRenderTagHandlernow declaresTYPED_ARGS(the channeljson_vieweruses, #3756) and readslazyas the value the engine resolved, not the string decoded back: a string cannot carrySafeString-ness, and the #3819 placeholder contract depends on it. Amark_safeplaceholder is markup and a plainstris text on both engines, byte for byte. The same read means a string variable holding the text'visible'(quotes inside) is refused as Django refuses it; the native handler used to strip the quotes and accept it. Onlylazyis read this way; the other keyword operands are the child'smount()kwargs and cross as they always did.TYPED_ARGS_KEYnow lives indjust.template_tags(re-exported bydjust.components.rust_handlers).tests/unit/test_native_live_render_lazy_dict_3838.py, gate-off verified (6 mutations).A namedtuple that cannot be rebuilt for rendering is logged through
log_failure(#3849). The debug log passed the user's exception to the logger directly, whichtest_log_exposure_pinforbids (ADR-038); the exception text now ridesexc_infoand only where diagnostics are allowed.The engine remembers a namedtuple shape
collections.namedtuple()refused, and "cached per shape" is now tested (#3849). Follow-up to #3856 from its independent review. The engine rebuilds a namedtuple's class for a native tag withcollections.namedtuple(name, fields)(named_tuple_class,crates/djust_core/src/lib.rs) and cached only a shape it BUILT: a shape the factory refused (arename=Trueduplicate, a leading underscore, a keyword, a non-identifier name) re-ran the factory, anexecthat raises, on every conversion (115 ms against 92 ms per 20,000 refused rows). A refusal (ValueError, or theSyntaxErrornamedtuple()raises for fields equal only after NFKC normalisation, which was not cached either and escaped the Python shape gate, failing the render) is a fact about the shape and is cached as such; no shape raisesTypeError, so it is no longer caught; any other failure (MemoryError, an exception from a patched factory) is not, and is tried again; the value still falls back to the plain tuple either way. Thelru_cacheon the Python shape gate (_namedtuple_shape_is_buildable_cached) had no test; both caches are now pinned by COUNTED work, the number of times the factory runs for a shape over 25 conversions (python/tests/test_namedtuple_shape_cache_3849.py), not a duration. Recorded, not changed, with the measurement that decided each: a namedtuple (or a plain tuple,UUID,Path) INSIDE a list or dict operand is still its JSON text ([[1, 2]]where Django prints[P(x=1, y=2)]; handing over a container that holds one keeps the whole operand in memory a second time, +24% peak RSS on a 100,000-row list, time unchanged), pinned by strictxfails inpython/tests/test_namedtuple_in_container_operand_3849.py. The custom-__repr__and tuple-depth items of the review stay recorded as in #3856.A native component tag gets a namedtuple as the namedtuple it is, and recovering a large tuple does less counted work (#3849). Follow-ups to #3843 (#3768). (1)
{% approval_gate reject_label=v %}overP(1, 2)printed(1, 2)on the Django-backend engine ([1, 2]before #3843; Django printsP(x=1, y=2)), and seven cells flipped from their default to(1, 2):dashboard_gridgap/row_height,map_pickerheight,resizable_panelinitial_size/min_size/max_size,sortable_gridgap. Two causes, one per layer:serialize_value(for_render=True)rebuilt every tuple astuple(items)(a namedtuple is now rebuilt positionally and keeps its type, so{{ v }}printsP(x=1, y=2)as Django does), and the engine's narrow ask (TYPED_ARGS_STANDINS_ONLY) handed over aValue::Tuplebut not aValue::NamedTuple(it does now)._parse_argsreturns aNamedTupleValue, aTupleValuethat is alist(theisinstance(x, list)slots keep working) and printsP(x=1, y=2); a namedtuple nested in a tuple is converted too. A namedtuple the engine cannot rebuild never fails a render:collections.namedtuplerefuses some shapes a real class can have (arename=Trueduplicate is('a', '_1', 'b')and a leading underscore is refused; a keyword or non-identifier field; a class whose name is not an identifier), and the engine's conversion of the context back to Python for a native tag raised for ANY tag in a template that merely held such a value ({% static %},{% now %},{% badge %}), operand or not.serialize_valuekeeps the type only whennamedtuple(name, fields)accepts the shape (the real constructor is the test, cached per shape), and the engine falls back to the plain tuple when it cannot build the class (named_tuple_or_tuple,crates/djust_core/src/lib.rs), for a value that reaches it without passingserialize_value(RustLiveView.update_state). The rebuild runs the class's own__new__on already-serialized values (a lazy string is astrby then), so ANY exception it raises degrades to the plain tuple, logged at debug. Pinned for every shape on both native entry points (the Django-backend engine andRustLiveView) against Django's own tag: plain, namedtuple (one field, none), tuple in namedtuple and the reverse, nested, empty, one-element and a tuple subclass (python/tests/test_typed_tuple_shapes_3849.py), and for each refused shape (python/tests/test_namedtuple_refused_shapes_3849.py). Recorded, not changed: a tuple subclass that overrides__repr__and a namedtuple with a custom__repr__print the default (the engine keeps no class); a tuple held as LiveView state is a list before the engine (normalize_django_value);{{ rows|dictsort:'x' }}/|dictsortreversedover a list of namedtuples still render EMPTY natively where Django sorts them (unchanged). (2) The review of #3843 measured a 2,000,000-element tuple at +34% time and +39% RSS, more than #3843 stated. The avoidable work is gone and pinned by COUNTED work, not a duration:_parse_argsno longer decodes the JSON text of an operand the typed value replaces (the test assertsjson.loadsnever sees it), and_from_typedcopies a tuple with no tuple member in one C-level pass instead of a Python loop per element (the test asserts the executed source lines are the same for 10 and 20,000 elements). The wall-clock effect is small and noisy: an independent run throughapproval_gatemeasured about 2 to 4% faster with the same peak RSS, a bare-handler run about 10% faster and 3% less RSS; do not read more into either. What remains is the engine's own: the operand is serialised once as the string argument, resolved a second time as a typed value and built once as Python objects. A lazy sequence view was considered and not done: ~55 handlers read a list slot withisinstance(x, list). Not addressed: the tuple-depth label difference at 1,100 and 3,000 levels (the render raisesRecursionErrorinserialize_valuefirst, unchanged) and the run-to-run variation of the reproducible cell counts (the reviewer's own measurement note).The safe-markup guard no longer trusts a class attribute the class body may not define, and checks where
safe_urloutput lands in the string-building forms it knows (#3850). Soundness follow-ups from two reviews of #3848. In the 322-function review corpus, the fuzz programs run (4,800 here, 12,800 in the review) and the real tree the guard clears nothing new (the audit baseline is unchanged except the correctedJSChain.__html__reason); that is measured, not proved. A class attribute defined only inside anif/try/loop, or deleted bydel C.X/del X, is trusted only when the class has no__getattr__(on it, on a subclass in the file, or assigned from outside), no base other thanobjectand no class keyword; the same holds for a method. The position check onsafe_url/url_attr/safe_css_*(text or a quoted value, never an unquoted attribute or a tag name, including a trailing<) andescape_json_for_script(script text only) now covers+chains,%andstr.format(a prefix held in a name, returned by a helper of the file or passed as a literal argument counts), and a string built from such an output ('' + safe_url(x),'%s' % ..., a helper that returns one, ajoinor container of it) is judged again where it is placed unless it is a balanced attribute fragment of its own (placed unchecked, see #3864); a bare output where nobody checked its position is not clean. Not covered, listed in the guard's docstring and #3864: an unreadable prefix is assumed position-neutral, a function that takes the output as a parameter and builds the tag, a lambda, a metaclass descriptor, hostile monkeypatching. A+chain longer than the depth bound is flagged instead of crashing the scan. Directed rows pin every rule.A formset whose form adds fields in
__init__can mark rows for deletion (#3861).remove_rowexempted frommax_form_fieldsonly the fields a form class declares (form.base_fields) and an inline formset's foreign key, so a form that addsd1,d2,d3in__init__counted all of their seeded row keys: with 600 rows (1,800 such keys) none of the 600 deletes was stored. The exempt names now come fromformset_cls().empty_form.fields(the unbound form, so they are the class's and never the client's), read once per formset class per process (a form whose fields depend on state that changes later keeps the first answer) and cached weakly, over an empty queryset for a model formset so no rows are read; a form that cannot be built without arguments keeps the declared fields exempt as before and logs the fallback once. Tests added for the inline foreign key (a realContentType/Permissioninline formset) and for theTOTAL_FORMSclamp'smaxagainstmin. #3867 adds tests only (no behaviour change): the kept-rows sidecar entry never replaces an explicit entry, never ships for a name outside the context, and nothing is kept for a QuerySet reached through a container.The Python
data_tabletemplate rendered an empty cell for every cell of a dict row, an emptydata-valueon every clickable row, and readsortable: Falseas True (#3870).table.htmlread a cell as{{ row|dictsort:col.key|first }}, butdictsortover a dict iterates its KEYS and resolves the column key against each, so the lookup was""; the same expression gavedata-value(row_click_value_key),data-href(row_url) and the href of a link column (col.link).{% if col.sortable|default:True %}turnedFalseinto True, so an unsortable column still haddj-clickon its header. Cells now come from atable_cellfilter (a dict row's value;""for a missing key or a row that is not a dict; written withstr()like the native table, so a number is not localised and1000is not1,000; amark_safestring is kept, as every Python tag does), andsortableisdefault_if_none:True. The two URL sinks the fix makes live go throughtable_href(url_attr):javascript:,data:andvbscript:become#, the value is escaped, and a quote cannot end the attribute. The native table readssortable: Noneas "not given" too, as the template does.table.htmlnow begins with{% load djust_components %}, so a test that renders it with a bareEngine()registers the library (the three that did are updated). The native table'sdata-valueand cells are pinned equal to the Python template's for dict rows with a missing key, aNone, falsy values, a key nameditems, numbers and a hostile payload sweep. Rows that are not dicts still render nothing natively and an empty cell under Python, as before. A row'sdata-valueis force-escaped, so amark_saferow value cannot leave the attribute.A native BLOCK tag's argument naming an unresolved variable became that variable's NAME (#3871).
{% nav_menu toggle_event=nope %}withnopeundefined rendereddj-click="nope"under the socket render (an event name the view may well have), and{% card title=nope %}printednope; the Django tag renders both empty, and an inline tag's argument ({% dj_button event=nope %}) already did on both engines. The engine resolves a block tag's arguments through a channel that keeps the raw token when a name does not resolve, so the handler was handed text it could not tell from an operand that resolved TOnope(a lookup against the context, thecontext.get(val, val)in_parse_args, answers a different question). Block handlers now declareTYPED_ARGSwithoutTYPED_ARGS_STANDINS_ONLY(_TypedBlockOperands): the engine says which operands resolved, and the wrapper empties exactly the ones that did not, so a block handler sees what an inline handler sees. A quoted literal, a number,True/False/None, a dotted lookup, a falsy variable (0,"",None,False) and a value that merely reads like an unresolved name (shadow = "nope") are untouched. Still different, and pinned intest_a_value_that_is_also_a_variable_name_is_resolved_a_second_time: that samecontext.get(val, val)fallback looks a RESOLVED value up a second time, soev = "name"prints whatever the variablenameholds natively; it stays because about 400 tests call a handler'srenderwith a raw token and rely on it (filed separately).python/djust/tests/test_component_arg_handling_3870_3871_3872.pyrenders 24 argument shapes throughnav_menu,nav_item,filter_bar,card,dj_buttonandbadgeon both engines. The{% call %}/{% component %}handlers keep their own argument reader, whose operands arrive as raw tokens by declaration (RESOLVE_ARG_POSITIONS), so a lookup there is the resolution, not a second one. Behaviour change to know: an unresolved operand on a block tag (title=view.page_title, a namedtuple attributept.x, both of which resolve on neither engine) now renders empty instead of its own text.Native component handlers stripped the whitespace of a variable-valued argument; the Django tags keep it (#3872).
{% dj_button event=ev %}withev = "x "(or"x\n","x "," x") rendereddj-click="x"natively anddj-click="x "under Django, and{% badge label=ev %}lost the same padding from visible text._parse_argsranval.strip()on the argument text, but the engine splits a tag on whitespace, so a source token never carries any: only a RESOLVED value was ever stripped. The text is now read as it arrives; a padded text is never read as a number (int(" 5 ")is 5) or asTrue/None, and a lone quote character is no longer read as the empty literal. A lone space now wires on both engines. Still different, and pinned intest_the_text_channel_cannot_tell_the_string_zero_from_zero: the string"0"reaches an inline handler as the number 0 (the engine hands handlers text, and a falsy count must wire nothing). Behaviour change to know: a resolved value that carries leading or trailing whitespace is no longer stripped natively, as the Django tag never stripped it, sodj-click="go "no longer dispatches, andpagination page=" 2 ", which rendered natively before, now raisesTypeErroras the Django tag does.{% include obj %}in a child view's template called arender()markedalters_dataordo_not_call_in_templates(#3880). #3879 refused it on five entries (DjustTemplateBackend,_rust.render_template,RustLiveView, a LiveView's WebSocket and HTTP GET renders), but embedded and sticky child views (websocket.py::render_embedded_child_html,templatetags/live_tags.py::_render_sticky_child_html) render through Django's stock engine, which called it. The engine is unchanged (no change to filters, tags or escaping): the render is now made insideinclude_guard_scope(python/djust/_include_guard.py) and a patchedIncludeNode.render, inert outside that scope, applies the samerefused_markercheck as the five entries, so a flaggedrenderis not called, the include renders the empty string and one WARNING is logged per type. The operand is resolved once and the checked bound method is the one that runs. The same scope now wraps the other places a view's or component's context reaches Django's engine: the two remaining{% live_render %}branches (legacy non-sticky and lazy), streamed fragments, pending layouts, the native renderer,dj_suspensefallbacks, the component gallery, a component's Django-engine fallback and{% extends %}path (both component kinds), the template of an inclusion tag bridged into a LiveView render, andLiveViewTestClient.render(engine="django"). A render no djust view or component is making (your own page, the admin) is still stock Django. Regression cases inpython/tests/test_render_call_guard_3815.py(the two child-view tests that pinned the old behaviour are flipped).The native
data_tabledrew norow_urlnavigation and no link columns, so they vanished when the socket opened (#3887). The Pythontable.html(the first render, under Django's engine) drawsrow_url="key"asdata-href,role="button",tabindex="0",style="cursor:pointer"and thedata-table-row-clickableclass on each<tr>, loadsdata-table-row-click.js(which does the navigating), and draws a column with"link": "key"as<a href>(withlink_class); the native handler implemented none of it, and its row-click rows lacked the role, tabindex and the script too. The native handler now writes the same markup, with two exceptions: arow_urlvariable holding the text"0","None"or"False"is a clickable row in the Python tag but not natively, because the native handler re-types the text it is handed (#3882, #3883), and a row whose URL value isNonestill draws<a href="None">on both engines (#3895). Every URL goes through the sameurl_attrallowlist as the Python tag (javascript:,data:and the other non-navigation schemes, case-folded, whitespace-, control-character-, entity- and percent-obfuscated, become#; the value is escaped),row_click_eventstill wins overrow_url, and a falsyrow_url/link(None,"",0,False) draws nothing, decided on the value.test_data_table_row_url_link_3887.pyrenders every shape through both engines (the sweep's 60 URL payloads and 17 more, three placements, 15 row-value shapes, 27 link-column forms, 27 parameter shapes, callables behind the call guard, marked-safe and__html__values called through the handler directly) and compares what a browser acts on; the parity baseline movesdata_tablefrom 118 to 120 readable parameters and 20 to 16tags/other-attrsdifferences. Amark_safeURL is still kept raw by the Python tag (#3894); the other residuals are listed in #3895.A relation read inside a filtered loop over a list of models renders empty on the Django-backend engine (#3888).
{% for u in users|slice:':2' %}{% for g in u.groups.all %}{{ g.name }}{% endfor %}{% endfor %}over alist/tupleof models (or aQuerySetat a nested name such aspage.users) rendered nothing where Django's own engine prints the groups; so did{{ u.groups.all.count }},{{ u.groups.count }}, a*_idattname such as{{ p.content_type_id }}, and the same reads behind|first,|dictsortand{% with s=users|slice %}. A model that reaches the engine inside a container used to be a dict of its fields, and anything the dict lacked was read through a loop alias that a filtered operand never registers. Such a model is now a floored live handle: the row carries its own handle through any filter, and the handle is the floor proxy the template sidecar already used, so every attribute read, relation traversals included, is checked against the serialization floor (the fields, methods, per-model rules and field types it withheld before stay withheld, the live handle refuses underscore-prefixed names for any reader, and anything a read returns is plain data or another floored handle (a model, manager or queryset becomes a proxy; a callable becomes a wrapper whose result is floored again; a class or module is refused, so{{ u.DoesNotExist }}renders empty; the wrappers report a class with no Python-level methods and close routes to Python-defined objects and namespaces: a UUID is its text, plain data is matched by exact type, a dict's keys are floored like its values, and a datetime or time with a Python-definedtzinfocarries a floortzinfothat answers the same offset, DST and name questions), andalters_datamethods are still never called). The rows a relation read returns (u.groups.all,u.groups.first) are handles too, so a second relation hop works.{% if u == rows.0 %}/u in rowscompare models by class and primary key,|dictsort:'field'(dotted keys included) reads the key off the row once per row,{{ u }}isstr(u), and a model written as JSON for a tag argument or to a state snapshot is the same floored dict it always was. Scope: only a model that reaches the engine inside a container; a top-level single model is still serialised first and keeps the dict form on a LiveView (#3936) —DjustTemplateBackendcarries it as a handle too since #3902 — so on a LiveView{{ u|pprint }}of a top-level model dumps its fields while{{ rows|first|pprint }}prints its name. Behaviour changes for a template that depended on the old dict: a model in a container is no longer a mapping, so{% for k, v in u.items %}renders empty, as on Django; a bareFileFieldprints its stored name, as on Django ({{ p.image.url }}is the URL on both); a custom Python filter or simple tag that receives such a model gets the floor proxy (attribute reads work;row["field"]does not) where it got the dict, while a top-level model on a LiveView or a top-level queryset loop still gets the dict; and{{ rows|json_script:"x" }},{{ rows|pprint }}and a data-dump tag such as{% json_viewer %}spell each model in a container as itsstr()(["u0", "u1"]) where they dumped its fields (Django raises for thejson_scriptcase), so code that reads such data in JavaScript must build the data explicitly. A bridged or native tag that takes a list of models as an argument ({% data_table rows=rows %}) still receives dict rows. LiveView state, the client payload and the msgpack snapshot are untouched. Not in this change:dictsortover a field the template reads nowhere else, on a QuerySet or in a LiveView, still renders empty (#3892).params_form_fallback = "fields"handshandle_params()the value the valid path would for a kept field the URL left out or empty, when that field has aninitial(#3891). On a valid URL the form'sclean()runs first and such a field then takes itsinitial, soclean()'s write to it is overwritten. The"fields"fallback filled theinitialbefore its extraclean()check and then adoptedclean()'s output, soclean()'s write survived: aclean()that copies a request value into another field handed a value that had failed validation tohandle_params()through a field the URL never named. Theinitialfill is now applied again to the check's output, with the helper the valid path uses. The check itself still runs on the combination that would be handed over, so the result stays a combinationclean()accepts, or the plain initials. Behaviour changes: aclean()that sets a default into such a field now loses to the field'sinitial(as on a valid URL), and the fallback hands over a copy of a mutableinitialit fills in (the valid path and"initial"mode hand over the object itself, so ahandle_params()that mutates it there changes the form class's default for later requests: #3926). Not covered, because the valid path shares it: a field with noinitial, aclean()that also writes the field intoself.data, and a multi-select field the URL left out (#3922). A field the URL supplied still keepsclean()'s output on both paths, a failed field is still itsinitial, and"initial"mode is unchanged. Guide: Navigation, "Keeping what validated".{% for u in users|dictsort:'username' %}{{ u.pk }}{% endfor %}over a QuerySet, or a QuerySet /list[Model]on a LiveView, no longer renders an empty loop when the template readsusernamenowhere else (#3892). A path-JIT row holds only the fields the template reads, and the template-variable extractor (extract_template_variables,crates/djust_templates/src/parser.rs) attributedu.<field>reads in a loop body and nothing else, so a field named only in a filter ARGUMENT was never in the row; Django'sdictsortanswers''for a missing key rather than raising, so the loop was silently empty. The quoted argument ofdictsort/dictsortreversed(a table of the built-in filters that read a named attribute of each item, measured against Django: exactly these two) and thebyattribute of{% regroup %}(which grouped byNoneon a LiveView) are now filed on the list beside the fields the body reads, through a loop, a{% with %}alias, a{{ }}or an{% if %}operand, and a nested path (page.users). One extractor servesDjustTemplateBackendand the LiveView, so both are fixed together. The key is filed only when the items are also read by field: with nothing else singled out the row is serialized whole, which already carries the key and the__str__a bare{{ u }}prints, so{% for u in users|dictsort:'name' %}{{ u }}keeps working. A key that is not a plain path (empty segment, a segment starting with_, as Django refuses) is ignored, and the serialization floor applies to a sort key as it does to{{ u.password }}. Not fixed here and filed: a context-variable key (dictsort:key, #3910), a dotted key the Rustdictsortcannot sort by (#3912), and a loop variable read whole next to a field (#3911). New cases inpython/tests/test_item_key_filters_3892.py; the strict xfails ofTestDictsortOverJitRowsinpython/tests/test_sliced_list_of_models_3888.pyare removed.A
Component's hybridtemplatenow applies the attribute rule of the Rust instance and_render_custom()to its attribute values (#3900).Component.renderdraws a class that has atemplatethrough it when the crate constructor refuses a value (a lazy string,gettext_lazy/format_lazy, is one; so is any object that is not plain text) and always when the class has no crate counterpart (ButtonGroup,Checkbox,Input,Offcanvas). That path wrote{{ src }}into an attribute with only autoescape and no URL check, soAvatar(src=<lazy "javascript:alert(1)">)printed a live script URL where the plain string printed<img src="#">, and a lazysize/statuscould leave its attribute. Now every{{ ... }}in the value of an attribute of a start tag is escaped whatever the value is marked as, however it yields its text (a marked-safe string, a lazy one, an object whose text form is marked safe, a callable the engine calls), also under{% autoescape off %}; and in a URL attribute (href,src,action..., and thedj-navigate/dj-patch/dj-patch-reloaddirectives) the value is judged with the scheme allowlist the other paths use (url_allowed,data_url_attr,srcset_attr), so any scheme outside the allowlist (javascript:,data:for a link,sms:,magnet:...) becomes#. The template is read once per template string (a bounded LRU cache) with the HTML attribute grammar (whitespace around=, quoted and unquoted values), so it covers the 19 shipped hybrid classes and a template you write yourself. Behaviour changes for your own hybrid components: a marked-safe value in an attribute is escaped again (mark_safe("a & b")printsa &amp; b; pass plain text); in an attribute a number prints in its canonical form (33.5, not a localized33,5) and a date in its ISO form (2026-01-02, not a localizedJan. 2, 2026), for a plain name, a dotted path and a filtered expression alike; a callable that needs arguments prints nothing, as in Django; and the hybrid path is now stricter than_render_custom()for a scalar argument in a non-URL attribute. A URL is judged as the WHOLE value in every way it can come out: a literal inside{% if %}/{% for %}may be skipped, so it never fixes the value (each branch is a path of its own, and a good value that follows a block-added literal is judged as if the block were skipped), and a literal is read the way a browser reads it (character references decoded, tab and newline dropped). A slot that can still choose the scheme (first in the value, or only scheme characters before it) is judged together with the literal text and the other slots; a slot after a top-level literal that already fixes the value (href="/search?q={{ q }}",href="http://{{ host }}/",href="{% url 'x' %}?q={{ q }}") is escaped, not judged, sofoo:barandhost:portare kept. A URL value that can choose the scheme but whose slot is a dotted path, a filtered expression, a{% for %}/{% with %}variable, sits beside a tag that prints text this module does not follow ({% trans %}), has more than 64 paths, or has an attribute NAME built by tags, is escaped but not judged; asrcsetis judged only when the slot is the whole value; an unquoted value is escaped but cannot be made safe. Each of these is named by one warning per component class and set of attributes, which carries the class name and the tag / attribute names only, never template text. A value whosestr()raises raises in a URL slot as the engine does; in a plain slot the Rust engine'sforce_escapeprints the escaped default repr where Django's raises (the crate's filter, unchanged).{% verbatim %}and{% comment %}blocks,{{ }}in an attribute name and text between tags are left as written. The sweep intest_marked_safe_attribute_sweep_3898.pyno longer sets the hybrid cells aside: it drives every template class through the template and throughComponent.render, with a claim for each kind of value, and its reach test asks per claim kind.A filtered rebinding of a top-level single model reads its relation empty through
DjustTemplateBackend(#3902).{% with q=user|default:user %}{{ q.groups.count }}{% endwith %}over ONE model passed as a top-level context variable rendered nothing where Django's own engine prints the count; so didq.groups.exists,q.groups.first.name,{% for g in q.groups.all %}, a model method that returns a queryset, a method call on the model through the rebinding ({% with q=obj|default:obj %}{{ q.describe }}{% endwith %}), a*_idattname, and{% if user in users %}.DjustTemplate.renderreplaced the model with a dict of its fields before the engine saw it, so a filtered operand (which registers no loop alias) had no manager and no relation to read. A plain render is not state, so the model now reaches the engine whole, as a list row has since #3888: a floored live handle, checked against the serialization floor on every attribute read, with the same refusals (underscore-prefixed names, Python-defined classes and modules, a function's__globals__, a UUID is its text, a Python-definedtzinfo, a dict's model keys). A model reached through a context processor (request.userasuser, a lazy object) takes the same route.serialize_value(for_render=True)also stops treating a model as a file: it askedhasattr(value, "url") and hasattr(value, "name"), which a model with anamefield and aurlfield satisfies, so{{ link }}over a list of such rows rendered the row's URL instead of itsstr(), and a row whoseurlproperty raised anything butAttributeErrorfailed the whole render. Behaviour changes for a template that depended on the old dict (top-level model,DjustTemplateBackendonly; each was run against the old and the new code): a bareFileFieldnow prints its stored NAME, as on Django, not its URL ({{ doc.attach }}printed the URL and now prints the stored name, which has no leading slash;{{ doc.attach.url }}prints the URL on both), so<img src="{{ profile.avatar }}">written against the old dict must become<img src="{{ profile.avatar.url }}">; the model is no longer a mapping or an iterable of field names, so{% for f in user %}and{{ user|unordered_list }}now fail the render (aRuntimeError; Django raisesTypeError) where they iterated the field names,{{ user|join:"," }}isstr(user)where it wasid,pk,__str__,...,{{ user|pluralize }}is empty where it wass,{% for k, v in u.items %}renders empty and{% if 'username' in u %}is false, as on Django;{{ u|pprint }}and{{ u|json_script:"x" }}print the model'sstr()where they dumped its fields (Django'sjson_scriptraises for a model), so code that reads such data in JavaScript must build the data explicitly; and a custom Python FILTER that receives the model gets the floor proxy (attribute reads work,row["field"]does not) where it got the dict (asimple_tagalready received the proxy, so nothing moves for tags), and a filter thatcopy.copys,copy.deepcopys or pickles the model now getscopy.Error/pickle.PicklingError. A bridged or native tag argument is still the same denylist dict text, byte for byte. Not in this change: aLiveViewholds the model as state (ADR-027: client payload, msgpack, change detection), so the same template still renders empty there (#3936).An item value that is marked safe is escaped in the attributes the template tags write it to, for the list parameters the sweep derives (#3904, follow-up to #3898). Behaviour change: an entry of a list parameter, or a value read out of an item dict, that is marked safe (
mark_safe, a lazy one, a subclass, astrwith__html__, or an object whose text form is marked safe) used to be written as it is into an attribute (value=,data-*,id=,class=,aria-label=,fill=, ...) by 43 tag parameters, and by a form field's choice value indj_form(also through the native engine, which hands the form to the same tag) and the column keys and filter options ofmodel_table:audit_log(entries,columns),autocomplete,collab_selection,combobox(options,selected),cursors,dashboard_grid,data_grid(columns,rows),dependent_select,dj_select,export_dialog(columns,formats),fab,file_tree,gantt_chart,kanban_board(a column and the cards inside it),line_chart,masonry_grid,meter,model_selector,multi_select,notification_center,reactions,scroll_spy,sortable_list,split_button,tag_input(tags,suggestions),toast_container,toggle_group,tour,tree_view,wizard,theme_radio/theme_selectofdjust_theming, and further parameters treated the same way. It is now escaped there exactly as a plain string is (attr_textin the Python tags,|force_escapein the three templates); text slots keep their markup. A scalar tag argument is unchanged. The template protection lives in the three shipped templates: a per-theme override or a project copy made from the oldtoast.html,select.htmlorradio.htmlmust add|force_escapeto the attribute values it prints from an item.test_marked_safe_attribute_sweep_3898.pynow drives each of the 43 parameters (a cell each: the attributes the item reaches are pinned, and every kind of marked-safe value must leave them as a plain string does), derives the list parameters of the swept tag libraries at run time and tries each with the item's keys read from the tag, its other lists filled, its flags on and off, the scalars that select a branch set, nested items, pairs and an empty block body; a planted-leak test proves the derivation can report a leak, and a static hunt over the tag modules, the structural check of the two, finds an escaped item written to a quoted attribute (an f-string, a concatenation,%or.format; not an unquoted attribute,format_html, or a helper that escapes a value it is handed) without enumeration. Its list of known leaks is empty.scripts/gate_off_batch.pyno longer leaves its test command running after a hard kill (#3905). The test command runs in its own session, so when the tool (or the pytest worker that was running it) was SIGKILLed, nothing could kill the command's process group and a hung child spun as an orphan until someone found it. The command now runs under a small watchdog that kills its whole process group as soon as the tool that started it is gone (the deadline, RSS, SIGINT and SIGTERM kills are unchanged), and every busy loop in the tool's own test fixtures ends by itself after 45 s. Six tests assert that no descendant, a spawned grandchild included, is alive after a HUNG run, an import-time hang, a RUNAWAY-MEMORY run, SIGINT, SIGTERM and a SIGKILLed tool; the self-check list gained three mutations for the group kill.The inline
templateof aLiveComponentand of a class-level (BoundComponent) component gets the attribute rule of the hybridComponenttemplate (#3913). All three draw theirtemplatestring through one helper,_render_inline_template(components/base.py), which appliesdjust.components._hybrid_attrs.harden_contextbefore the engine; an AST census of the package fails when another site renders a component's inline template (under any spelling: an alias, another module, a directTemplate(...).render(...)). So every{{ ... }}in the value of an attribute is escaped whatever the value is marked as (a marked-safe string, a lazy one, an object whose text form is marked safe, a callable the engine calls; also|safeand under{% autoescape off %}), a URL attribute (href,src,action..., and thedj-navigate/dj-patch/dj-patch-reloaddirectives) is judged with the scheme allowlist on the whole value as it is assembled, and the text of<textarea>,<title>and HTML comments is escaped (#3914). Behaviour change for existing inline templates, measured over a corpus of 9,950 renders (118 differ): (1) a scheme outside the allowlist in a plain-name URL slot becomes#(href="{{ url }}"withjavascript:alert(1), andsms:,whatsapp:,zoommtg:,webcal:,geo:,slack:, silently; the no-ophref="javascript:void(0)"/javascript:;is kept, in anhrefonly, so in an<img src>ordj-navigateit becomes#); (2) a marked-safe value, or a plain value written with|safe, in an attribute, in<textarea>, in<title>or in a comment is escaped (title="{{ v|safe }}"withmark_safe('{"a": "b"}')printed the quotes raw, ending the attribute, and now prints";mark_safe("a & b")printsa &amp; b, so pass plain text); (3) a number or date in an attribute prints in its canonical form (2026-01-02,33.5, notJan. 2, 2026/33,5). Not changed:<script>,<style>,<xmp>,<noscript>... keep the author's markup (<script>var cfg = {{ cfg|safe }};</script>stays JSON; usejson_scriptor adata-*attribute to put data in a page); relative,http(s):,mailto:,tel:, fragment and query URLs, ids,component_idand every ordinary value render byte for byte as before; a text slot keeps a marked-safe value's markup. Also fixed:{% blocktranslate %}/{% blocktrans %}in an attribute (aria-label="{% blocktranslate %}Delete {{ item }}{% endblocktranslate %}"printedDeletebecause the rewrite renamed the catalog-keyed variable; the mechanism came in with #3915 for the hybridComponent). A translation block's body is now never rewritten, and what it prints is escaped once (a variable it reads from the context is escaped around it, awithargument gets|force_escape, acountargument is left alone); it works on both engines, for plural,with,trimmed,contextandasvar, in an attribute and in<textarea>/<title>/ a comment. A block in a URL attribute is escaped, not scheme-checked. What is escaped but not scheme-checked (a URL whose slot is a dotted path, a filtered expression or a{% for %}/{% with %}variable, a translation block; an unquoted value), the slots of an{% include %}d partial (neither escaped nor checked), a tag that prints text inside<textarea>/<title>, and shapes the scanner cannot follow like a browser does are named by one warning per component class (class, tag/attribute/element names, shape names and a count; never an attribute value or anything from the context; an attribute name is template text and is logged, so keep names static in a template built per instance). An{% include %}of a literal name that the loaders find and that holds nothing to harden is no longer reported. Two behaviours: a callable in a URL slot is called when the context is prepared, even if its branch does not render (it must be judged first), where a callable in<textarea>/<title>text is called only where the engine reaches it. Not changed: atemplate_namefile (loaded by the project's loaders; #3933) and a LiveView's owntemplate. See the components guide.A value written into
<textarea>,<title>or an HTML comment of a hybrid componenttemplateis escaped even when it is marked safe (#3914). The content of<textarea>and<title>is text in which character references are decoded, ended by the first matching end tag, and-->ends a comment the same way, so markup in a value there can end the element.Component.renderdraws a class through itstemplatewhen the crate constructor refuses a value (a lazy string is one) and always for a class with no crate counterpart, and that path left a marked-safe value alone:TextArea(value=<lazy marked-safe text that ends the textarea>)ended the element and put live markup after it. Now a{{ }}in that text is escaped whatever the value is marked as (a marked-safe string, a lazy one, an object whose text form is marked safe, a callable returning one; also{{ v|safe }}and under{% autoescape off %}). Before/after:<textarea>{{ v }}</textarea>withv = mark_safe("<b>x</b>")printed<b>x</b>and now prints<b>x</b>; the same for<title>and for<!-- {{ v }} -->. An ordinary value prints exactly as before: a plain name keeps the engine's own formatting (a date localized, a number formatted), checked by rendering a corpus (strings, numbers, decimals, dates, datetimes,None, booleans, lists, dicts) before and after; a dotted path or a filtered expression ends in|force_escape, so a date or number there prints in its canonical form (2026-01-02). A callable in such a slot is called where the engine reaches the slot, not before, so one in a branch that does not render is not called. Unchanged on purpose:<script>,<style>,<xmp>,<iframe>,<noembed>,<noframes>,<plaintext>and<noscript>keep the author's markup. Entities are not decoded in them, so HTML escaping is the wrong encoding (<script>var cfg = {{ cfg|safe }};</script>must stay JSON); use{{ data|json_script:"id" }}or adata-*attribute to put data in a page. The scanner still reads the first seven as raw text, so an<a href="{{ u }}">inside<xmp>is not an attribute. Every other text slot keeps a marked-safe value's markup, as before; the remaining difference to the Rust and custom paths for ordinary text slots is documented in the components guide and tracked in #3932. A{% blocktranslate %}block in an attribute value or in that text is no longer broken (see the #3913 fragment); a tag that prints text inside<textarea>/<title>/ a comment ({% firstof %},{% cycle %}), shapes the scanner cannot follow like a browser does (an element name built by a tag, an end tag in another block than its start) and an{% include %}d partial that holds slots are named by the existing once-per-class warning (class, tag/attribute/element names, shape names and a count; never a value). The scanner also reads a raw-text end tag the way the HTML tokenizer does (ASCII case and whitespace;</textarea-x>is not an end tag) and follows an element opened inside{% verbatim %}.An option whose value contains
&,<,>,"or'is drawn as selected (#3927).{% combobox %}decided "selected" by comparing the HTML-escaped option value with the raw selected value, in single mode (value=) and in multi mode (selected=), so a value such asa&bwas never drawn as selected (nocombobox-option-selectedclass,aria-selected="false") whileabwas. Both modes now compare the raw text of the option value and of the selection (asstr, so an integer1still matches"1"andNonestill matchesNone), and escape only what they write;a&bas a raw value stays a different value froma&b, and a marked-safe or lazy value is compared by its text, not by its marker. The same escaped-versus-raw comparison was in three other places, all fixed: the nativedj_selecthandler (selected), the nativedj_radiohandler (checked; the Django tag already compared raw, so the two engines disagreed), and the class-basedDependentSelect.dependent_select, the data-table select filter anddj_selecton the Django engine compare two values escaped the same way, or two raw ones, and are unchanged.The server-function and sticky-LiveView demo pages printed their own template comments. Five multi-line
{# #}comments inexamples/(Django's{# #}is single-line only, so they rendered as text) are now{% comment %}blocks.scripts/check-template-comments.pyscanned onlypython/djust; it now scansexamples/too (--pathis repeatable) and skipsstaticfiles/,node_modules/and virtualenvs.Per-model serialization settings are honoured for lazily wrapped model instances.
djust_exclude_fields,djust_serializable_fields,djust_serialize_sensitive_fields,to_dict()and the column-type rule are read from the model class a wrapper reports through__class__, on the native engine (a bare read, a dotted filter, a filtered rebinding), the{% load %}bridge, the eager serializer, the JIT attribute gate (which decides by name) and aModelFormover a wrapped instance. A wrapper that cannot be evaluated raises. A model that declares none of these settings renders as before. Regression cases inpython/tests/test_per_model_rules_lazy_wrapped_model.py.{% theme_icon %}no longer raises for a long digit string on an interpreter whosedecimalis the pure-Python_pydecimal(follow-up to #3766).theme_iconis documented to warn and never raise for a hostilesize, but asizeof 5000 digits raisedValueError: Exceeds the limit (4300 digits) for integer string conversionon the Python 3.15.1 CI cell (python/tests/test_theme_icon_size_3766.py, thevery-longrows) while passing on 3.12. The size text matched the validation pattern (it is all digits) andDecimal(<the digits>)was then built outside thetry; CPython's C_decimalparses digits itself, but_pydecimalbuilds the value withint(), which is subject tosys.get_int_max_str_digits(). The whole-number part of the size is now length-checked (leading zeros dropped, so"0016"is still16) before anyDecimalis built from it, and a failure fromDecimalanyway falls back to the default size under the existing guard. The same class indecode_state_roundtrip(python/djust/serialization.py): a user dict{"__djust_decimal__": "<5000 digits>"}that collides with the state tag now stays a dict, as its docstring promises, instead of raisingValueErroron a_pydecimalbuild. The new tests (python/tests/test_theme_icon_int_limit.py) force the pure-PythonDecimaland the digit limit, so they fail without the fix on every interpreter.URLs are normalised the way a browser reads them before the same-site test. The data table's row navigation (
data-table-row-click.js), the map picker's tile and asset URL (map_picker._plain_url,map-picker.js),validate_mount_urlandsame_origin_targetnow apply the browser's own URL normalisation before deciding whether a URL stays on the site, through one definition (djust.security.urls.url_as_parsedandurl_is_protocol_relative, mirrored in the two static scripts).same_origin_targetalso answers"/"for a URL its parser refuses, where it raised.url_allowed/url_attrare unchanged: they are a scheme allowlist, and their docstring now says so. Absolutehttp(s)row URLs are still followed. A differential over about 120,000 generated URLs checks the helper, the unchanged JavaScript guard and the Python decisions against the browser's URL parser and Django'surl_has_allowed_host_and_scheme.
Security
FormMixindoes not pre-fill password and other credential fields from the stored value. A field is withheld when it is on the serialization floor and looks secret-like: it has a password widget; or its name containspassword,passwd,secret,tokenorapikey, or one ofkey,hash,salt,pwd,passphrase,otp,signature,pin,credential,credentialsas a whole word (api_keyandpinCodematch,privatekeyandpwdo not); or its column is of a type the type floor excludes (asensitive_field_typesentry,BinaryField, an encrypted field type). A floor field that does not look secret-like (for exampleemaillisted inDJUST_SENSITIVE_FIELDS) and a custom column that is not on the floor (api_keywith the default configuration; list it inDJUST_SENSITIVE_FIELDSto include it) are seeded as before. A withheld field is seeded with""on mount,reset_form, a wizard step and after a valid submit, inform_dataand inas_live(),as_live_field(),{% live_field %}andget_field_value().{{ form }}and{% dj_form %}render as before.Behaviour. On an edit form an absent, empty, whitespace-only,
None,0,False,[]or{}submission of a withheld field keeps the stored column. A withheld password-widget field writes a typed value; every other withheld field is read-only (disabled, as before) and ignores typed input. APasswordInput(includingrender_value=True) is no longer echoed by the live renderers, and a hash-widget field that was notdisabledbecomes read-only (a one-time warning names the field). After a validUserCreationFormsubmitpassword1andpassword2are""in the form state, and theUserChangeFormpassword input is empty. A secret-like field with aninitialon a create form is not pre-filled in the live renderers. A validation message that carries a withheld field's stored value is replaced by "Enter a valid value."; other messages, such as a failed login or a password-policy message, are unchanged. AFormMixinview with no form class gets""for secret-like keys of its hand-managedform_datawhen state is restored. Saved form state from earlier versions is cleaned when it is restored. Regression cases inpython/tests/test_form_data_secret_seed.py.pulldown-cmarkis pinned to>= 0.13.6, past RUSTSEC-2026-0414 and RUSTSEC-2026-0433 (#3741, #3742). Both advisories (an exponential event blow-up from[[a|]], and quadratic work on nested![[wikilinks) affectpulldown-cmark0.13.0 through 0.13.5 and only withOptions::ENABLE_WIKILINKS.v1.3.0rc14shipped 0.13.4 in itsCargo.lockand SBOM;v1.3.0rc15andmainalready lock 0.13.6, so the weekly scan clears once it next reads a newer tag. djust is not reachable by either advisory:crates/djust_templates/src/markdown.rs::to_pulldownstarts fromOptions::empty()and sets only tables, strikethrough and task lists, never wikilinks. This change raises theCargo.tomlrequirement from0.13to0.13.6, so a lockfile regeneration cannot resolve below the patched version, and adds a Rust test thatOptions::ENABLE_WIKILINKSis unset and wikilink syntax stays literal text under everyRenderOptscombination. One behaviour difference between 0.13.4 and 0.13.6 reachesrender_markdown: a link reference definition followed by a whitespace-only line ([b]: /u\n\t\n) no longer renders an empty<p></p>. A differential of the two versions over the 401 markdown files in this repository (zero differences) and 200,000 generated inputs under all eight option combinations found that one class and nothing else. Regression casewikilink_syntax_is_never_a_linkincrates/djust_templates/src/markdown.rs.djust_themingjudged the URL a template prints, not the value it was handed (#3749). Atheme_nav/theme_nav_group/theme_breadcrumb/theme_sidebar_navitem whoseurlis a callable (def url(self): return "javascript:alert(1)") printedhref="javascript:...": the check ran before the template engine called the callable, judged"<bound method ...>", and the engine then printed what the method returned. The item's value is now looked up and resolved the way the engine does it:item["url"]first and the attribute second (for any object with a__getitem__, not only aMapping), then called once by the engine's own rules (do_not_call_in_templates,alters_data, a call that needs arguments), and the item is rewritten with a value the engine will not call or convert again; a callable that returns a callable is written back as the text the engine would have printed for it. A callableitemson a sidebar section is resolved the same way. A value is converted to text once, and that text is what is judged and printed (an object whose__str__answers differently the second time can no longer pass the check and print something else). A lazy value that prints as marked-safe text (lazy(lambda: mark_safe(...), str)()) is plain text like aSafeString.The
**attrspass-through oftheme_button/theme_input/theme_select/theme_progressjudged onlyhref/src/action/formaction. These are now also judged bydjust.components.utils.url_allowed:ping,srcset(entry by entry, so one bad entry does not drop the good ones),poster,data,background, and the framework's owndj-navigate/dj-patch/dj-patch-reload. A rejected scheme renders#and logs a warning, and these never raise (only the four that always did still do). The set is an explicit list decided name by name, not a pattern:cite,longdesc,manifest,codebase,usemap,icon,profile,archive,classid,itemid,itemtype(no browser follows them on the elements these tags print, or they hold text such asicon="mdi:home") and every author-defineddata-*/dj-*name (data-url,dj-action,data_location="Paris: France") are printed as written. One visible consequence:data="x:y",databeing the<object>fetch attribute the issue names, renders#likehref="x:y".What this covers, and what it does not: the tags' own arguments and the pinned pass-through names. A project template override of one of the theming component templates (the badge's, say) that prints an
attrs.<name>into a URL attribute is outside it, because a tag cannot see which key an override reads; no template in this repository does (the set of such reads is derived from the templates and pinned inTestAttrsReadsInUrlPositionsArePinned). Regression cases inTestCallableUrl,TestEveryPassthroughTagAndAttribute,TestThePassthroughSetIsPinnedinpython/tests/test_theming_url_hardening_3749.pyand inpython/tests/test_theming_url_review_3765.py; the sweep (scripts/template-library-url-sweep.py) drives every URL attribute the scanner reads through every tag that takes**attrs, so a new pass-through sink failstest_url_scheme_sweep_3665.pyuntil it is classified.{% theme_icon %},{% theme_form %},{% theme_form_errors %}and the inline theme switcher no longer write a raw value into markup they mark safe (#3766).theme_icon(name, size=20)built each icon with an f-string that interpolatedsizeand returned it undermark_safe, the bannedmark_safe(f'...')with an interpolated value:{% theme_icon 'check' size=u %}withu = '1" onload="alert(1)'printed<svg width="1" onload="alert(1)" ...>, an attribute injection wheneversizeholds user-influenced data (a literalsize=16was inert, which is why nothing showed it).sizeis now validated and the markup is built withformat_html. A number above zero and at most 1000, with at most three decimals, optionally followed bypx,em,rem,ex,ptor%in any case, is accepted; anint,floatorDecimalis rounded to three decimals first (so a computed14 * 1.1prints15.4), any other type is read by its text. Anything else renders the default size20and logs oneWARNINGper call (the value's type, never its content; an icon rendered in a loop logs once per icon), and does not raise: that includes a value whose__str__raises and an int over Python's 4300-digit limit, both of which used to propagate.size=Nonemeans "not given" and renders the default silently.theme_iconhas no Rust tag handler of its own (djust.theming.rust_handlersregisters thetheme_tagstags only), so both engines run this one function, and the tests drive it through both.What changes for a size that worked before: it prints what it printed when it is an int, or text of the form
16/1.5/24px/1.5remwith a lower-case unit. Deliberate differences: the number is re-printed from its parsed value (16.0prints16,"016"prints16,"1.50"prints1.5); a unit is lower-cased (16PXprints16px); afloatorDecimalis rounded to three decimals (Decimal("1E+2")prints100,0.1 + 0.2prints0.3); and a size outside the range now renders20with a warning (a number over 1000, such as1001or1234.5; zero or less; a float that rounds to zero, such as0.0001; a type whose text is not a plain number, such asFraction(3, 2)). Everything else a hostile or malformed value can be (a quote,>, an entity, whitespace, a sign, an exponent, abool, a very long string) renders20too.The same class, found in review where the first guard could not see it, because the value never sat inside
mark_safe(<f-string>):theme_formandtheme_form_errors(theme_form_tags.py) appended f-string lines holding the configuredcss_prefixandBoundField.id_for_label(built from the FIELD NAME, the formprefixandauto_id) to a list and marked the join safe, so a form with a field nameda" onfocus="alert(1)" x="printed<label for="id_a" onfocus="alert(1)" x="" class="theme-label">; every line is now a constant orformat_html. The{{ theme_switcher }}chunk (context_processors._render_theme_switcher, marked safe byLazyThemeHTML) printedpreset["name"]andpreset["display_name"]into<option value="...">unescaped; they are escaped now (&,<,>and"; the apostrophe is left alone, because both places sit between double quotes or in text and the built-in presetSynthwave '84keeps its bytes, so a name containing&prints&, which a browser shows unchanged)._passthrough_attrsjoinedname="value"parts built with an f-string;nameis validated first so it was inert, and it isformat_htmlnow with the same output. The gallery tooltip example (python/djust/theming/gallery/context.py, a constant from a literal tuple) isformat_html. A benign form renders byte for byte what it did (40 renderings across prefix, layout and form, captured before the change, are pinned inpython/tests/test_theme_markup_escaping_3766.py).The guard is
scripts/lib/safe_markup_taint.py, tested inpython/tests/test_safe_markup_guard_3766.py. It is name-based and single-function: it reads the syntax tree one function (or the module body) at a time, does no type inference, and never follows a call into another function. It follows a value through local variables, tuple unpacking,:=, containers (d[k] = ...,.append,.extend,.insert,+= [...]), list / dict / generator comprehensions,+=,join,%,+,.format,.format_mapandTemplate.substitute, to amark_safe/SafeString/SafeTextcall under any alias, or to the format string offormat_html. What counts as an escaper is decided by where the name comes from, not how it is spelled:escape,conditional_escape,format_htmlandformat_html_joinimported fromdjango.utils.html,html.escape, andint/float/bool/lenclean a value;re.escape,shlex.quote, anescapeimported from anywhere else, and a locally defineddef escape(v): return vdo not.min/max/round/abs/strof clean arguments,uuid.uuid4().hex,secrets.token_hex, an f-string field with a numeric format spec ({n:.2f}), anenumerateindex and.render(...)output (trusted by method name) are clean. It flags the pre-fixtheme_icon,theme_form,theme_form_errorsand_passthrough_attrs, which the earlier literal-shape check did not, and flags nothing underdjust/themingnow (the audited allowlist is empty).What it does not see, probed and pinned in
KNOWN_MISSESof its test: anything that crosses a function (a string built in one function and marked safe in another, a module global appended to in one function and joined in another, a value stored onself, a helper that returns an interpolated string);mark_safe(param); a sink reached without a call to it by name (partial(mark_safe, ...),map(mark_safe, ...),getattr(safestring, "mark_safe")(...));functools.reduceover raw items; and any builder not listed above. In particular the inline theme switcher (preset_options += f"..."inside_render_theme_switcher, marked safe byLazyThemeHTMLelsewhere) is protected by its own tests inpython/tests/test_theme_markup_escaping_3766.py, not by the guard.build_theme_head_contextbuilds the<style>/<link>blocks thatThemeMixinmarks safe; that was read, not machine-checked.A tag rendered through the
{% load %}bridge no longer walks raw objects past the serialization floor (#3771).{{ obj.user.password }}has always been empty under the Rust engine, because the engine's walk re-protects after every segment of a path. A bridged tag (relative_time,avatar_groupand the other object-taking djust component tags, every third-partysimple_tag/inclusion_tag/ raw@register.tag,{% blocktranslate %},{% cache %}) resolves its operands in Django's own node withVariable._resolve_lookupover the raw objects of the handler dict, and nothing re-protected those steps:{% relative_time datetime=obj.user.password %}printed the password hash, and so didform.instance.password,form.fields.owner.queryset.first.password,obj.users.first.passwordand, since #3770 hands a bridged tag a liveBoundField,{% for f in form %}…f.form.instance.password. The leaf floor only wrapped aModel/Manager/QuerySetthat was itself the root of the path, so aForm, aBoundFieldor a plain object holding a model passed through.The node stays Django's. What changes is the walk: while a bridged node renders, every
Variableit resolves against theContextthe bridge built (FilterExpressionoperands,|filter:argument, and thetemplate.Variablea raw tag constructs itself, at compile or at render time) is resolved by Django's own_resolve_lookupover a context whose lookups return floor-checked wrappers, and the next step is re-protected after every segment, including after a floor proxy: a stock queryset method or a model property that returns a plain container of models (qs.in_bulk.<pk>.password,obj.user.friends_prop.0.password,obj.user.get_friends.0.password) hands Django a container, and the model inside it is protected on the step that reaches it. This is the re-protectionContext::walk_livedoes for{{ }}, applied to the steps Django's resolver takes; it is not a second implementation of that walk, and the two can differ for an exotic object (see below). The value the walk ends on is released: a wrapper is unwrapped; a model reached on the way is the floor-enforcing proxy; a container that holds a model is re-protected like a top-level container; anything else, including a container with no model in it, arrives as the caller's own object. The refused path is "missing", as in{{ }}: an unresolved operand is the empty string and a filter argument that does not resolve raisesVariableDoesNotExist, exactly as Django treats any missing variable. The gate is exact (theContextregistered by the bridge for that node, per thread and task), so a Django engine in the same process, another thread, and theContext.new()an inclusion tag renders its own template on are untouched. Benign output is unchanged (the walker is transparent tocallable,do_not_call_in_templates,alters_data,silent_variable_failure, signature binding and a property that raisesAttributeError).Behaviour change. A model reached by a path now arrives in a tag as the floor proxy (as a top-level
{% tag user=user %}always did) rather than the raw instance. Soisinstance(operand, User),operand._meta,operand.save()and an ORM filter on the instance no longer work on a path-spelled operand, exactly as they do not on a top-level one. Equality did flip silently ({% t obj.user obj.user %}and{% t request.user obj.user %}compared two distinct proxies and went from equal to unequal); the proxy's__eq__/__hash__now delegate to the model behind it, which reveals identity and no field, so those comparisons are as they were.isinstancecannot be restored without making the proxy aModel, which would let_protect_sidecar_valuetreat it as a model to wrap again, so it is not attempted.What this does not floor (the template author's reach through the tag author's Python): a generic accessor tag that takes a path as a string (
{% getattr obj "user.password" %},get_item,call_method) receives a released plain object and walks it in its own code; atakes_contexttag readingcontext["obj"]itself; a tag function's body, which receives its released operands; an inclusion tag's own template, rendered onContext.new(); a node that never usesVariable; and an operand released as a non-list/tuple/dict/setiterable (a dict view, a generator, aRawQuerySet), which arrives as the raw object. Whether a particular third-party tag is one of the unfloored shapes is the tag author's code, not something the engine can see.Regression cases in
python/tests/test_bridge_floor_3771.py(the five spellings from the issue and their neighbours, under ten operand shapes, for an inline tag, a raw tag that builds its ownVariable, a raw-block tag and a cache vary-on operand, throughDjustTemplateBackend,LiveViewTestClient, an HTTP GET and a WebSocket mount); each mechanism is gate-off tested separately. The code ispython/djust/template_bridge_floor.py; the three places the bridge renders a node (_render_node, the raw-block handler, the cache operand) enter its scope.install()is one assignment of a constant and reads nothing back, so it cannot lose a race.A template that named a mutating callable ran it:
{{ users.delete }}over a QuerySet deleted every row underDjustTemplateBackendand in a LiveView (#3772). Django refuses, becauseModel.deleteandQuerySet.deletecarryalters_data = Trueand the engine never calls such a callable. The refusal existed where the Rust engine walks an object (Context::maybe_call), and_rust.render_template, which has no queryset step, was never affected. The call was made one step earlier. The Rust queryset serializer (serialize_object_with_pathsincrates/djust_live/src/lib.rs, behindDjustTemplateBackend.renderand the LiveView queryset JIT) walks the paths a template names and called every callable it found there with noalters_data/do_not_call_in_templatescheck; the Python codegen twin already had the check. So{{ users.delete }},{% for u in users %}{{ u.delete }}{% endfor %},{{ u.save }},{{ u.groups.clear }},{{ u.groups.create }},{{ g.user_set.clear }}and{{ g.user_set.create }}each ran against the real rows. The reachable shapes are a QuerySet's rows and the related managers reached through them. A single model ({{ user.delete }}), alist[Model]and a top-level manager were already inert and stay so; they are kept in the test matrix as controls.The rule is now stated once, in
djust_core::template_call_guard(do_not_call_in_templatesfirst, thenalters_data, Django's order), and both walkers ask it: the sidecar walk and the queryset serializer. A refused name is left out of the serialized row, exactly as a name the serialization floor denies is, so the template renders empty. The serializer's.all()call on a to-many attribute goes through the same guard. A callable with neither marker is still called ({{ u.get_username }},{{ u.groups.count }}), as Django calls it. The order of the two reads is Django's: a callable carrying both markers is used as it is and prints as its repr, it is not refused asalters_data; that order is pinned incrates/djust_core/tests/test_template_call_guard_3772.rsand inTestTheMarkerOrderIsDjangos3772.Not changed: the fixed-name calls in
serialize_python_value(render()on a component, fourget_*names on a model), which a template author does not choose. Regression cases inpython/tests/test_alters_data_toplevel_3772.py: every shape runs against a real table throughDjustTemplateBackend, a LiveView and_rust.render_template, with Django's own engine as the control and a check that the detector sees a direct mutation.A context key named
__djust_typed_args__no longer reaches a template tag handler (#3773).json_viewer(and any handler that declaresTYPED_ARGS = True) is handed its resolved operands under that reserved key. The engine put the key in only when the tag had a resolved operand, so for a tag whose operands were all quoted literals ({% json_viewer data="x" class="plain" %}) a top-level context key of that name, supplied by the application, stood in for the engine's value and replaceddataandclass. The output stayed escaped, so this was not an XSS; it was an application value overriding an engine-to-handler channel. The key is now the engine's alone: it is removed from the flat context map, from the raw-Python objects laid over it, and from the live frames a bridged tag reads, before any handler is called, and the engine puts its own value back only for a handler that declaresTYPED_ARGS. A user value of any shape (a dict, a string, a list, a number,None, an object that cannot cross) no longer reaches an inline, block, assign or bridged handler. Regression cases inpython/tests/test_typed_args_reserved_key_3773.py; each of the four removals is gate-off tested separately.An HTML attribute NAME built from a template argument could carry markup (#3774).
djust._html.build_tagescaped every attribute value but wrote the name as given, so{% live_input "text" handler="h" event=var %}withvar = "dj-input onfocus=alert(1) autofocus y"rendereddj-input onfocus=alert(1) autofocus y="h". Names are now checked against the HTML rule (no whitespace, control, quote,<,>,/or=) by the newdjust._html.attr_name, whichbuild_tagandFormMixin.as_live'sBaseAdapter._build_tagshare; an invalid name raisesValueError. The writers that did not go throughbuild_tagnow call it too:render_icon's extra attributes (the name was onlyhtml.escaped, which cannot make one safe), thedata-*keys ofButton,CardandPaginationComponent, and the trigger anddj-value-*keys ofLiveComponent.event_attrs. Every real name (dj-input,aria-label,@click,dj-keydown.enter) renders exactly as before. Found by auditing the 125mark_safesites the safe-markup guard flags outsidedjust.theming: none of those was vulnerable._rust.serialize_contextranrender()on any object and fourget_*methods on any model, with no call guard, and shippedpassword,is_superuserandis_staffof a model (#3778). The Rust function (serialize_python_valueincrates/djust_live/src/lib.rs) is a public#[pyfunction]that no production path calls: agit grepoverpython/djust,examples,benchmarksandscriptsfinds only the Python helper of the same name indjust.template.serialization(a different function), every importer ofdjust._rust.serialize_contextis a test, and a spy on it records zero calls across aDjustTemplateBackendrender and a LiveView mount. So the exposure was a caller who hands it an object, not a template author. Reproduced first against the real call path: arendercarryingalters_datastill ran, aget_usernamemarkedalters_datadeleted the row (User.objects.count()1 -> 0),get_absolute_urlmarkeddo_not_call_in_templatesran, and the model's dict containedpassword,is_superuserandis_staffbecause the arm built it withmodel_to_dictand never asked the serialization gate (the oneserialize_querysetasks since #2685). The issue named the calls; the floor bypass was found by running it.Both arms now follow the rule the queryset serializer follows. Every call goes through
djust_core::template_call_guard(#3772): arenderorget_*that carriesdo_not_call_in_templatesoralters_datais not called, a refusedrenderfalls back tostr(obj)and a refusedget_*is left out. Every model field and method name goes through the serialization gate (djust.optimization.codegen.emittable_names), so the floor,DJUST_SENSITIVE_FIELDSand the per-model allow and deny lists apply, and a name it refuses is dropped before the method behind it can be called. If the gate is missing or raises the model isstr(obj); if the gate fails internally it permits no names and the model ships as an empty dict. It is never an ungated dict. The gate call is now shared with the queryset serializer (permitted_among), not copied. A callable with neither marker is still called, so the output of an ordinary model and component is unchanged.Decision: guarded, not deleted. The function is listed in
_rust.pyianddocs/TYPE_STUBS.md, so removing it is a public-surface call for a maintainer; with no production caller, deleting it later is a pure removal and is tracked as the follow-up on #3778. The stub also declared afield_pathsargument the function never took; it now matches the signature and describes the guard. Regression cases inpython/tests/test_serialize_context_guards_3778.py: a component whoserenderdeletes rows, each of the fourget_*names under each marker, the floor fields, a configured sensitive field, a per-model allowlist and exclusion, a nested model at every container depth, and a gate that raises.LIVEVIEW_CONFIG['theme']['css_prefix']is validated when the config is read; a prefix that holds markup can no longer break out of the theme<style>blocks (#3779).get_css_prefix()returned the raw config string. It reachedgenerate_component_css,generate_css_for_stateand the critical-CSS split, which print it into.{prefix}btn { ... }inside<style>blocks thatpython/djust/theming/templates/djust_theming/theme_head.htmlrenders under{% autoescape off %}.LIVEVIEW_CONFIG = {"theme": {"css_prefix": "x</style><img src=x onerror=alert(1)>"}}printed a parsed<img onerror>element in{% theme_head %},{% theme_css %}and{{ theme_head }}(the context-processor chunk). This is settings-level, not request-reachable: it needs a hostile or careless value in settings, which is why nothing had shown it.cookie_namespacewas regex-validated anddirectionallow-listed;css_prefixwas neither.get_theme_config()now raisesImproperlyConfiguredfor acss_prefixthat is not a string of ASCII letters, digits, underscores and hyphens ([A-Za-z0-9_-]*), the way it already does forcookie_namespace.""(the default) andNonestill load. Every reader goes through that function (theme_head,theme_css, the context processor, the CSS views,ThemeMixin,theme_form/theme_form_errors, the component and page tags,get_css_prefix), so one check covers them all, in both the Django and the Rust template engines. It is an exception at load rather than only a system check becausemanage.py checkdoes not run undergunicornoruvicorn: a check-only gate would leave the injection live in production. The three generators that also take a prefix as an ARGUMENT, bypassing the config (generate_component_css,CompleteThemeCSSGenerator,DesignSystemCSSGenerator), run the same validator, sogenerate_css_for_state(state, css_prefix=...),generate_critical_css_for_state,generate_deferred_css_for_stateandgenerate_theme_cssare covered through them.What changes: a project with a prefix outside that alphabet now fails at the first theme render with a message that names the setting (previously it rendered a broken or hostile stylesheet). No documented or tested prefix is affected:
dj-,dj,djt-,myapp-and""render byte for byte what they did (43 renderings across prefix, tag and engine, captured before the change, are pinned inpython/tests/test_theme_css_prefix_validation_3779.py).Related, same file:
check_css_prefix(djust_theming.E004/W002) usedre.match(r"^...$"), and$also matches before a trailing newline, so a prefix of"dj-\n"passed the check; it now usesfullmatch. The check reads the unvalidated config so it can still REPORT a prefix the loader refuses (E004) instead of dying inget_theme_config(). E004 stays stricter than the loader (it also wants a leading letter): the loader draws the line at "cannot carry markup", the check at "is a valid class prefix".A model form's values are held to the serialization floor under the native engine (#3790). BREAKING for a template that reads a floor field's value back from a form.
ModelForm.initialismodel_to_dict(instance), a plaindictof strings by the time a template reads it, so the engine's walk never met aModeland the model proxy had nothing to refuse. On aUserChangeFormbound to aUser,{{ form.initial.password }},{{ form.password.value }}and{{ form.password.initial }}printed the password hash while{{ form.instance.password }}was empty: the same secret by a different spelling. So didform.cleaned_data.password(a disabled field validates to itsinitial),form.data.password(and a prefixed form'su-password),form.password.data,form.password.subwidgets.0.data.value(andform.password.0, and iterating the field), theas_text/as_textarea/as_hiddenrenderers (they swap in a widget that prints the value),{% for f in form %}{{ f.value }},form.visible_fields,form.get_context.fields…,form.password.form.initial.password, a{% with %}alias of any of these, thedata/cleaned_data/initialof a formset over a model (andfs.management_form.data, which holds the formset's whole submitted data), and the same paths as the operand of a{% load %}-bridged tag. djust's owndj_form,comboboxandrich_selecttags drew aUserChangeForm's hash into an<input value="…">. The floor is the model's, not the wordpassword:is_superuser,is_staff,DJUST_SENSITIVE_FIELDS, a per-modeldjust_exclude_fieldsanddjust_serializable_fields, thedjust_serialize_sensitive_fieldsopt-out, and the field TYPE floor (sensitive_field_types, #1987) are all consulted through the one authority the model proxy uses (_attr_is_serializable).Migration (read this if a template stopped showing a checkbox, a flag or a value). A floor field's value is now missing from a template path, exactly as
{% if user.is_staff %}already was on the model.{% if form.is_staff.value %}checked{% endif %}renders unchecked, and re-posting that page would then demote the user. Render the control with{{ form.is_staff }}(the widget the form's author chose, unchanged), or read the value in the view and pass it inget_context_data(), or opt the field in withdjust_serialize_sensitive_fieldson the model. The first refused read of each field is logged once as a warning naming the field (User.is_staff … via .value), so the break is found in the log and not by a user losing a flag.The cure is at the chokepoint where the engine reads a path, not at spellings. After the root and after every segment of a path, the engine now calls
djust.serialization._protect_sidecar_walk, which is_protect_sidecar_valueplus three arms: aModelFormbecomes a floor proxy whoseinitial/data/cleaned_dataare COPIES without the model's floor fields (datais matched through its prefix:u-password,form-0-password; the form itself is never mutated:initialis what a disabled field validates against, and a rendered form still saves without clearing the user's password); a formset over a model gets the same for the lists it holds, and itsmanagement_form(a plainFormthat carries the formset's wholeQueryDict) is proxied with the formset's floor; and theBoundFieldof a floor field becomes a proxy that refusesvalue,initial,data,subwidgets(and indexing or iterating the field) and the three renderers that swap in a value-printing widget. A refused name is a missing name: it renders empty, asform.instance.passworddoes. Every otherBoundField, and every field of a form that holds no floor field, is the real object, byte-identical to before (checked against Django's output for the benign paths).template_bridge_floorwalks a bridged tag's operand paths through the same function.The invariant, and where it is enforced. A template walk never receives a real
BoundFieldof a floor field, nor the real form (or management form) behind one. A path segment is floored by the walk function. Every OTHER route by which a form or field becomes a value the template can use (a{% for f in form %}loop variable,visible_fields,hidden_fields,get_context().fields, a|slice,|first/|last, a{% with %}alias, a loop over a formset,bound_field.form,management_form.<field>.form) goes through ONE conversion, and that conversion now stores the floored object as the value's live handle (floored_form_handle,crates/djust_core/src/lib.rs); every sink that reads the handle afterwards (iteration,in, an index or a slice,==, the walk of a loop variable) therefore reads the floor. A formset pins its floor on its management form, which is a plainFormholding the formset's whole submitted data. A consequence, wanted:{% if form.is_staff in form %},{% if f == form.is_staff %}for a loop variablef, and==between a path and an alias all compare as Django does.python/tests/test_form_floor_differential_3790.pygenerates the cross product (root form x producer x sink) and asserts the full secret and its last twelve characters never appear, with a Django-engine control per group.Policy, stated plainly. Django itself prints all of these. The floor is the stricter policy djust adds (SECURE_DEFAULTS Pattern 1), and the Django template engine is unchanged by this fix; only the native engine and the bridge's operand walk apply it.
The limit: widgets are not blanked. The floor guards what a template can READ BACK, not what a form DRAWS.
{{ form.password }},{{ form.as_p }},{{ form }}and a loop's{{ x }}render the widget the view author chose: Django'sReadOnlyPasswordHashWidget(whatUserChangeFormuses) shows a summary of the hash and never the hash, but aTextInputover thepasswordcolumn, or over aDJUST_SENSITIVE_FIELDSfield, printsvalue="…". Blanking an editable control would clear it on the next save (measured: a blank or an omitted text control clears the column and an unchecked checkbox demotes the user), so it is not done; tracked in #3797.Components: a disabled floor field is drawn without its value, an editable one is not.
dj_form,comboboxandrich_selectreadbound_field.value()in Python (through one function,_bound_field_value). A disabled floor field (Django ignores what is posted for it:UserChangeForm.password) is now drawn without its value. An editable floor field (is_staff, aDJUST_SENSITIVE_FIELDStext field the form posts back) is still drawn with its value, because the alternative is silent damage on the next save; the component logs once, naming the field, that it did. Declare the form fielddisabled=True, or leave it out, to draw it without the value.What a tag is handed. A bridged tag is the application's own Python (djust's component readers; a third-party form tag), so it is handed the real form and the real
BoundField, never the proxy. Only a template PATH is floored, so{% tag x=form.initial.password %}receives nothing and{% tag form.password %}receives the field. The engine therefore has two entry points:_protect_sidecar_walkfor a path the template spells (Context::walk_live, the sidecar walk, the bridge's operand walk) and_protect_sidecar_valuefor an object handed to a tag (value_into_handler_pyobject, the handler dict). The Rust side looks the walk function up by name and falls back to the leaf floor when it is absent. The legacy sidecar walk used to hand the raw object on when its floor raised; it now drops the value (the cell renders empty). An extension that predates this fix keeps asking the leaf floor and would lose the form arms silently, soDjustConfig.ready()logs an error whendjust._rust.WALK_FLOOR_COVERS_FORMSis missing: rebuild or reinstall the extension (make dev-build).Reach, measured. A LiveView's own top-level
self.formattribute was not reachable by these paths when this was written (mixins/rust_bridge.pyflattened aBaseForminto the state and left it out of the raw-object sidecar), so there it was not a leak; since #3767 it is a sidecar value like any other object, andpython/tests/test_form_floor_differential_3790.pyruns the whole grammar against a form held as view state. The exposed shapes were a standalone render (DjustTemplateBackend,render_template) with a form in the context, a form held by another object in a LiveView (page.form.initial.password), and the component tags.What this does not floor. A plain
Form(no model behind it: #3799; this includes a plainFormthat carriesself.instance = <Model>, and a management form handed to the template directly as a context variable, since only a formset's ownmanagement_formis pinned); a typedPasswordInputvalue that is not a model column, such asUserCreationForm.password1(held from #3798 on, by widget type);form.fields.<name>.initial(a declared or model-default value, never the instance's); a tag author's own Python and atakes_contexttag reading the context itself; the form's own widget rendering (#3797); an editable floor field in a component; and the Django template engine.Regression cases in
python/tests/test_form_initial_floor_3790.pyandpython/tests/test_form_floor_followups_3790.py(the spelling table for an unbound and a boundUserChangeForm, each with a Django-engine control that prints the hash so the comparison is never two empty strings; prefixed,QueryDictand formset data; the components; what posting a rendered page back does; what widgets do; the real object handed to a tag;LiveViewTestClient, HTTP GET and WebSocket mount). Each mechanism is gate-off tested separately. The code is_protect_sidecar_walk,_SidecarFormProxy,_SidecarFormSetProxyand_SidecarBoundFieldProxyinpython/djust/serialization.py,_bound_field_valueinpython/djust/components/templatetags/_forms.py,protect_sidecar_walk_strictincrates/djust_core/src/context.rs, andreleaseinpython/djust/template_bridge_floor.py. Requires the matching compiled extension (rebuild withmake dev-build).A SECRET floor field of a form is never rendered with its stored value, and the column is protected from being rewritten by the empty control (#3797). BREAKING for a template that relied on a secret floor field echoing its value. #3796/#3798 refused every way a template could READ a model form's floor-field value back, but
{{ form.password }},{{ form.as_p }},{{ form }}and{% for x in form %}{{ x }}{% endfor %}still printedvalue="..."for a floor field with a plain widget (thepasswordcolumn over aTextInput, a declaredapi_token), the body of aTextarea, theselectedoption of aSelect, thecheckedof a checkbox and the parts of aMultiWidget;{{ formset.as_p }},as_widget,as_field_group,BoundField.render(), the{name: html}snapshot of a form kept in LiveView state and theBoundFieldhanded to a bridged{% load %}tag or filter ({% render_field form.password %},|add_class) did the same. Such a field's control is now drawn the way Django draws a field that has no value, by Django's own widget on a copy of theBoundField(value()andinitialanswerNone; its own posted keys, including Django 5's hidden-initial value, are removed from a copy of the form; its validation messages that interpolate the value, such as adisabledChoiceField'sinvalid_choice, are replaced by a generic one): novalue=, an empty<textarea>, noselected, an unchecked box, emptyMultiWidgetparts, with no cooperation from a custom widget. Only SECRET-LIKE floor fields are blanked: a password widget, a name with apassword/secret/token/key/hash/salt/pwd/otpword (api_token,secret_key), or asensitive_field_typescolumn. Privilege flags (is_staff,is_superuser), selects and ordinary declared-sensitive text (email,first_name) keep drawing their real state, because an empty control is posted back empty (a blank text control clears the column, an unselected<select>posts its first option, an unchecked box posts nothing and a classic POST of the stockUserChangeFormwould demote a superuser). The column is protected where Django can protect it, on the FORM FIELD:FormMixin.get_form()(soModelFormMixin) marks every blanked fielddisabled=Trueon a form that edits an existing row, so Django ignores the posted value and keeps the stored one; HTMLdisabled/readonlyand leaving the control out still save"". A plainModelFormin a classic view cannot be protected at render time: the new system checkdjust.S014reports an editable secret floor field in aModelFormwith the fix. djust's component tags draw an editable secret floor field empty too; another editable floor field keeps its value (as before). Kept by their own contract: Django'sReadOnlyPasswordHashWidget(exact type; a masked summary) andPasswordInput(render_value=True)(the author's explicit echo); the model'sdjust_serialize_sensitive_fieldstakes a field off the floor entirely. A value the project's own code puts elsewhere (widget.attrs,help_text, a closure holding the form) and a bridged tag that walks the real FORM it is handed are not covered. A field the protection makes read-only is logged once per (model, field) with the ways out (djust_serialize_sensitive_fields, a different field name, ordisabled=Trueyourself): it reaches fields that only look secret (sort_keynamed indjust_exclude_fields) and a "set a new password" box on an existing row. The name heuristic has known limits (pass,passcode,pw,ssn,cvv,card_number,auth_code,bearer,jwt,totp_seed,recovery_codeare not matched); aget_form()override that does not callsuper()is not protected;djust.S014also reports a form that only aFormMixinview uses.What a person typed into a password input can no longer be read back by a template under the native engine, on any form (#3798). Narrowly BREAKING for a template that branches on, or prints, such a value. A bound
UserCreationFormholds the typed password underpassword1/password2, which are no columns ofUser, so the model floor of #3790 (keyed on the model's field names) did not name them:{{ form.password1.value }},{{ form.data.password1 }}and{{ form.cleaned_data.password1 }}printed the plaintext, as did every sibling spelling (.data,.initial,form.data/form.initial/form.cleaned_dataas a whole or by iteration, a prefixed form'su-password1,{% for f in form %}{{ f.value }},visible_fields, a{% with %}alias, a formset'sdata/cleaned_data/initial). A plainFormhas no model floor at all (#3799), so the same read worked on any form with a password field.The floor is now also keyed on the WIDGET. A field whose widget is a password input (
PasswordInputor a subclass, any widget withinput_type == "password", or aMultiWidgetwith such a part, whose per-part keyspair_0/pair_1are named too) is a floor field by type, whatever its name and whatever the form is (aModelFormor a plainForm). It sits at the chokepoint #3796 built (_model_form_floor->_floor_field_readable), so the form, bound-field and formset proxies and the engine's value conversion all hold it with no per-spelling code, and the Rust extension is unchanged. The test is one function,djust.serialization.field_has_password_widget, whichdjust.forms.is_sensitive_form_field(persisted_form_input) now asks too, so the two cannot disagree about what a password field is. A formset's own lists are read from the forms it built as well as the form class, so a field added in a form's__init__is held. djust's owndj_form,comboboxandrich_selectdraw a password field blank whatever its widget class is called: aPasswordInputsubclass under a name_get_field_typedoes not know fell to the text branch and printedvalue="…".What is NOT lost (measured). Django's
PasswordInputnever writes a value back into the control (render_value=False), so{{ form.password1 }},{{ form }},{{ form.as_p }}and the errors of a typed-but-invalid form are byte-identical to Django's with and without this change, and a re-save posts what the person types now, not what was typed before. The view still readscleaned_data["password1"]andform.save()still hashes it; the proxy replaces nothing on the form. Migration: a template that printed the typed value, or branched on it ({% if form.password1.value %}), now sees nothing and takes the else branch. Compute what the template needs inget_context_data(). The first refused read of each field is logged once, naming the field and never the value.Not floored, by decision. A plain
Formfield that is merely NAMEDpasswordover a text widget, and a plainFormhandedinitial=model_to_dict(user), are #3799 (a name-based floor for every form would blank a login form's typedpassword; that is a product decision).PasswordInput(render_value=True)writes the value into the control on purpose and still does; the template's reads of it are refused. A view's own dict (form_data) is not a form. The Django template engine is unchanged.Regression cases in
python/tests/test_password_widget_floor_3798.pyand, as roots of the existing grammar,python/tests/test_form_floor_differential_3790.py(a boundUserCreationForm, a bound model formset over it, a bound and an unbound plain form with aMultiWidgetand a late-added password field, and a plain-form formset, each x every producer x every sink, each with a Django-engine control that prints the secret). Each mechanism is gate-off tested separately.Also stricter:
persisted_form_input. A field the new widget test classes as sensitive (aTextInputsubclass withinput_type = "password",TextInput(attrs={"type": "password"}), aMultiWidgetpassword part) is now refused bypersisted_form_input(...)at view-class definition withExposureConfigurationError. Before, such a typed value could be persisted to the server session. A view that declared one of these as persisted now fails at import: remove it frompersisted_form_input, or give the field a realPasswordInput.{% include obj %}and the component tags called arender()/get_full_name()markedalters_dataordo_not_call_in_templates(#3815).render_template_object(python/djust/template/operands.py) calledobj.render()on a context object named by{% include obj %}with no look at either marker; the component tagsavatar_group,AvatarGroup,LiveIndicatorandPresenceAvatarscalleduser.get_full_name()the same way. Stock Django does call such arender, so this was Django parity; djust is now stricter than Django here on purpose, on the rule the serializers already apply throughdjust_core::template_call_guard(#3772, #3778). The guard covers the five entriesDjustTemplateBackend,_rust.render_template,RustLiveView, a LiveView's WebSocket render and a LiveView's HTTP GET render. Arenderwhose marker is truthy is not called and the include renders the empty string; a flaggedget_full_nameis not called and the avatar falls back tostr(user), the branch a blank name already takes. Neither raises; each logs one WARNING per type and method per process. A marker read that raises anything butAttributeErrorrefuses too (fail closed), and the method is looked up once so the object checked is the object called. An unmarkedrender()/get_full_name()is called exactly as before. Not covered: the templates of{% live_render %}/ sticky and embedded child views are rendered by Django's stock engine and still call a markedrender; a test pins that so a later fix flips it on purpose.{% extends obj %}was never affected (it reads a compiled template's source and does not callrender). Regression cases inpython/tests/test_render_call_guard_3815.pycount the calls on a real payload, with Django's own engine as the control.Attributes passed through to a tag now follow one policy: no inline event handlers, no script URLs, no
typethat submits (#3818).{% live_input %}forwards every keyword it does not know as an HTML attribute, andFormMixin.as_liveforwards a Django widget'sattrsandrender_iconits extras.build_tagescapes the value and (since #3774) checks the name, but neither says what the attribute does:live_input("text", handler="h", formaction="javascript:alert(1)"),onfocus=...andtype="image" src="javascript:..."rendered as given on both engines (the native engine bridges to the same tag), whiledjust.theming's_passthrough_attrsalready refused them.djust._attr_policy.passthrough_attrsis now the one place that decides, called bylive_input's collector,BaseAdapter._merge_widget_attrs(and_get_field_type, where a widget'sattrs={"type": ...}becomes the tag's owntype) andrender_icon(and with it the native{% icon %}handler). Only an attribute NAME raises: an event-handler attribute (on*, and the@,x-on,v-onandhx-onspellings) raisesValueError. A VALUE never raises, because a template can take it from data (formaction=profile.website) and an error page that a stored hostile value triggers for every viewer would be an availability hole of its own: a URL whose scheme the shared allowlist rejects (javascript:,vbscript:,data:,blob:,file:, custom; read the way a browser reads it) renders#and is logged by attribute name inhref,src,action,formaction,poster,srcset,ping,data,background,xlink:href,dj-navigateanddj-patch(asrcset/pinglist is judged entry by entry), and atypevalue ofimage,submit,resetorbuttonis left out so the field keeps its own type. Everything else passes through byte-identically, including othertypevalues (date,color...),aria-*,data-*,style,x-*andhx-*. Behaviour change: a project that passesonclick=/onchange=throughlive_input, widgetattrsor an icon now gets aValueError(use adj-*binding); ajavascript:URL informaction/hrefnow renders#instead of a live script URL.djust.theming's own implementation is unchanged (it still raises for a script-bearinghref/src/action/formaction);python/tests/test_passthrough_attr_policy_3818.pyruns both against the same payloads, asserts the documented differences and nothing else, and pins the URL attribute sets equal. Regression cases there cover both engines, the widget route, the icon route, theming parity and a tripwire over the set of passthrough writers; gate-off verified.A
{% live_render %}lazy placeholder is escaped unless it is marked safe, and the JSON inlined in a<script>is script-escaped (#3819).lazy={"placeholder": ...}was spliced into<dj-lazy-slot>as raw HTML with no stated contract, so a placeholder built from data injected markup. It now follows Django's own rule (conditional_escape): a marked-safe value (mark_safe,format_html, anything with__html__, a lazy marked-safe string) is markup and is spliced in as written, a plainstris text and is escaped. Behaviour change: a project that passed"<div>Loading...</div>"must wrap it inmark_safe(...); the contract is documented in the streaming-render guide.routing.get_route_map_scriptputjson.dumps(routes)inside a<script>withmark_safe, on the argument that URL config cannot hold</script>;json.dumpsleaves<,>and&raw and nothing enforced that argument, so it now passes the JSON throughescape_json_for_script(the helper the debug panel and the PWA tags use). The same one-line fix applies to the two other developer-config blobs inlined in a script: the handler-metadata script (window.handlerMetadata) andwindow.DJUST_LOADING_GROUPING_CLASSES. Output is byte-identical for any value with none of<,>,&, U+2028, U+2029.python/tests/test_raw_slots_3819.pyand the payload sweep (live_renderis now given a hostile plain-str placeholder), gate-off verified.The safe-markup guard (
scripts/lib/safe_markup_taint.py) now follows a name's flow, narrows on membership tests, trusts the project's own escapers by origin and in the position they are clean in, and sees the twomark_safewrappers_safeandsafe_html; the #3774 audit list shrinks from 199 flagged sinks to 55 (#3820).Over
python/djustthe guard flagged 125 sinks (#3774 audited and kept 96 of them in 93 functions, mostly its own limits). It also could not see_safe(...)(about 190 call sites incomponents/rust_handlers.py) ordjust._html.safe_html(...), so a newdef f(v): return safe_html(f"<div>{v}</div>")passed the guard, the audited baseline and the payload sweep alike. Both are sinks now, and so is any function the guard finds, by data flow, to mark one of its parameters safe (def _ms(v): x = v.strip(); return mark_safe(x), a lambda, apartial,str(v),*args), and a test names every such wrapper in the package. With the wrappers the first guard flagged 199 sinks; the guard as it is now flags 55 in 51 functions. 144 of the 199 are proved by the guard and their audit entries are deleted rather than kept; 15 sinks in 14 functions were read by a person and are recorded inscripts/lib/safe_markup_audit.pywith a reason and evidence (verdict S: read and traced, not proved). This is a statement about what the guard can prove, not a claim about the sinks it cannot: the guard has known false negatives, listed in its docstring and in one issue.What the guard learned, each rule shown by a dirty shape and its clean twin in
python/tests/test_safe_markup_guard_3820.pyandtest_safe_markup_guard_3848.py: flow (width = int(width)leavesf"{width}"clean; a coercion in only one branch, past acontinue/break, in a loop that comes back round, in atrybody whose handler runs, in an earlierexcept*handler, in awiththat can swallow, after a+=/append, in a closure,global,nonlocal,:=,finally,import/matchrebinding or besidelocals()does not); narrowing (x in CONSTANT,x == "a"andisinstance(x, int)clearxon the branch where they hold, from either edge;x == othernarrows nothing, and neither does a collection that anything in the file changes in place (.append,.update,.setdefault,D[k] = v,|=,__setitem__, throughself.X,cls.Xor an alias), declaresglobal, or hands on (a = D,f(D),return D), the wrong edge, orassert); the project's escapers, trusted by the module that DEFINES them, never a re-export or a same-named twin or an import the file rebinds (safe_url,url_attr,srcset_attr;safe_css_color/_length/_ratio/_numberwhen theirdefaultis clean and no*args/**kwhides it; the record buildersupload_attrs,voice_extras,map_picker_attrs,pad_extras,cropper_extras,menu_attrs,clamp_columns,clamp_cells,rich_html,render_markdown), each pinned by a hostile-payload test of its contract. A value-escaper's output is clean in element text or a QUOTED attribute value and not in an unquoted attribute (<a href={safe_url(x)}>keeps its spaces);escape_json_for_scriptis clean only forjson.dumpsoutput in<script>text (it leaves"alone);attr_nameis not cleared at all (onmouseoveris a legal name); class tables and constants (self.ICONS.get(k, "")) are trusted only when nothing writes them: not undersetattr,__dict__,__setattr__,vars,globals()anywhere in the file, not in a class that inherits (across the package) from one that writes its constructor keywords withsetattr(Component), not when the table is changed throughself/cls/ an alias, not in a dataclass-style annotated field, and not when a class of the same file overrides them;d.get(k, DEFAULT), calls to a helper the file defines (judged by what it returns, with its parameters raw; not whenglobalorsetattrcan replace it, and a bare name in a method is the module's, not the class's),sum/maxof clean values and comprehensions of them, a Rust handler's blockcontent, which the engine hands over as aSafeString(checked against the compiled engine), and a depth bound that FLAGS a chain it cannot follow instead of clearing it.Found while teaching it:
TABLE.get(key, default)was judged by the table only, so a rawdefaultcame out clean (no site in the tree used it). Fixed, and pinned with the rule switched off. Three tag modules (_advanced.py,djust_components.py,_forms.py) importedsafe_url,_dev_tools.pyimportedconditional_escapeand three call sites importedescape_json_for_scriptthrough a re-export, which the guard rightly did not trust (the #3774 lesson); they import from the defining module now, andTestNoEscaperIsImportedThroughAReExportis the lint that finds the class without running the guard.The flow analysis is checked against real execution: a differential fuzz generates random functions, runs them with a marker as the raw value, and fails when a
mark_safethat received the marker was not flagged (no false negative in 40 000 programs across 16 seeds). 57 unsafe functions from an independent review's corpus are RUN (each must emit the payload raw) and flagged; 27 of them the earlier head of this change had stopped flagging and are regression-tested by name. Twenty-two deliberately broken analyses (a loop that forgets itsbreak/continuestates, anifthat follows one branch, a merge that keeps the first path, narrowing on the wrong edge or against any expression, anassertthat narrows, an exception that sees only the state before thetry, a helper that is always clean, hazards that are not found, a read from another scope that is always vouched for, a class body visible to its methods, a depth bound that fails open, ...) are each caught by the fuzz or a directed row. The payload sweep now also drives the Rust-native handlers and parses what they print. The safe-markup baseline check is onmake merge-gates(test_safe_markup_gate_3848.py), because two PRs can break it together.A context value reaching a
{% djust_pwa_head %},{% djust_pwa_manifest %},{% djust_sw_register %}or{% djust_offline_indicator %}argument can no longer write template code or markup into the page under the native engine (#3824). The four handlers intemplate_tags/pwa.pybridged to the Django tags by writing the tag into Django template SOURCE askey="<resolved value>"and compiling it, so a value carrying a quote and%}ended the tag early and everything after it was compiled as template text:{% djust_pwa_head name=evil %}withevil = 'a" %}<script>alert(1)</script>{% djust_pwa_head name="b'rendered a live<script>alert(1)</script>. All 16 keywords the four tags take through this bridge were reachable, through a plain variable and through a dotted one (u.name); a tenant-configurable app name was enough. The Django engine resolvesname=varas a value and was never affected. The handler now builds its source from constants only (a tag name and keyword names checked against_TAG_PARAMS, then one context-variable reference per keyword,name=kw_name) and passes every value in the render context, so Django resolves it as data and the tag's own escaping applies.icons(a list of dicts, which this bridge can only ever pass as a string) is no longer accepted:{% djust_pwa_manifest icons=... %}under the native engine now renders the failure comment instead of a silently odd manifest. A keyword the tag does not take, or a tag name outside the table, raises into the existing marked<!-- djust: ... render failed -->diagnostic instead of reaching the source. Two consequences to know: a quote-bearing value used to fail with that diagnostic and now renders, escaped; and an unquoted-safe literal such astheme_color="Tom & Jerry's"is now escaped in<meta content>(Tom & Jerry's) because the value is a plain string rather than aSafeStringliteral. Ordinary values render the same bytes (a differential over 48 ordinary cells: no difference), and for all 208 tag x keyword x payload cells the native engine now renders what the Django engine renders (89 differed before).theme_color/background_colorthat are not colour-shaped and asw_urlcarrying ajavascript:/data:/vbscript:/blob:scheme or a control character are refused in the shared Django tag functions (the default is used and the parameter name is logged), so both engines agree.python/tests/test_pwa_failure_diagnostic_2434.pyand the_ARG_VECTORSrow intest_custom_tag_return_escape_2379.pyused a double-quote-bearing value as their only way to reach the diagnostic; both now use an unsupported keyword. Regression cases inpython/tests/test_pwa_tag_template_injection_3824.py, gate-off verified (ten mutations, each caught).A POST key of any length now costs linear work to classify against the form floor (#3830).
_floor_data_key_readableasks the floor about aform.datakey and about every--delimited tail of it, and it sliced and hashed every tail in full, so the cost was quadratic in the key's length. A client could POST a key of dashes to any view whose template readsform.dataon a boundModelForm(or a formset, ormanagement_form.data) and hold the worker's GIL: a 32 KB key cost ~0.7 s and a key at Django's 2.5 MBDATA_UPLOAD_MAX_MEMORY_SIZEextrapolated to tens of minutes (5,000 keys of 500 dashes: ~11 s). A tail longer than the longest name the floor treats specially (the form's password names, the model's denied / allowlist / opt-out sets, the sensitive model methods and the model's own columns) is now asked in the form of its firstbound + 1characters, and the keys of a mapping share one set-up (_DataKeyReader). The verdict is unchanged for every key, including a tail the model arm refuses for starting with_or for being absent from an allowlist, which is why the shortcut of skipping long tails was not taken. Counted on the real path (child process, release-independent, pure Python): a 32,000-dash key went from 32,004 questions and 512,016,016 characters to 28 questions and 32,292 characters; a 2,500,000-dash key now takes 0.48 s. The reference function is kept as the oracle inpython/tests/test_floor_data_key_linear_3830.py, which holds the new one to it over 38,494 generated keys on ten floor shapes, and counts questions and characters, not time.A client can no longer grow a form's submitted data without bound over a WebSocket, and a template that reads
form.datamany times no longer pays for it each time (#3834).max_message_sizebounds one frame, butFormMixin.form_dataoutlives it (one client-named key pervalidate_fieldevent, every key of asubmit_formevent, all bound to the form on every event), soLIVEVIEW_CONFIG["max_form_fields"](default 1000, counted beyond the fields the form declares) and["max_form_data_size"](default 2,621,440 characters, every string,bytesand nested list or mapping at any depth) now hold whatFormMixin,WizardMixin, the admin form view and aparams_formkeep, with Django's ownDATA_UPLOAD_MAX_*defaults; an event past them is refused whole, logged without the keys, and reported inform_errors/wizard_step_errors["__all__"]/params_errors["__all__"](the message is cleared by the next accepted event;0removes a limit; a value that is not a non-negative integer is the default, never unlimited). Behaviour change: more than 1,000 undeclared fields, or 2.5 MB, sent over the socket is now refused as it is over HTTP; setmax_form_fields/max_form_data_sizehigher (or0) for a form that needs more. Separately, the serialization floor's filter ofform.datais kept on the form and reused only while the mapping has the same keys under the same floor (a widget swapped on a live form, the model's floor sets, the model's columns,sensitive_field_typesandDJUST_SENSITIVE_FIELDSare all followed; values are read fresh on every read), so ten reads of 100,000 dashed keys filter once (~1 s) instead of ten times.A form's stored data is now bounded by how many members it holds, not only how many characters (#3844).
max_form_data_sizebounds characters, and characters do not bound time: every event hands ALL of the stored form data to the template engine (a Python pass per member, then the extension'supdate_state, about 0.7 microseconds a member), so 174validate_fieldevents of[""] * 15000stored 2.61 million empty members under the 2.6 million limit and made each later event take 0.03 s growing to 1.25 s ([[]] * 7500: up to 3.9 s) over a connection's own state.LIVEVIEW_CONFIG["max_form_members"](default 100,000;0removes it, a value that is not a non-negative integer is the default, never unlimited) now counts every key, every value and every list, tuple, set or mapping inside them as one, however long a string is, and is judged by the same walk, early stops, nesting bound and replaced-entry arithmetic as the size limit, on every path that keeps a form's data (FormMixin,WizardMixin, the admin form view, aparams_form); an event past it is refused whole and reported like the others, inform_errors/wizard_step_errors["__all__"]/params_errors["__all__"], without the keys. Behaviour change: form data of more than 100,000 members over the socket is refused (a form with a multi-select of tens of thousands of options needsmax_form_membersraised, or0); the state sync itself still walks everything that is stored, so the cost of an event at the cap is bounded rather than gone: about 70 ms for flat strings or numbers, and roughly 0.2 to 0.4 s for container-heavy data (a list or mapping weighs one member, the same as a leaf; measured on a loaded machine). Each field counts as two members (its key and its value), so raisingmax_form_fieldsabove about 50,000 needsmax_form_membersraised too. A wizard's member and character limits are the TOTAL across its steps, not each step's (#3858). Also: the exact-limit case inside a bulk container with pending non-leaf members is now pinned for both limits (the>to>=mutation in_charge_restsurvived #3842's tests), and the lazy-provenance benchmark targets are 10x their measured medians, not 5x.A wizard's data limits are a total across its steps,
remove_rowasks the form-data bound before it stores a client-named key, and the three limits are validated when written in code (#3858). The review of #3857 found thatWizardMixinbounded each step's data alone, so a four-step wizard ofrequired=Falsefields kept about four timesmax_form_members(360,000 members against 100,000) and handed every one of them to the template engine on every event;max_form_data_sizeandmax_form_membersnow bound every step together (max_form_fieldsstays per step, a step being a form).FormSetHelpersMixin.remove_rowandremove_row()stored<prefix>-DELETEfor a client-named prefix with no check (3,000 calls reached 3,000 keys againstmax_form_fields= 1000); past a limit the call is now a logged no-op, and only the formset's management keys and the real fields of its rows (belowTOTAL_FORMS, plusDELETE,ORDERandid, indexed in canonical ASCII digits) are exempt frommax_form_fields: any other key under its prefix, an invented suffix under a real row or a zero-padded or non-ASCII index included, counts.config.set()/config.update()ofmax_form_fields,max_form_data_sizeormax_form_membersskipped the validation done at load, so'abc'made every form event raiseTypeError,-5refused everything and3.7/Truewere accepted: a value that is not a non-negative integer is now the default, as at load. The refusal messages and the production "no limit" warning name the setting (and which one is 0). Behaviour change: a wizard whose steps together hold more than the limit is refused (raisemax_form_members/max_form_data_sizefor a long wizard).The Python
data_tabletemplate no longer trusts a marked-safe row value in an attribute (#3894). A row URL (row_url, a link column), a link column'slink_class, the row id of the selection checkbox, a column key and a column width are checked and escaped whatever the value is marked as (mark_safe, a lazy or__html__value), for any kind of column list; a marked-safe URL that was already HTML-escaped is now escaped once more (&becomes&amp;, so pass the plain URL). A marked-safe value in a TEXT cell still renders its markup.Marked-safe values are no longer exempt from the URL scheme check; the no-op
javascript:void(0)/javascript:;hrefs are still accepted (#3898, #3894). Behaviour change: amark_safe(also lazy, or__html__) value used as a URL anywhere a component prints one (the class-based components, the native handlers, the template tags' item data and their own keywords such as{% avatar src=... %}) is now checked against the scheme allowlist and escaped like any other string (an already-escaped&is escaped once more, so pass the plain URL). An ITEM value (an entry of a list of items, or an item dict) that lands in an attribute is escaped whatever it is marked as in every class-based component and in the template tags this change edits (avatar_group,presence_avatars,chat_bubble,mentions_input,sortable_grid,rich_select,live_indicator,activity_feed,data_card_grid,org_chart,carousel,lightbox,model_table's row id and filter value); 34 other tag parameters still leak an item value and are listed, and pinned exactly, in #3904. A scalar argument (a constructor argument or a tag keyword) in an attribute other than a URL is the developer's own and is unchanged. Text slots keep their markup. The literal no-op hrefsjavascript:void(0)andjavascript:;(exact text,hrefonly, never an image source) are accepted marked or not, so a dropdown toggle needs nomark_safe. URL helpers convert values to the exactstrtype before checking them.test_marked_safe_attribute_sweep_3898.pyderives the classes, parameters, tags and item keys from the code and fails on any URL call that leaveshonor_safeat its default.{% modal %}finds its{% modal_description %}block by a marker escaped variables cannot spell. The marker is now<!--dj-modal-description-->, no marker is left in the rendered output, and both engines share the onerender_modalpath.
Documentation
- Audit of the 125
mark_safesites the taint guard flags outsidedjust.theming(#3774).docs/internal/mark-safe-audit-3774.mdrecords the verdict and the evidence for each, andscripts/lib/safe_markup_audit.pyis the machine-checked baseline (python/tests/test_safe_markup_audit_3774.py): a flagged site with no entry, a sink added to an audited function, a stale entry, or evidence that is not in the audited function's own code fails. A payload sweep drives the audited sinks with a hostile value (a markup payload and ajavascript:URL payload), parses the output, and a line-coverage test fails when an audited sink never ran._advanced.py,_charts.pyand_forms.pynow importconditional_escapefromdjango.utils.htmlinstead of through._registry, which removes 29 of the 125 sites from the guard's output without a behaviour change. The wrappers_safe(...)andsafe_html(...)are outside the guard (#3820). - A row's
__str__is documented as public text (#3806).djust_exclude_fields,djust_serializable_fieldsand the serialization floor decide which fields of a row a template can read; they do not decide whatstr(row)says, because"__str__"is an identity key every serialized model carries (and Django's own engine prints the same text). A model whose__str__prints a field the floor withholds therefore shows that field's value in{{ row }}while{{ row.that_field }}is empty.docs/SECURE_DEFAULTS.md(section 1) anddocs/SECURITY_GUIDELINES.md(Common Vulnerabilities 8, and the review checklist) now state the paths that ship it (a top-level loop over a QuerySet, a model in the context, a foreign key read whole, a row reached through.all, and on theDjustTemplateBackenda QuerySet's.first/.last), that the exclusion and allowlist rules do not constrain it, the Django parity, and the author guidance: build__str__from public fields only. No behaviour changes. Pinned, with Django's engine as the control, inpython/tests/test_row_str_is_public_text_3806.py(which also records, as a strictxfail, that a LiveView attribute that is a top-level QuerySet renders{{ rows.first }}as nothing: it withholds more than documented, never less). - The kept-rows cost and the sliced-name gap of the QuerySet
.allmethod read are measured and recorded (#3867). Items 3 and 4 of the #3863 review. (3) A mixed read (rows and.counton one.all) through{% for u in users|slice:':2' %}still prints no count where Django prints one, on a QuerySet and alist[Model], through the Django-backend engine and a LiveView: the kept rows are reached by the loop variable's alias forusers.<N>, a filtered operand registers none, and the serialized rows carry no handle of their own. Closing it means shipping handle-bearing rows, a change to what the serializer ships, not a contained fix in the extractor or the sidecar, so it is declined and pinned by 16 strictxfailcells (12 are this gap; the 4list[Model]cells on the Django-backend engine are attributed to #3888, where the sliced loop is empty whatever is read) plus controls that show the gap is exactly the mixed read (python/tests/test_queryset_all_slice_3867.py). (4) The kept rows measured at 20,000 users, release extension: render 1.19 to 1.34 s without them, 1.96 to 2.00 s with them (+48 to +69%), peak RSS 296 MB to 358 to 360 MB (about 3.2 KB a row); no cheap, obviously correct saving exists (a lazy proxy changes how the serialization floor is built), so the figure is indocs/JIT_SERIALIZATION_PATTERN.mdinstead. A different finding made on the way is filed as #3888 (a relation read inside a sliced loop over alist[Model]is empty on the Django-backend engine, with no kept rows involved). - Documented the merge-queue ruleset (how it is set up, how to roll it back) and the
gh run view --log64 KiB truncation gotcha indocs/testing-strategy.md. - Recorded in
docs/testing-strategy.mdwhat the first real merge-queue run confirmed (base_sha,base_ref, scope classification) and the review and push settings onmain.