5.1.3 release notes

September 23, 2026

Welcome to django CMS 5.1.3!

These release notes cover what has changed since django CMS 5.1.2. This patch release contains seven security fixes: broken access control in the page tree, the add-page form, the page wizard, the move-plugin endpoint and the global page permission admin, and a stored XSS in the page delete confirmation. We therefore recommend that all users of django CMS 5.1.x upgrade as soon as possible. It also repairs migration cms.0045 on PostgreSQL and fixes a regression from django CMS 5.0.0 that hid explicitly declared child plugins in placeholders restricted by CMS_PLACEHOLDER_CONF. Read the security fixes and their backward-incompatible effects before updating an existing project.

Django CMS 5.0.12 contains the same security fixes, except the fix for the stored XSS in the page delete confirmation (GHSA-mgv8-5r6j-2gjq).

If you are upgrading from django CMS 5.0.x or earlier, read the 5.1.0 release notes first — they cover the backward incompatible changes, deprecations, and removals of the 5.1 series.

Django and Python compatibility

django CMS 5.1 supports Django 5.2, 6.0, and 6.1. Django 4.2, 5.0, and 5.1 are no longer supported because those series no longer receive upstream maintenance. We highly recommend and only support the latest release of each supported series.

It supports Python 3.10, 3.11, 3.12, 3.13, and 3.14.

How to upgrade to 5.1.3

Before upgrading, back up your database and review the backward-incompatible changes below.

Upgrade django CMS:

python -m pip install --upgrade "django-cms==5.1.3"

Then collect static files and run the django CMS checks:

python manage.py collectstatic --noinput
python manage.py cms check

This release adds no new migrations. It repairs cms.0045_pageurl_site_unique_path, which failed on PostgreSQL for databases containing duplicate page paths with cannot ALTER TABLE “cms_pageurl” because it has pending trigger events. If that migration failed for you, run it again after upgrading:

python manage.py migrate

Databases that already applied the migration successfully are unaffected.

What’s new in 5.1.3

Security fixes

Except for GHSA-mgv8-5r6j-2gjq, the same fixes are contained in django CMS 5.0.12. This release fixes seven advisories. All of them require an authenticated staff account; none is exploitable anonymously. We recommend that all users upgrade as soon as possible.

Page-tree copy, duplicate and move disclosed view-restricted pages

This release fixes broken access control in the page tree’s Copy, Duplicate and Move operations. All three require CMS_PERMISSION = True and at least one per-page view restriction: an installation that does not use the permission system has nothing to disclose, because Page.has_view_restrictions() is always False there. Each of them needs a staff session that may add pages; none is exploitable anonymously, but the content they expose is readable anonymously afterwards.

GHSA-976q-w6ch-6w82 covers all three, which share a root cause, their prerequisites and their fix:

  • Copy authorized only the root of the subtree it was asked to copy, while every descendant below it was copied and recreated without its view restrictions. A delegated editor who could view one public page therefore obtained a publicly readable copy of every view-restricted page below it. Selecting Copy permissions did not help: the flag was passed to the descendants but not to the copied root, so the root lost its own permission records as well. Copying now authorizes the whole subtree and preserves the view restrictions the source inherited from ancestors outside it.

  • Duplicate created the new page with no permissions at all, so duplicating a view-restricted page republished its content to anonymous visitors. The duplicate now carries the source’s view restrictions, both its own and the ones it inherited.

  • Move dropped the view restrictions that a page and its descendants inherited from the ancestors they left behind, exposing the pages themselves – including descendants the moving user was not allowed to view – to anonymous visitors. Moving now authorizes the whole subtree and materializes the inherited view restrictions on the moved page.

No action beyond upgrading is required. Pages that were copied, duplicated or moved before the upgrade keep the permissions they were created with, so review restricted branches for unrestricted copies made earlier.

Adding a page authorized the parent from the query string

GHSA-g52p-phr3-hvx9: with CMS_PERMISSION = True, the add-page admin checked the add permission against the parent named in the query string (?parent_page=), while the page was created under the parent_page submitted in the form body – which was only checked to belong to the current site. A staff user whose page permissions are confined to one part of the page tree could therefore create pages anywhere in it, including at root level. AddPageForm.clean_parent_page() now re-checks the permission against the parent the page is actually created under, and against the site when no parent is submitted. The flaw allowed page insertion only: no existing page could be read, changed or deleted through it.

