GHSA-6x92-6vx4-5fwr: CSRF
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
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/django-cmsto a version that resolves this vulnerability.Fixed in 5.0.9 - Configuration
In DuplicatePageForm (and/or the duplicate handler), add an object-level authorization gate on the POSTed `source` so that the duplication/copy is rejected unless `user_can_view_page(requesting_user, source)` is true. Concretely, implement logic like: if source and not user_can_view_page(self._user, source): raise ValidationError("You do not have permission to copy this page.") (instead of validating only URL uniqueness / using permissions=False).
Wagtail/CMS page duplication flow (DuplicatePageForm/AddPageForm/pageadmin.py) duplicate-source authorization (clean_source/has_add_permission) = Enforce object-level permission check on source before allowing duplication/copy (deny when source is not viewable by the POSTing user). - Configuration
Change DuplicatePageForm.source from `forms.ModelChoiceField(queryset=Page.objects.all(), widget=HiddenInput())` to a queryset that is narrowed/filtered for the requesting user (e.g., only pages in the user’s allowed site/subtree for limited editors). This ensures hidden-field tampering cannot cause copying of other-tenant/restricted pages.
Wagtail/CMS duplicate form field (DuplicatePageForm.source) source queryset narrowing = Use a queryset restricted to pages the requesting user is allowed to copy, rather than `Page.objects.all()`. - Compensating control
Until the code fix is deployed, restrict the `cms.add_page` permission to fully trusted staff users only (avoid granting it to delegated/limited editors such as those restricted via `CMS_PERMISSION`/`PUBLIC_FOR`).
Event History
Frequently Asked Questions
Which users can exploit this issue?
Any authenticated user who passes the duplicate flow's add-page permission check can exploit it. That check only verifies that the user may create a page on the target site or as a subpage; it does not verify access to the page selected as the copy source.
What does an attacker need to do to access content from another page or site?
The attacker can submit a duplicate-page POST request with the source field set to an arbitrary page ID. The form accepts pages from every site and does not validate the source against the user, site, or subtree, so the copy operation duplicates all placeholders and plugins into a new page on the attacker's site.
Why can copying a restricted page disclose its contents?
The copy operation is performed with permissions disabled. View restrictions from the original page are therefore not retained on the newly created copy, leaving the copied content accessible without the source page's restrictions.