Repository navigation
v1.2.4
A 1.2 patch release that includes security fixes; upgrading from 1.2.3 is recommended.
Changed
- Markdown output keeps at most 32 containers open at once.
djust.render_markdownand{% djust_markdown %}emit one<blockquote>per>and a<ul>/<li>pair per list level, so the depth of the output, and the time spent parsing and diffing it, grew with the nesting of the source. A block quote, list or list item that starts whileMAX_MD_NESTING(32) containers are already open is now rendered without its own tags; its content stays in place inside the innermost kept container, so no text is lost, every kept<li>stays balanced, and the cost of rendering stays proportional to the length of the source. Documents that nest 32 containers or fewer (ordinary documents: 914 distinct Markdown files from the docs of this repository and its sibling projects all render unchanged, and the deepest nests 6) render byte for byte as before. The bound is exported asdjust._rust.MAX_MD_NESTING. Regression cases inpython/tests/test_markdown_nesting_bound.pyand themarkdownunit tests ofdjust_templates. - Terminal's ANSI conversion keeps a bounded number of styles open per line. Each open style is a nested
<span>until a reset or the end of the line, so a short line could nest as deep as it was long. The conversion (in theTerminalcomponent, the inline handler and the{% terminal %}tag) now keeps at most 32 styles open per line and opens styles from at most the first 16 parameters of a sequence (the client hook reads the same 16); a style past either cap is dropped, and a reset anywhere in a sequence closes every open style. Lines that stay under the caps, which is every line a program prints in practice, render byte for byte as before. Regression cases inpython/tests/test_terminal_ansi_span_cap.py. - The VDOM bounds the nesting depth of the HTML it parses. Every walker over the VDOM (diff, patch serialization, serialization back to HTML) recurses once per nesting level, so a page whose markup nests thousands of elements deep needs more stack than the rendering thread has. The parser now keeps at most
MAX_VDOM_DEPTH(256) levels, counted from the LiveView root: an element at the limit keeps its tag and attributes, and everything below it is flattened to its text content, so nothing nests deeper and no text is lost (except below an SVG or MathML<style>,<script>or other raw-text-named element at the limit, which keeps no text: its descendants' text is not its own). Pages are unaffected: the deepest of 92 rendered demo-project pages and 316 template files nests 14 levels, and parsing, diffing and serializing them gives byte-identical results before and after. The search for thedj-rootelement no longer walks the raw tree recursively either. The limit is exported asdjust._rust.MAX_VDOM_DEPTH; the cached-subtree fast paths (a re-rendered component, a loop item) fall back to a full parse when grafting a subtree would pass it. Regression cases inpython/tests/test_vdom_nesting_bound.pyandcrates/djust_vdom/tests/test_nesting_depth_bound.rs.
Fixed
- Test fixtures no longer write
user.name = Test,user.emailand
commit.gpgsign = falseinto the real repository's.git/config(#3179).
The self-tests for the pre-commit wrapper and the shared-git-config checker
built their git environment from the wholeos.environ. When pytest ran with
GIT_DIRexported, as under a git hook, theirgit init/git config
calls re-initialised and configured the real checkout. Commits made there
afterwards were authored "Test". Both now use
tests.git_env.isolated_git_env(). Every test module that spawns git, or a
script that runs git, stripsGIT_EXECUTION_VARSwith an autouse fixture.
The rootconftest.pyalso strips them for every test, which covers library
code that runs git in-process.tests/test_git_env_guard_3179.pyruns the
fixed helpers against a throwawayGIT_DIRthat must stay unchanged, and
adds a static guard that fails on any unprotected process spawn in a
git-spawning test module. - Components check the CSS values they write into
styleattributes. A
colour, length, ratio or number that a component takes from its caller and
writes into an inlinestyleattribute is now checked against the shapes the
component documents, and anything else is replaced by the component's
default. Colours accept#rgb/#rgba/#rrggbb/#rrggbbaa, a CSS named
colour (withtransparentandcurrentcolor),rgb()/rgba()/hsl()/
hsla()with plain numeric arguments, andvar(--name)references; lengths
accept numbers with a unit, the usual keywords,var(), andcalc()/min()/
max()/clamp(). Valid values render exactly as before. Covers
CursorsOverlayandCollabSelection(the user colour),CalendarView
event colours,Metersegment colours,ColorPicker, kanban column colours,
the data table colour scale, and the heights, widths, gaps, offsets and
ratios ofMapPicker,RichTextEditor,ScrollArea,StickyHeader,
DataGrid,DataTable,ResizablePanel,SortableGrid,DashboardGrid,
AspectRatio,ResponsiveImage,SplitPane, the progress components and
{% theme_skeleton %}, on every render path (component class, template tag
and Rust template engine). The helpers are
djust.components.utils.safe_css_color,safe_css_length,safe_css_ratio
andsafe_css_number. - Text in an SVG or MathML
<style>/<script>is serialized as escaped text. The VDOM wrote the text of any element namedscript,style,noscript,xmp,iframe,noembed,noframesorplaintextverbatim, by tag name alone. The HTML serialization rules apply that only to HTML elements: inside<svg>or<math>these are foreign elements, whose text the parser decodes, so it has to be escaped again when written back (<stays<). The serializer now tracks the namespace while it writes (svgandmathswitch to foreign content;foreignObject,desc,title, MathML text elements and HTMLannotation-xmlswitch back), so re-parsing the HTML gives the same tree. HTML<style>and<script>output is byte-identical, and so is SVG CSS without<,>or&. HTML raw-text content is written verbatim exactly as the HTML serialization algorithm writes it, including a script that holds</script>inside<!-- … -->(the legacydocument.writepattern) and<plaintext>. An inserted{% if %}block (the HTML of anInsertSubtreepatch) is written the way the page writes it, since the client now parses it in its parent's namespace, except that a<noscript>there always has its text escaped: the client parses that HTML with scripting disabled, where<noscript>holds markup, not raw text. Therender_binary_difftext-only fast path now updates a text node only when the HTML's text runs and the tree's text nodes line up one for one, and never inside a raw-text element: it mapped changes by byte offset, so dropped whitespace or text outside the root sent a change to the neighbouring node, and it decoded<inside a<script>. The text fast paths (the in-place update,try_text_only_vdom_update,try_text_patches, and the fragment and text-region paths ofrender_with_diff) also require each text node to sit in the element the source has open around its text: text directly inside a<table>is moved before the table by the parser, so two equal texts could line up in the wrong order and a change was applied to the other node. Such updates, and any start tag the parser would move out of a table, now take the full parse. The text fast path also falls back to a full parse for every<style>or<script>that follows an<svg>or<math>start tag; it used to count</svg>/</math>to decide when foreign content closed, and the tree builder ignores some of those end tags (a stray</math>inside<svg>, an</svg>insideforeignObjectordesccontent), so a text change could still be sent to the wrong node. The serializer also starts from the real namespace of the root: the parser reports it, so adj-rooton an element inside<svg>or<math>(<svg><g dj-root>) writes its<style>/<script>text escaped, as does itsdj-update="ignore"cache, and the HTML of anInsertSubtreepatch is written for its parent's namespace; the text fast paths run only under an HTML root. Regression cases inpython/tests/test_vdom_foreign_raw_text.py,python/tests/test_vdom_html_raw_text_verbatim.pyand incrates/djust_vdom/src/lib.rs. - A dj-root on an SVG or MathML element keeps its children's namespace through
html_update,html_recoveryand the morph of a prerendered page on mount. These three paths parsed the root's inner HTML in an HTML context (DOMParser, orinnerHTMLon a<div>), so every child of a dj-root placed on an SVG or MathML element came out as an HTML element and replaced the SVG / MathML element of the full render: an<iframe>,<a>,<form>,<details>or<video>written there was a working HTML element after the update, where the full render has an inert SVG or MathML element.html_updateandhtml_recoverynow parse the frame as the root's children in the root's namespace, inertly (_parseRootFrameHtml), and the mount morph parses it in a detached element of the root's own namespace and name (_parseRootHtmlLive). A dj-root on an HTML element parses exactly as before. Regression tests intests/js/full_html_frame_namespace.test.js. html_updateandhtml_recoverykeep the content of a<noscript>as text. The page reads a<noscript>with scripting enabled, so its content is text, and the server writes that text as it is (#3045). The client parsed these frames with scripting disabled (DOMParser), which reads the same content as markup, and the morph then put those elements into the page: an<img>or<a>written inside<noscript>became a working element after the update. When the inert parse of a frame holds an HTML<noscript>, the client now parses the frame again as the mount morph does, in a detached element of the live document, which reads it with the page's scripting flag. Frames without a<noscript>keep the inert parse. Regression tests intests/js/full_html_frame_namespace.test.js.- A node inserted inside
<svg>or<math>by anInsertChildpatch keeps the SVG / MathML namespace. The client chose an inserted element's namespace from its name alone: an allow-listed HTML name (iframe,a,form,details,video) inside<svg>or<math>became a working HTML element where the full render has an inert SVG or MathML element, and MathML names became a<span>. The client now derives the namespace from the element the node is inserted into, by the same rules a page parse follows (foreignObject,desc,title, the MathML text elements and HTMLannotation-xmlhold HTML), and creates allow-listed names, MathML element names included, in that namespace. Names outside the allow lists still become a<span>, and an element under an HTML parent is created as before. Regression tests intests/js/insert_subtree_namespace.test.js;tests/playwright/test_insert_subtree_namespace.pycoversInsertChildin Chromium too. - The children of a placeholder for an SVG or MathML element keep their namespace. An
InsertChildpatch turns an SVG or MathML element outside the client's allow lists (filter,fe*,animate*,set,mstack, an unknown name) into a<span>, and then created its children inside that HTML<span>: an<iframe>,<a>,<form>,<details>or<video>there became a working HTML element, where the full render has an inert SVG or MathML element. The children are now created in the namespace of the element the<span>stands in for. The placeholder itself stays a<span>. Regression tests intests/js/insert_subtree_namespace.test.js. - An inserted
{% if %}block inside<svg>or<math>keeps the SVG / MathML namespace. The client parsed the HTML of everyInsertSubtreepatch in an HTML<template>, so the elements of a conditional block inside<svg>or<math>came out as HTML elements, while the full render has them as SVG or MathML elements: an<iframe>,<a>,<form>,<details>or<video>written there is inert in the full render but was a working HTML element after the patch. The client now parses that HTML as children of the element it is inserted into: inside an<svg>or<math>wrapper for an SVG or MathML parent (a MathMLmi,mo,mn,ms,mtextorannotation-xmlparent is reproduced as well), and as HTML, as before, for an HTML parent and for the HTML integration points (foreignObject,desc,title, andannotation-xmlwith an HTMLencoding). The server writes that HTML for that parse. An HTML<noscript>in an inserted block is written with its text escaped, so its content stays text as in the full render. Regression tests intests/js/insert_subtree_namespace.test.js,python/tests/test_vdom_foreign_raw_text.pyandcrates/djust_vdom/src/lib.rs;tests/playwright/test_insert_subtree_namespace.pycompares real patches with a full render in Chromium. - The mount morph no longer runs a MathML or SVG
<script>. After the morph of a prerendered page, the client re-creates each classic<script>in the dj-root as a new HTML<script>so that inline page scripts run (#1848). It also matched<script>elements inside<math>and<svg>and re-created them as HTML scripts, so a MathML<script>, which never runs in a full render, ran after the morph._runInsertedScriptsnow re-creates only HTML<script>elements. Regression test intests/js/mount-morph-script-exec-1848.test.js. - Live transports follow Django's
LoginRequiredMiddleware. With
django.contrib.auth.middleware.LoginRequiredMiddleware(Django 5.1+, or a
subclass) inMIDDLEWARE, a view mounted over a live transport (WebSocket,
SSE, API dispatch,mount_batch) now gets the answer the middleware gives
for its page: an anonymous user is sent to the login page, where the
middleware would send them (itslogin_urlandredirect_field_namefor
that route), unless the view is public. A view is public when its
dispatchcarries Django'slogin_not_required
(@method_decorator(login_not_required, name="dispatch"), public
everywhere), or when the URLconf routes that view class through
login_not_required(MyView.as_view()), or when the request's own route
serves the view and the installed middleware (subclass exemptions
included) lets it through. A view the URLconf does not route, mounted
beside the page view or hydrated as a lazy child over the WebSocket, needs
thedispatchmarker to be mounted anonymously; naming a public page's URL
does not make it public. When the middleware refuses such a view, only that
view is refused (anerrorframe withcode: "login_required", or a
failed[]entry in themount_batchreply) and the page stays where it is;
the first view mounted on a socket is the page view, and its refusal sends
the page to the login page. That is the middleware's answer: a view refused
by its ownlogin_required = Truestill sends the page to the login page,
and a view that relies only onpermission_required,check_permissions()
or Django's auth mixins gets the middleware's answer first, so beside the
page view it is refused on its own. On SSE and the API the request is the
endpoint's, so a view served there anonymously needs thedispatchmarker
or alogin_not_requiredroute of its own (and the endpoint itself must be
exempt from the middleware for the request to arrive). Views rendered inside
a page ({% live_render %}children) take their page's answer, as Django
never checks what a page includes, and a page's own HTTP GET and POST are
left to the middleware itself. djust's ownlogin_required = Falseis not
an exemption, as it is not for the middleware. Without the middleware, or on
Django < 5.1, nothing changes. {% masonry_grid %},{% dashboard_grid %}and{% audit_log %}escape
caller values in the Rust template engine. The component classes and the
Django template tags escape each masonry item'scontent, each dashboard
panel'scontentand each audit-log column name (written into the cell's
classattribute); the Rust-engine handlers wrote them unchanged. They now
escape them the same way. The Rust engine passes list arguments to component
handlers as data, so markup marked safe inside items is escaped there too;
render such items with the component class or the Django tag. The
MasonryGridandDashboardGriddocstrings and the gallery examples now
show text content. Tests in
python/djust/tests/test_rust_handler_item_escaping.py.
Security
-
The djust admin change list filters and sorts only on declared fields.
ModelListView.apply_filteraccepts the field names in the ModelAdmin's
list_filter(get_list_filter()), andsort_byaccepts the sortable
list_displaycolumns and the fields named inordering(get_ordering()),
as Django's admin does. The queryset applies the same check to the stored
filters and ordering, so any other name (another lookup, a path through a
relation, an expression) is ignored and logged at debug level. The filter
list on the page now comes fromget_list_filter()as well. A custom
change-list template that filters or sorts on other fields must declare them
inlist_filter,list_displayorordering. The auth admin's social
accounts list sorts only on its own columns. -
The djust admin loads rows through the ModelAdmin's
get_queryset().
ADjustModelAdminthat scopes rows inget_queryset(request)was honoured
by the change list only: bulk actions ran on the selected primary keys
throughModel.objects, and the change and delete views loaded their
object withModel.objects.get(). As in Django's admin, an action now runs
on the selected rows thatget_queryset(request)holds (other selected keys
are ignored), and the change and delete views load their object with the
newDjustModelAdmin.get_object(request, object_id), which looks it up in
that queryset; a row it leaves out, or a key that names no row, is a 404. -
A live view whose
mount()raises is no longer left mounted. An
exception frommount(),handle_params()or the first render used to
answer with an error frame and keep the view as the connection's mounted
view, so later events still ran its handlers, and it stayed in its channel
groups with its presence tracked. Such a mount now
ends like a refused one on WebSocket and SSE: aPermissionDeniedis
answered as the authorization check answers it (Permission denied; the
socket closes with 4403 outside amount_batch), any other exception keeps
its error frame, and in both cases the view is dropped, its groups are
left, its presence is untracked and its tick stops. A view whose object
permission is refused aftermount()also untracks its presence. The HTTP
fallback and the HTTP API already refused such a request. -
Live transports enforce or refuse access control that only the HTTP request runs.
The WebSocket and SSE mount, events, the HTTP POST event fallback, the API
and{% live_render %}children never call a view'ssetup(),
dispatch(),get()orpost(), and never run the wrappers the URLconf
puts aroundas_view(). djust replays its own declarations and Django's
LoginRequiredMixin,PermissionRequiredMixinandUserPassesTestMixin
there, but a customAccessMixinsubclass that checks the user in its own
dispatch(), a@method_decorator(...)ondispatch,getorpost, an
overridden method that checks the user, or a URLconf wrapper such as
path("x/", login_required(MyView.as_view()))did not run at all.
check_view_authnow:- replays Django's
login_required,staff_member_required,
user_passes_test(...)andpermission_required(...)applied with
@method_decorator, wrapped aroundas_view()in the URLconf (read from
the server's URLconf, not from the URL the client sends) or by an
as_view()override, and other packages' decorators built on
user_passes_test. The same users are admitted as on the HTTP request;
an authenticated user who fails the test getsPermissionDenied, where
Django's HTTP response would redirect to the login page. Async test
functions are awaited (called directly on the event loop's thread, the
check refuses instead of blocking).login_not_requiredis not access
control. A decorator applied only topostis checked for every event on
the page (the view's, its components', its embedded views' and uploads),
not for the mount; - refuses a view whose custom
AccessMixinsubclass definesdispatch(),
unless it declarespermission_requiredorcheck_permissions()
(login_required = Truealone does not cover it); - refuses a view whose
setup()/dispatch()/get()/post()override, a
decorator on one (also under another decorator stacked above
@method_decorator, or returned by a project decorator that delegates to
it), or a URLconf wrapper checks the user (any attribute of it, such as
is_staff,groupsor a role) and denies by any means (raises, including
PermissionDeniedandHttp404; callshandle_no_permission()or
redirect_to_login(); returns a redirect, an error response or a page of
its own such asrender(...)orHttpResponse(...); calls
get_object_or_404()), unless a declared rule covers it (login_requiredcovers a check that reads only
is_authenticated/is_anonymous); - does not refuse a view for any other decorator or URLconf wrapper that
is neither Django's nor djust's and whose code does not check the user (a
CSP, rate-limit or logging decorator); it does not run on live
transports, anddjust.V013warns about it.
Permitted users of views using Django's
login_required,
staff_member_required,user_passes_testandpermission_required
decorators and wrappers, or its three auth mixins, are unaffected. Views
whose access check lives in their ownsetup(),dispatch(),get(),
post(), decorators or URLconf wrappers are refused for every user until
the rule is declared. The HTTP GET page, which Django's dispatch already
gated, is unchanged. System checkdjust.S012(an Error, somanage.py checkandmigratestop until the view is fixed) names each refused view
defined in the project;djust.V013(a warning) names overrides that only
read the user and unknown decorators and wrappers, which are not refused,
refused views that an installed package defines, and URLconf routes whose
wrapper does not usefunctools.wraps(djust cannot read it).djust.S012no longer reports Django's
own decorators ondispatch(), which are replayed. Onlydjustand
djust.*modules are treated as framework code: apps and packages named
djust_*are inspected, djust-auth'sLoginRequiredLiveViewMixinis read
aslogin_required = True, and djust-tenants'TenantMixinis not
reported.LoginRequiredLiveViewMixinindjust.authnow also sets
login_required = True.
Upgrade note: a view refused this way must declare its rule;
login_required = Falseis not a declaration. - replays Django's
-
MCP tools keep filesystem paths inside the project.
find_handlers_for_templateread any absolute path that existed, so it
confirmed which files existed anywhere on disk and returneddj-*-shaped
strings from them. It now reads an absolute path only when the file resolves
(symlinks and..followed) inside a directory the template loaders serve or
insideBASE_DIR, and answers any other absolute path like a missing
template.seed_fixturescompared paths with a string prefix, so
/app-datapassed for aBASE_DIRof/app, and skipped the check when
BASE_DIRwas unset; it now compares whole path components and refuses to
load anything withoutBASE_DIR. Both use one containment helper. -
The live mount checks object permission after
handle_params(). The
WebSocket and SSE mounts ranhas_object_permission()aftermount()but
beforehandle_params(), so a view that selects its object from the URL's
query parameters inhandle_params()was checked against the object
mount()chose and then rendered the one the parameters selected. The mount
now runsmount(),handle_params(), the object check, and then the
first render: the order the HTTP GET render and
url_changenavigation already used. A refused mount still sends a
permission_deniederror frame and closes the WebSocket with 4403. -
One
@rate_limitbudget per handler function, whatever name reaches it. The shared store keyed buckets on the caller and the name the route used (the client-suppliedevent_nameon WebSocket/SSE, the function name on the HTTP API), so a method reachable under two names (an alias attribute, or a second name bound inmount) had a separate budget per name. The bucket is now keyed on the function that actually runs (__func__of a bound method), accepted only when that function is declared on the owning class (or, for an ADR-031 component, on the class behind the bound proxy); only the exact builtinstaticmethod/classmethodwrappers are unwrapped, and membership is compared withis. A handler whose identity cannot be determined this way (a property or other descriptor, afunctools.partial, a callable instance, a function from an unrelated class) is refused with a named error rather than given a budget of its own, and two handlers that resolve to one key with different limit settings are refused rather than sharing the first one's settings. Functions made by one factory keep separate budgets. Tests:python/djust/tests/test_ratelimit_post_fallback.py. -
@rate_limitnow applies on the page-POST fallback. The per-handler@rate_limitwas checked on WebSocket/SSE and on the HTTP API, but the page-POST fallback (mixins/request.post), which also runs an@event_handlerfrom the request body, did not consult it, so calls through it did not use the shared per-caller budget that F27 documents as "one budget per handler regardless of transport". The fallback now checks the limit at the same point as the WebSocket path (after the decorator and permission checks, before the handler runs) and answers429withcode: "rate_limited"in its existing JSON error shape. Both HTTP transports share one implementation,djust.rate_limit.http_handler_rate_check, and one caller-key helper,caller_key_for_request(still behindresolve_client_ip, soDJUST_TRUSTED_PROXY_COUNTstill applies); the HTTP API's behaviour is unchanged. Tests:python/djust/tests/test_ratelimit_post_fallback.py. -
Backport the theming URL-scheme guards (#3400, #3407, #3420).
Data-derived URLs in navigation, breadcrumbs, pagination, avatars and page
tags are neutralised to"#"forjavascript:,vbscript:anddata:
schemes, including whitespace and control-character obfuscation. Avatar
image sources retaindata:image/*; links do not. Other schemes, including
sms:,geo:and app deep links, remain unchanged. Developer-supplied
buttonhref=kwargs and passthrough URL attributes raiseValueError.
Navigation covers dictionary and object items, iterable collections and
sidebar sections without mutating caller-owned items. Pagination checks
every page, first, last, previous and next URL after formatting.
What's Changed
- fix(theming): neutralise script-bearing data-derived URLs in theming tags (1.2 backport) by @johnrtipton in #3421
- fix(tests): git fixtures must not write into the real .git/config (1.2 backport of #3179) by @johnrtipton in #3423
- chore(release): 1.2.4 by @johnrtipton in #3458
- chore(deps): bump source-map-js to 1.2.2 on 1.2 (#3466) by @johnrtipton in #3471
Full Changelog: v1.2.3...v1.2.4