The page wizard allowed creating pages below a parent on another site

GHSA-ppqv-h89f-6q9p: in a multi-site installation, the New page and New sub page wizards accepted an origin page from any site. The add permission was then checked against the current site while the new page was attached to the foreign parent, so a staff user with add rights on one site could insert pages into the page tree of a site they have no rights on, leaving the new page with a parent on a different site. The wizard now only resolves origin pages on the current site, and the page creation form rejects a parent that belongs to another site – for superusers and with CMS_PERMISSION = False as well.

Adding a page from a page type disclosed restricted page types

GHSA-7fqg-chmv-c89g (low severity): with CMS_PERMISSION = True, the Page type field of the add-page form narrowed the choices it rendered to the current site’s page types, but validated a submitted value against every page type in the database. A staff user who may add a page could therefore submit the id of a page type on another site, or one protected by a view restriction, and have its placeholders, plugins and extensions copied into a page they own – without the view restrictions of the source.

Page types cannot be created through the interface in django CMS 4 and 5, so only databases that still carry page types migrated from django CMS 3, or created programmatically, are affected. The add-page form, the page wizard and Duplicate now accept only page types of the current site, require view permission on the source, and carry the source’s view restrictions over to the new page.

The move-plugin endpoint disclosed plugin content through plugin_parent

GHSA-79vf-xh44-8cpm: the paste and cut paths of the move-plugin endpoint resolved the client-supplied plugin_parent without scoping it to the target placeholder. Because the endpoint renders its response from the new plugin’s parent, a staff user with change permission on a single placeholder could name any plugin id and have that plugin’s rendered subtree returned – including plugins on pages they have no permission for, on other sites, and in other users’ clipboards. The copy was also created with a parent outside its own placeholder.

The parent is now looked up within the target placeholder and language on every path, including the case where plugin_parent names the plugin’s existing parent. This flaw has been present since django CMS 3.0; unsupported release lines will not receive a patch.

Delegated permission managers could grant themselves page rights

GHSA-cx9c-3cc5-gg94: with CMS_PERMISSION = True, the Page global permissions admin offered every can_* flag regardless of the rights the acting user held. A non-superuser whose only elevated right was can_change_permissions could therefore grant themselves – or anyone else – can_publish, can_delete, can_change_advanced_settings and can_move_page, site-wide. The per-page permission inline had always restricted the offered flags; the global admin had no equivalent check.

The admin now offers only the flags the manager holds, and refuses a grant of a flag they do not hold on every selected site. Grants that carry flags beyond the manager’s rights, or that cover a site where they may not manage permissions, can no longer be changed or deleted by them, so their user, group or sites cannot be used to move those rights elsewhere. See the backward incompatible changes below.

Because GlobalPagePermission rows carry no audit fields, review the existing rows after upgrading and check Django’s admin log for entries created by non-superusers.

Stored XSS in the page delete confirmation escalated editors to administrators

GHSA-mgv8-5r6j-2gjq: a staff user who may change a page could store markup in that page’s url through the Overwrite URL field – a value containing no / bypassed url validation entirely and was copied verbatim to PageUrl.path. The page delete confirmation screen marked the url safe when rendering it, so the stored markup executed in the browser of whoever opened that screen, typically an administrator about to delete the page. The script runs on the admin origin: it cannot read the HttpOnly session cookie, but it can read the CSRF token and issue authenticated requests as its victim, which turns an ordinary content editor into an administrator.

CMS_PERMISSION = True is not a mitigation – it only limits which pages an attacker can poison to the ones they may already edit. A second route stores the payload on the home page, where ChangePageForm.clean() returned early and never validated the value; it is ignored while the page is the home page and becomes the live PageUrl.path as soon as another page takes that role.

Two changes fix this. The confirmation screen no longer marks the page url safe and builds the page entry with format_html(), and a path without a / is now validated rather than skipped – in the new ChangePageForm.clean_overwrite_url(), so that the home page is covered too.

Upgrading is required even where every editor is trusted today: validation does not clean values that are already stored, and paths written through cms.api or a direct model save never pass through the form at all. Only the output encoding protects those rows. Audit the existing data with:

from cms.models import PageUrl
PageUrl.objects.filter(path__regex=r"[<>]")

Placeholder restrictions honour explicitly declared child plugins again

