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.
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, andplugins 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_viewPagePermissionrows (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 optionaluserargument and now writesPagePermissionrows. 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
403when 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 thecan_viewgrants that protect a page, andPage.apply_view_restrictions()recreates such a snapshot on a page at its new location. Code that relocates pages outside ofPage.move_page()orPage.copy_with_descendants()should use them.cms.utils.page_permissionsgaineduser_can_relocate_descendants()with the thin wrappersuser_can_copy_descendants()anduser_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 aparent_pagediffering 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.sourceonly 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 toCreateCMSPageForm, gets a validation error.AddPageFormgainedclean_source(), which requires view permission on the source, and itsfrom_source()now applies the source’s view restrictions to the new page.DuplicatePageForminherits both instead of defining its own. The denial message is set per form through the newsource_permission_deniedclass attribute.Submitting both
cms_page(adding a translation) andsourcetoAddPageFormis rejected: a translation reuses the existing page and would bypass the view restrictions applied infrom_source().CreateCMSPageForm.clean_parent_page()rejects a parent on another site, andWizardStep1Formonly 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_permissionson 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, needscms.change_pageandcms.publish_page.can_viewhas 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.permissionsgainedget_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_regexno 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’sURLValidator, 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 anhref.Two helpers back this up in
cms.forms.validators:validate_path()dispatches on whether the value contains a/, andvalidate_path_segment()checks a single segment against the newpath_segment_regex. Code that writes page paths outside the admin forms should callvalidate_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 BraunSkip 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 StoykovRe-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
CurrentUserMiddlewarein async (ASGI) mode (#8863) – Fabian BraunCheck 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_silentlyargument to Django and suppress only transport errors and missing mailers (#8842) – Fabian BraunFix
GrouperModelAdminsaving to the latest content object instead of the one selected by pk (#8826) – Fabian BraunFix the slug uniqueness check when moving pages ignoring the site and page types (#8831) – Fabian Braun
Fix migration
cms.0045failing on PostgreSQL for databases with duplicate page paths (#8823) – Fabian BraunOpen 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!