See how django cms compares to other vendors in security performance
Summary The structure-board endpoint (renderobjectstructure) renders a page's plugin structure without verifying that the requesting user is allowed to view the page. The edit and preview endpoints enforce this via renderpage(), but the structure endpoint does not, allowing a low-privileged staff user to read the plugin structure of a view-restricted page.
Details renderobjectstructure (in cms/views.py) loads the PageContent object and renders cms/toolbar/structure.html directly. Unlike renderobjectendpoint (used by edit/preview), which renders through renderpagecontent → renderpage and calls usercanviewpage(request.user, page) (returning 404 when the user may not view the page), the structure endpoint performs no page-level authorization.
The rendered structure board includes each plugin's getshortdescription() (e.g. link names/URLs, text snippets), so the content of a restricted page is disclosed, not just its shape.
Impact A staff user (any account with isstaff=True) who lacks view permission on a view-restricted page can retrieve that page's plugin structure and short descriptions by requesting the structure endpoint with the page's content-type id and object id.
This only applies when CMSPERMISSION=True and the page has view restrictions (or CMSPUBLICFOR='staff'). Sites without per-page view restrictions are not affected.
Patches Fixed in 5.0.8: the structure endpoint now enforces usercanviewpage() for PageContent objects, matching edit/preview.
Workarounds None other than restricting staff access. Upgrade is recommended.
Credits Reported by the security team at the University of Sydney ([@reporter]).
django CMS is a content management system powered by Django. Prior to 5.0.8 and in 5.1.0a1, the django CMS page cache in cms/cache/page.py ignores request headers declared by plugins through getvarycacheon(). The pagecachekey function includes the cache prefix, site, language, path, and timezone but not the declared header values. Although setpagecache adds those names to the response Vary header, getpagecache retrieves the first stored variant under the same header-agnostic key. When CMSPAGECACHE is enabled and a plugin varies content on a header such as Country-Code, one visitor can receive another visitor’s request-specific content, and an unauthenticated attacker can prime the cache with attacker-chosen content. This issue is fixed in versions 5.0.8 and 5.1.0.
Summary
When plugin rendering fails in edit mode, django CMS renders a cms-rendering-exception block so editors can see that a placeholder could not be rendered. Older code built that block's heading by interpolating the exception message, placeholder/source strings, and the failing plugin's short description directly into an HTML string, then returned the placeholder output as safe markup.
If an editor could store HTML in data used by a plugin's getshortdescription() (or in other values interpolated into the exception message), and that plugin later raised during edit-mode rendering, the payload was parsed as HTML in the staff user's browser. This is a stored XSS condition in the CMS editing context.
Impact
The vulnerable path is only reached when placeholder rendering catches a plugin rendering exception:
python try: placeholdercontent = "".join(plugincontent) except Exception as e: context["excinfo"] = sys.excinfo() placeholdercontent = self.renderexception("rendering placeholder", context, placeholder, editable)
renderexception() constructs a message from values that can include stored content:
- value - the exception message. - placeholder - the placeholder string representation. - placeholder.source - the source object string representation, such as page content. - instance.getshortdescription() - plugin-provided summary text, often derived from plugin model fields.
In the vulnerable implementation, that message was embedded directly into an HTML heading. The final placeholder content was later returned through marksafe, so Django template autoescaping did not protect the heading.
settings.DEBUG does not mitigate the issue: it only controls whether Django's traceback HTML is appended. The custom heading is rendered in edit mode regardless of DEBUG.
Patch
Escape the custom exception heading before returning it as safe placeholder markup. The current fixed code uses formathtml, which escapes message before inserting it into the heading:
python heading = formathtml('<h2 class="cms-rendering-exception-title">{}</h2>', message)
The traceback HTML from ExceptionReporter.gettracebackhtml() should remain separate from django CMS's custom heading; Django's traceback escaping does not protect additional HTML assembled by django CMS.
Workarounds
Until patched, reduce exposure by ensuring only fully trusted staff can edit plugins whose stored fields are included in getshortdescription(), and fix or disable plugins that can be made to raise during edit-mode rendering. This is only a partial mitigation because the escaping bug is in the shared exception-rendering path.
References
- cms/pluginrendering.py - ContentRenderer.renderplaceholder - cms/pluginrendering.py - ContentRenderer.renderexception - Fixed code: heading = formathtml('<h2 class="cms-rendering-exception-title">{}</h2>', message) - Regression tests: cms.tests.testpluginrenderers.TestExceptionCatchers.testexceptioninpluginrenderescapesusercontentineditmode, cms.tests.testpluginrenderers.TestLegacyRendererExceptionCatcher.testexceptioninpluginrenderescapesusercontentineditmode
Summary The clipboard copy paths of the copyplugins admin endpoint validate only the target (the user's own clipboard) and skip source-side authorization. A staff user can copy plugins out of a placeholder they have no permission on into their clipboard, then read the (secret) content.
### Details In cms/admin/placeholderadmin.py, copyplugintoclipboard and copyplaceholdertoclipboard check hascopypluginspermission, which only evaluates request.toolbar.clipboard.hasaddpluginspermission(...) — the clipboard belongs to the requesting user, and checksource is likewise applied only to the clipboard. The source placeholder identified by the attacker-supplied sourceplaceholderid / sourcepluginid is never authorization-checked. (The placeholder-to-placeholder copy path, hascopyfromplaceholderpermission, correctly checks both sides.)
### Impact A staff user holding the global add permission for a plugin type, but with no access to a given placeholder/page, can copy that placeholder's plugins into their own clipboard and read content (e.g. link names/URLs, text) they cannot reach through the normal edit endpoints.
Requires CMSPERMISSION=True with per-placeholder/page restrictions.
### Patches Fixed in 5.0.8: the clipboard copy paths now also verify source-side permission (hasaddpluginspermission + checksource on the source placeholder), matching placeholder-to-placeholder copy.
### Workarounds None. Upgrade is recommended.
### Credits Reported by the security team at the University of Sydney ([@reporter]).
django CMS is an easy-to-use and developer-friendly enterprise content management system powered by Django. Prior to 5.0.8, the moveplugin endpoint in cms/admin/placeholderadmin.py accepts an attacker-controlled pluginparent value without rejecting a plugin’s own identifier or a descendant identifier. A staff user with plugin-change permission under CMSPERMISSION can create a parentid cycle in the plugin tree. The getdescendantscte and getancestorscte queries in cms/models/pluginmodel.py have no cycle guard, so getdescendants() and later rendering, copy, or delete operations can recurse indefinitely or reach a database recursion limit, corrupting the tree and consuming request workers. This issue is fixed in versions 5.0.8.
Summary
The django-cms frontend-editing structure endpoint
GET /<lang>/admin/cms/placeholder/object/<contenttypeid>/structure/<objectid>/
did not perform an object-level authorization check for non-PageContent objects. Any authenticated, active staff user could request the structure endpoint for a frontend-editable object (a model using PlaceholderRelationField) and read its placeholder/plugin structure, even without permission to change that object and without the cms.usestructure permission that the toolbar UI requires before offering structure mode.
PageContent objects were already protected (a page-view check added in GHSA/PR #8644); this advisory covers the remaining non-PageContent branch of the same view.
Severity
The issue is staff-gated and read-only, disclosing CMS structure metadata (placeholder slot names, plugin tree, plugin identifiers/labels, object existence) rather than write access or arbitrary field data.
Affected versions
- django-cms >= 4.0.0, <= 5.0.x and 5.1.0a1 (the vulnerable non-PageContent branch was introduced with the frontend-editing endpoints in 4.0)
Patched versions
- django-cms TODO: 5.0.9
Preconditions
- An authenticated, active staff account (isstaff=True). - The deployment exposes a non-PageContent model with django-cms placeholders / frontend editing (e.g. via PlaceholderRelationField). - The attacker can guess or enumerate the target contenttypeid and object id. - The attacker needs no model/object change permission and no cms.usestructure permission.
Impact
A low-privileged staff user can read the editorial placeholder/plugin structure of non-PageContent objects they are not authorized to edit through the toolbar. Depending on the installed plugins and templates this may reveal placeholder names, plugin layout, plugin identifiers and the existence of objects owned by other staff users or teams. This is most relevant for deployments using third-party or custom django-cms apps that expose frontend-editable objects outside the page tree.
Proof of concept
Using django-cms' own test model placeholderrelationfieldapp.FancyPoll (a non-PageContent model with a PlaceholderRelationField):
python target = FancyPoll.objects.create(name="private-fancy-poll") placeholder = rescanplaceholdersforobj(target)["content"] attacker = self.createuser("lowstaff", isstaff=True, issuperuser=False) attacker has neither changefancypoll nor cms.usestructure
with self.loginusercontext(attacker): response = self.client.get(getobjectstructureurl(target, language="en"))
Before fix: HTTP 200, body contains '"placeholderid": "<pk>"' After fix: HTTP 404, structure not disclosed
Patch
renderobjectstructure now authorizes the non-PageContent branch, mirroring Placeholder.haschangepermission at the object level (honouring a custom hasplaceholderchangepermission hook, otherwise falling back to the model/object change permission) and returning 404 when the user is not authorized:
python else: contenttypeobj = contenttype.getobjectforthistype(pk=objectid) if not canchangeplaceholderobject(request.user, contenttypeobj): raise Http404
Workarounds
No configuration workaround. Deployments that do not register any non-PageContent frontend-editable model are not affected. Otherwise, upgrade to a patched release.
Credit
Reported by doanmanhducz.
Impact
The only authorization gate on the duplicate flow is PageAdmin.hasaddpermission, which checks usercanaddpage(user, site) / usercanaddsubpage(...) — i.e. “may this user create a page at all”. Nothing checks the user’s relationship to the page being copied:
- cms/admin/forms.py — DuplicatePageForm.source = ModelChoiceField(queryset=Page.objects.all(), widget=HiddenInput()) spans every page in the database, on every site. - cms/admin/forms.py — AddPageForm.init returns early when the source widget is hidden, so the queryset is never narrowed to the user’s site/subtree. - cms/admin/forms.py — AddPageForm.clean() validates only URL uniqueness; source is never validated against the user. - cms/admin/pageadmin.py — duplicate() seeds source from the URL only on GET; on POST the value comes entirely from the request body. - cms/admin/forms.py — AddPageForm.save() → fromsource() performs source.copy(..., permissions=False) and copies every placeholder and all plugins of source into a new page on the attacker’s site. Because permissions=False drops the source’s view restrictions, the resulting copy is fully readable by the attacker.
This crosses a real privilege boundary: a staff user restricted (via CMSPERMISSION) to their own site or subtree can exfiltrate the content of restricted pages and of pages belonging to other tenants.
Read-back is trivial (verified): the copy is created on the attacker’s site and, because copy(..., permissions=False) strips the source’s view restrictions, the new page is unrestricted. usercanviewpage() then returns True for it (unrestricted + PUBLICFOR), so the attacker — or even an anonymous visitor — can read the duplicated content directly from the front end. No further permission on the new page is required.
Proof of concept
1. Log in as a staff user attacker who has add page permission but no view/change permission on a target (secret / other-site) page SECRETID. 2. Send (the URL <id> only needs to be a PageContent the attacker can already see — e.g. one of their own pages; the victim id goes in the POST body):
http POST /admin/cms/pagecontent/<MYOWNPAGECONTENTID>/duplicate/ HTTP/1.1 Cookie: sessionid=<attacker session> Content-Type: application/x-www-form-urlencoded
csrfmiddlewaretoken=...&title=x&slug=x&language=en&source=<SECRETID>
3. A new, unrestricted page is created under the attacker’s site containing a verbatim copy of the secret page’s plugins, which the attacker can now preview/edit/read.
Patches
Enforce an object-level permission check on source:
python class DuplicatePageForm(AddPageForm): source = forms.ModelChoiceField( queryset=Page.objects.all(), required=True, widget=forms.HiddenInput(), )
def cleansource(self): source = self.cleaneddata.get("source") if source and not usercanviewpage(self.user, source): raise ValidationError(("You do not have permission to copy this page.")) return source
(usercanviewpage is imported from cms.utils.pagepermissions.)
Workarounds
Until patched, restrict access to the cms.addpage permission to fully-trusted staff, or disable the duplicate action for delegated/limited editors.
References
- cms/admin/pageadmin.py — duplicate(), hasaddpermission(), geturls() - cms/admin/forms.py — DuplicatePageForm, AddPageForm.init/clean/save/fromsource - Regression tests: cms/tests/testforms.py::DuplicatePageFormSecurityTestCase