Since django CMS 5.0.0, the plugins list of a placeholder in CMS_PLACEHOLDER_CONF also restricts which plugins can be added as children of other plugins (#6246). This was meant for container plugins that do not restrict their children, which previously offered every registered plugin. It also hid children that a container declared explicitly, so a placeholder allowing only a container plugin could not hold that container’s children, unless they were listed in plugins – which in turn made them available at the placeholder’s root, too (#8432).

Plugins that are explicitly related to an allowed plugin are now available as its children without being listed in plugins:

  • plugins named in its child_classes, and

  • plugins naming it in their parent_classes,

either on the plugin class or in CMS_PLACEHOLDER_CONF. Only literal plugin names count as explicit relations; glob patterns do not. The related plugins do not become available at the placeholder’s root, and excluded_plugins and allowed_slots still apply. Container plugins that do not restrict their children remain limited to the placeholder’s plugins. For example, the following configuration works again as it did before django CMS 5.0:

CMS_PLACEHOLDER_CONF = {
    "content sections": {
        "plugins": ["ContentSectionPlugin"],
        "child_classes": {
            "ContentSectionPlugin": ["LinkPlugin", "PicturePlugin", "TextPlugin"],
        },
    },
}

Configurations that list child plugins in plugins and confine them with parent_classes keep working unchanged.

Backward incompatible changes in 5.1.3

View restrictions follow pages that are copied, duplicated or moved

The security fixes above change how permissions behave around page copies and moves:

  • A page moved out of a restricted branch stays restricted at its new location. The view grants it inherited are written to the moved page as can_view PagePermission rows (no other flag is carried over). To publish such a page, remove the restriction explicitly – which requires the can change permissions right on it.

  • Page.move_page() accepts a new optional user argument and now writes PagePermission rows. Pass the acting user so that the permission caches are invalidated for them:

    page.move_page(target, position, user=request.user)
    
  • The move endpoint answers 403 when the moved subtree contains pages the user may not view and the destination would grant them access, mirroring the behaviour of copy.

  • Two new methods support this: Page.get_view_restrictions() snapshots the can_view grants that protect a page, and Page.apply_view_restrictions() recreates such a snapshot on a page at its new location. Code that relocates pages outside of Page.move_page() or Page.copy_with_descendants() should use them.

  • cms.utils.page_permissions gained user_can_relocate_descendants() with the thin wrappers user_can_copy_descendants() and user_can_move_descendants() for authorizing an operation against a whole subtree rather than its root.

See Permissions for the full picture of how permissions behave when pages are moved, copied or deleted.

Page creation validates the page type and parent more strictly

  • AddPageForm.clean_parent_page() requires add permission on the submitted parent (user_can_add_subpage()), or on the site when no parent is submitted (user_can_add_page()). Code that posts a parent_page differing from the ?parent_page= query parameter, or that instantiates the form for a user without add rights on that parent, gets a validation error. Adding a translation (cms_page) is exempt.

  • AddPageForm.source only accepts page types below the current site’s page-type root. Previously only the rendered choices were narrowed; the field now also narrows its queryset. Code that passes a page type from another site to the form, or to CreateCMSPageForm, gets a validation error.

  • AddPageForm gained clean_source(), which requires view permission on the source, and its from_source() now applies the source’s view restrictions to the new page. DuplicatePageForm inherits both instead of defining its own. The denial message is set per form through the new source_permission_denied class attribute.

  • Submitting both cms_page (adding a translation) and source to AddPageForm is rejected: a translation reuses the existing page and would bypass the view restrictions applied in from_source().

  • CreateCMSPageForm.clean_parent_page() rejects a parent on another site, and WizardStep1Form only accepts pages of the current site. Custom wizards that deliberately created pages across sites no longer work.

Global page permissions are limited to the rights of the manager

The fix for GHSA-cx9c-3cc5-gg94 restricts what a non-superuser can do in the Page global permissions admin. Superusers and installations with CMS_PERMISSION = False are unaffected.

  • A can_* flag can only be granted by a manager who holds it on every site the grant covers. A grant with no sites selected covers all sites and requires an equally unrestricted grant of the flag.

  • The manager must hold can_change_permissions on every site the grant covers. Managing permissions on one site no longer reaches grants for another.

  • A flag counts only together with the Django permissions its page action requires – can_publish, for instance, needs cms.change_page and cms.publish_page. can_view has no Django counterpart.

  • Grants outside those limits are no longer editable or deletable by that manager: the change and delete views answer 403.

  • cms.utils.permissions gained get_grantable_global_permissions(user, site_ids=None), the single source of truth for what a user may hand out. Projects with their own permission admin should use it.

Existing GlobalPagePermission rows are not changed by the upgrade. A row that was created through the old admin keeps its flags; only what managers may grant from now on is restricted.

Restricted placeholders may offer additional child plugins

With the fix for explicitly declared child plugins above, editors may be offered child plugins that were hidden in django CMS 5.0.0 to 5.1.2: plugins declared in an allowed plugin’s child_classes, or declaring it in their parent_classes, even if they are not listed in the placeholder’s plugins. Only what the structure board offers changes; stored content is not affected.

To hide such a child plugin again, either add it to the placeholder’s excluded_plugins, or override the parent’s child_classes for the placeholder in CMS_PLACEHOLDER_CONF.

Page paths reject angle brackets

The input validation for GHSA-mgv8-5r6j-2gjq rejects values that earlier releases accepted:

  • relative_url_regex no longer allows < and > anywhere in the value. It previously excluded them from the leading segment only, so markup passed as soon as one safe character preceded it (foo/a<img src=x onerror=...>).

  • validate_url_uniqueness() now validates single-segment paths as well. Paths without a / used to skip validation entirely.

  • validate_url() rejects < and > outright, before it decides between the relative and the absolute branch. The absolute branch is Django’s URLValidator, whose path pattern accepts both characters, so a value carrying a scheme (https://example.com/<script>…</script>) used to bypass the check above. This also covers the Redirect field, which renders into an href.

  • Two helpers back this up in cms.forms.validators: validate_path() dispatches on whether the value contains a /, and validate_path_segment() checks a single segment against the new path_segment_regex. Code that writes page paths outside the admin forms should call validate_path().

Paths already stored in the database are left untouched; only new input is rejected. They are, however, escaped on output from now on.

The current user is stored behind a wrapper

To fix CurrentUserMiddleware hanging requests under ASGI, the cms.utils.permissions._current_user context variable no longer holds the user itself but a private wrapper. Use the public helpers get_current_user(), set_current_user() and reset_current_user() instead of accessing the context variable directly.

Bug Fixes

  • Reject markup in the Overwrite URL and Redirect fields, and escape page titles and urls on the page deletion confirmation screen (#8888) (#8894) – Fabian Braun

  • Honour explicitly declared child plugins in placeholders restricted by CMS_PLACEHOLDER_CONF (regression from #8215) (#8883) – Fabian Braun

  • Skip page URLs without a path when collecting apphook URL patterns, so that get_app_patterns() no longer raises for unpublished pages with an attached application (#8887) – Venelin Stoykov

  • Re-check the add permission against the parent a page is actually created under (#8822) – Fabian Braun

  • Restrict global page permission grants to the rights the acting manager holds (#8874) – Fabian Braun

  • Scope the move-plugin parent lookup to the target placeholder on the paste and cut paths (#8849) – Fabian Braun

  • Fix a hang of CurrentUserMiddleware in async (ASGI) mode (#8863) – Fabian Braun

  • Check permissions on the page type used as source when adding a page (#8858) – Fabian Braun

  • Preserve view restrictions when copying, duplicating or moving pages in the page tree (#8840) – Fabian Braun

  • Reject origin pages and parents from other sites in the page wizard (#8848) – Fabian Braun

  • Keep CMS mail delivery best-effort: stop forwarding the deprecated fail_silently argument to Django and suppress only transport errors and missing mailers (#8842) – Fabian Braun

  • Fix GrouperModelAdmin saving to the latest content object instead of the one selected by pk (#8826) – Fabian Braun

  • Fix the slug uniqueness check when moving pages ignoring the site and page types (#8831) – Fabian Braun

  • Fix migration cms.0045 failing on PostgreSQL for databases with duplicate page paths (#8823) – Fabian Braun

  • Open inline editing and plugin/placeholder deletion modals as Django admin popups (#8816) – Fabian Braun

Documentation

  • Provide a redirect for the documentation link in the help menu (#8862) – Fabian Braun

Statistics

This release includes 16 changelog entries, created with the help of the following contributors (in alphabetical order):

  • Fabian Braun (15 changelog entries)

  • Venelin Stoykov (1 changelog entry)

With the review help of the following contributors:

  • Vinit Kumar

Thanks to all contributors for their efforts!