Skip to content

v1.2.4

Choose a tag to compare

@github-actions github-actions released this 15 Oct 02:58
· 502 commits to main since this release
36c27ac

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_markdown and {% 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 while MAX_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 as djust._rust.MAX_MD_NESTING. Regression cases in python/tests/test_markdown_nesting_bound.py and the markdown unit tests of djust_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 the Terminal component, 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 in python/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 the dj-root element no longer walks the raw tree recursively either. The limit is exported as djust._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 in python/tests/test_vdom_nesting_bound.py and crates/djust_vdom/tests/test_nesting_depth_bound.rs.

Fixed

  • Test fixtures no longer write user.name = Test, user.email and
    commit.gpgsign = false into 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 whole os.environ. When pytest ran with
    GIT_DIR exported, as under a git hook, their git 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, strips GIT_EXECUTION_VARS with an autouse fixture.
    The root conftest.py also strips them for every test, which covers library
    code that runs git in-process. tests/test_git_env_guard_3179.py runs the
    fixed helpers against a throwaway GIT_DIR that 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 style attributes. A
    colour, length, ratio or number that a component takes from its caller and
    writes into an inline style attribute 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 (with transparent and currentcolor), rgb()/rgba()/hsl()/
    hsla() with plain numeric arguments, and var(--name) references; lengths
    accept numbers with a unit, the usual keywords, var(), and calc()/min()/
    max()/clamp(). Valid values render exactly as before. Covers
    CursorsOverlay and CollabSelection (the user colour), CalendarView
    event colours, Meter segment colours, ColorPicker, kanban column colours,
    the data table colour scale, and the heights, widths, gaps, offsets and
    ratios of MapPicker, 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
    and safe_css_number.
  • Text in an SVG or MathML <style> / <script> is serialized as escaped text. The VDOM wrote the text of any element named script, style, noscript, xmp, iframe, noembed, noframes or plaintext verbatim, 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 (&lt; stays &lt;). The serializer now tracks the namespace while it writes (svg and math switch to foreign content; foreignObject, desc, title, MathML text elements and HTML annotation-xml switch 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 legacy document.write pattern) and <plaintext>. An inserted {% if %} block (the HTML of an InsertSubtree patch) 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. The render_binary_diff text-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 &lt; 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 of render_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> inside foreignObject or desc content), 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 a dj-root on an element inside <svg> or <math> (<svg><g dj-root>) writes its <style> / <script> text escaped, as does its dj-update="ignore" cache, and the HTML of an InsertSubtree patch is written for its parent's namespace; the text fast paths run only under an HTML root. Regression cases in python/tests/test_vdom_foreign_raw_text.py, python/tests/test_vdom_html_raw_text_verbatim.py and in crates/djust_vdom/src/lib.rs.
  • A dj-root on an SVG or MathML element keeps its children's namespace through html_update, html_recovery and the morph of a prerendered page on mount. These three paths parsed the root's inner HTML in an HTML context (DOMParser, or innerHTML on 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_update and html_recovery now 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 in tests/js/full_html_frame_namespace.test.js.
  • html_update and html_recovery keep 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 in tests/js/full_html_frame_namespace.test.js.
  • A node inserted inside <svg> or <math> by an InsertChild patch 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 HTML annotation-xml hold 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 in tests/js/insert_subtree_namespace.test.js; tests/playwright/test_insert_subtree_namespace.py covers InsertChild in Chromium too.
  • The children of a placeholder for an SVG or MathML element keep their namespace. An InsertChild patch 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 in tests/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 every InsertSubtree patch 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 MathML mi, mo, mn, ms, mtext or annotation-xml parent is reproduced as well), and as HTML, as before, for an HTML parent and for the HTML integration points (foreignObject, desc, title, and annotation-xml with an HTML encoding). 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 in tests/js/insert_subtree_namespace.test.js, python/tests/test_vdom_foreign_raw_text.py and crates/djust_vdom/src/lib.rs; tests/playwright/test_insert_subtree_namespace.py compares 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. _runInsertedScripts now re-creates only HTML <script> elements. Regression test in tests/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) in MIDDLEWARE, 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 (its login_url and redirect_field_name for
    that route), unless the view is public. A view is public when its
    dispatch carries Django's login_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
    the dispatch marker 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 (an error frame with code: "login_required", or a
    failed[] entry in the mount_batch reply) 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 own login_required = True still sends the page to the login page,
    and a view that relies only on permission_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 the dispatch marker
    or a login_not_required route 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 own login_required = False is 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's content, each dashboard
    panel's content and each audit-log column name (written into the cell's
    class attribute); 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
    MasonryGrid and DashboardGrid docstrings 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_filter accepts the field names in the ModelAdmin's
    list_filter (get_list_filter()), and sort_by accepts the sortable
    list_display columns and the fields named in ordering (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 from get_list_filter() as well. A custom
    change-list template that filters or sorts on other fields must declare them
    in list_filter, list_display or ordering. The auth admin's social
    accounts list sorts only on its own columns.

  • The djust admin loads rows through the ModelAdmin's get_queryset().
    A DjustModelAdmin that scopes rows in get_queryset(request) was honoured
    by the change list only: bulk actions ran on the selected primary keys
    through Model.objects, and the change and delete views loaded their
    object with Model.objects.get(). As in Django's admin, an action now runs
    on the selected rows that get_queryset(request) holds (other selected keys
    are ignored), and the change and delete views load their object with the
    new DjustModelAdmin.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 from mount(), 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: a PermissionDenied is
    answered as the authorization check answers it (Permission denied; the
    socket closes with 4403 outside a mount_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 after mount() 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's setup(),
    dispatch(), get() or post(), and never run the wrappers the URLconf
    puts around as_view(). djust replays its own declarations and Django's
    LoginRequiredMixin, PermissionRequiredMixin and UserPassesTestMixin
    there, but a custom AccessMixin subclass that checks the user in its own
    dispatch(), a @method_decorator(...) on dispatch, get or post, 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_auth now:

    • replays Django's login_required, staff_member_required,
      user_passes_test(...) and permission_required(...) applied with
      @method_decorator, wrapped around as_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 gets PermissionDenied, 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_required is not access
      control. A decorator applied only to post is 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 AccessMixin subclass defines dispatch(),
      unless it declares permission_required or check_permissions()
      (login_required = True alone 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, groups or a role) and denies by any means (raises, including
      PermissionDenied and Http404; calls handle_no_permission() or
      redirect_to_login(); returns a redirect, an error response or a page of
      its own such as render(...) or HttpResponse(...); calls
      get_object_or_404()), unless a declared rule covers it (login_required covers 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, and djust.V013 warns about it.

    Permitted users of views using Django's login_required,
    staff_member_required, user_passes_test and permission_required
    decorators and wrappers, or its three auth mixins, are unaffected. Views
    whose access check lives in their own setup(), 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 check djust.S012 (an Error, so manage.py check and migrate stop 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 use functools.wraps (djust cannot read it). djust.S012 no longer reports Django's
    own decorators on dispatch(), which are replayed. Only djust and
    djust.* modules are treated as framework code: apps and packages named
    djust_* are inspected, djust-auth's LoginRequiredLiveViewMixin is read
    as login_required = True, and djust-tenants' TenantMixin is not
    reported. LoginRequiredLiveViewMixin in djust.auth now also sets
    login_required = True.
    Upgrade note: a view refused this way must declare its rule;
    login_required = False is not a declaration.

  • MCP tools keep filesystem paths inside the project.
    find_handlers_for_template read any absolute path that existed, so it
    confirmed which files existed anywhere on disk and returned dj-*-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
    inside BASE_DIR, and answers any other absolute path like a missing
    template. seed_fixtures compared paths with a string prefix, so
    /app-data passed for a BASE_DIR of /app, and skipped the check when
    BASE_DIR was unset; it now compares whole path components and refuses to
    load anything without BASE_DIR. Both use one containment helper.

  • The live mount checks object permission after handle_params(). The
    WebSocket and SSE mounts ran has_object_permission() after mount() but
    before handle_params(), so a view that selects its object from the URL's
    query parameters in handle_params() was checked against the object
    mount() chose and then rendered the one the parameters selected. The mount
    now runs mount(), handle_params(), the object check, and then the
    first render: the order the HTTP GET render and
    url_change navigation already used. A refused mount still sends a
    permission_denied error frame and closes the WebSocket with 4403.

  • One @rate_limit budget per handler function, whatever name reaches it. The shared store keyed buckets on the caller and the name the route used (the client-supplied event_name on WebSocket/SSE, the function name on the HTTP API), so a method reachable under two names (an alias attribute, or a second name bound in mount) 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 builtin staticmethod/classmethod wrappers are unwrapped, and membership is compared with is. A handler whose identity cannot be determined this way (a property or other descriptor, a functools.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_limit now applies on the page-POST fallback. The per-handler @rate_limit was checked on WebSocket/SSE and on the HTTP API, but the page-POST fallback (mixins/request.post), which also runs an @event_handler from 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 answers 429 with code: "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 behind resolve_client_ip, so DJUST_TRUSTED_PROXY_COUNT still 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 "#" for javascript:, vbscript: and data:
    schemes, including whitespace and control-character obfuscation. Avatar
    image sources retain data:image/*; links do not. Other schemes, including
    sms:, geo: and app deep links, remain unchanged. Developer-supplied
    button href= kwargs and passthrough URL attributes raise ValueError.
    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

Full Changelog: v1.2.3...v1.2.4