GHSA-6X92-6VX4-5FWR

Vulnerability from github – Published: 2026-08-20 18:42 – Updated: 2026-08-20 18:42
VLAI
Summary
django CMS: Broken access control in page *Duplicate* allows reading the content of any page (cross-site / restriction bypass)
Details

Impact

The only authorization gate on the duplicate flow is PageAdmin.has_add_permission, which checks user_can_add_page(user, site) / user_can_add_subpage(...) — 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() → from_source() 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 CMS_PERMISSION) 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. user_can_view_page() then returns True for it (unrestricted + PUBLIC_FOR), 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 SECRET_ID.
  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):
POST /admin/cms/pagecontent/<MY_OWN_PAGECONTENT_ID>/duplicate/ HTTP/1.1
Cookie: sessionid=<attacker session>
Content-Type: application/x-www-form-urlencoded

csrfmiddlewaretoken=...&title=x&slug=x&language=en&source=<SECRET_ID>
  1. 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:

class DuplicatePageForm(AddPageForm):
    source = forms.ModelChoiceField(
        queryset=Page.objects.all(),
        required=True,
        widget=forms.HiddenInput(),
    )

    def clean_source(self):
        source = self.cleaned_data.get("source")
        if source and not user_can_view_page(self._user, source):
            raise ValidationError(_("You do not have permission to copy this page."))
        return source

(user_can_view_page is imported from cms.utils.page_permissions.)

Workarounds

Until patched, restrict access to the cms.add_page permission to fully-trusted staff, or disable the duplicate action for delegated/limited editors.

References

  • cms/admin/pageadmin.py — duplicate(), has_add_permission(), get_urls()
  • cms/admin/forms.py — DuplicatePageForm, AddPageForm.__init__/clean/save/from_source
  • Regression tests: cms/tests/test_forms.py::DuplicatePageFormSecurityTestCase
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 5.0.8"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "django-cms"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "5.0.9"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-63003"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-639",
      "CWE-862"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-08-20T18:42:44Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Impact\n\nThe only authorization gate on the duplicate flow is `PageAdmin.has_add_permission`,\nwhich checks `user_can_add_page(user, site)` / `user_can_add_subpage(...)` \u2014 i.e. *\u201cmay\nthis user create a page at all\u201d*. Nothing checks the user\u2019s relationship to the page being\ncopied:\n\n- `cms/admin/forms.py` \u2014 `DuplicatePageForm.source = ModelChoiceField(queryset=Page.objects.all(), widget=HiddenInput())`\n  spans **every page in the database, on every site**.\n- `cms/admin/forms.py` \u2014 `AddPageForm.__init__` returns early when the `source` widget is\n  hidden, so the queryset is **never narrowed** to the user\u2019s site/subtree.\n- `cms/admin/forms.py` \u2014 `AddPageForm.clean()` validates only URL uniqueness; `source` is\n  never validated against the user.\n- `cms/admin/pageadmin.py` \u2014 `duplicate()` seeds `source` from the URL **only on GET**; on\n  POST the value comes entirely from the request body.\n- `cms/admin/forms.py` \u2014 `AddPageForm.save()` \u2192 `from_source()` performs\n  `source.copy(..., permissions=False)` and copies **every placeholder and all plugins** of\n  `source` into a new page on the attacker\u2019s site. Because `permissions=False` drops the\n  source\u2019s view restrictions, the resulting copy is fully readable by the attacker.\n\nThis crosses a real privilege boundary: a staff user restricted (via `CMS_PERMISSION`) to\ntheir own site or subtree can exfiltrate the content of restricted pages and of pages\nbelonging to other tenants.\n\n**Read-back is trivial (verified):** the copy is created on the attacker\u2019s site and, because\n`copy(..., permissions=False)` strips the source\u2019s view restrictions, the new page is\n*unrestricted*. `user_can_view_page()` then returns `True` for it (unrestricted +\n`PUBLIC_FOR`), so the attacker \u2014 or even an anonymous visitor \u2014 can read the duplicated\ncontent directly from the front end. No further permission on the new page is required.\n\n## Proof of concept\n\n1. Log in as a staff user `attacker` who has *add page* permission but **no** view/change\n   permission on a target (secret / other-site) page `SECRET_ID`.\n2. Send (the URL `\u003cid\u003e` only needs to be a `PageContent` the attacker can already see \u2014\n   e.g. one of their own pages; the victim id goes in the POST body):\n\n```http\nPOST /admin/cms/pagecontent/\u003cMY_OWN_PAGECONTENT_ID\u003e/duplicate/ HTTP/1.1\nCookie: sessionid=\u003cattacker session\u003e\nContent-Type: application/x-www-form-urlencoded\n\ncsrfmiddlewaretoken=...\u0026title=x\u0026slug=x\u0026language=en\u0026source=\u003cSECRET_ID\u003e\n```\n\n3. A new, unrestricted page is created under the attacker\u2019s site containing a verbatim\n   copy of the secret page\u2019s plugins, which the attacker can now preview/edit/read.\n\n## Patches \n\nEnforce an object-level permission check on `source`:\n\n```python\nclass DuplicatePageForm(AddPageForm):\n    source = forms.ModelChoiceField(\n        queryset=Page.objects.all(),\n        required=True,\n        widget=forms.HiddenInput(),\n    )\n\n    def clean_source(self):\n        source = self.cleaned_data.get(\"source\")\n        if source and not user_can_view_page(self._user, source):\n            raise ValidationError(_(\"You do not have permission to copy this page.\"))\n        return source\n```\n\n(`user_can_view_page` is imported from `cms.utils.page_permissions`.)\n\n## Workarounds\n\nUntil patched, restrict access to the `cms.add_page` permission to fully-trusted staff, or\ndisable the duplicate action for delegated/limited editors.\n\n## References\n\n- `cms/admin/pageadmin.py` \u2014 `duplicate()`, `has_add_permission()`, `get_urls()`\n- `cms/admin/forms.py` \u2014 `DuplicatePageForm`, `AddPageForm.__init__/clean/save/from_source`\n- Regression tests: `cms/tests/test_forms.py::DuplicatePageFormSecurityTestCase`",
  "id": "GHSA-6x92-6vx4-5fwr",
  "modified": "2026-08-20T18:42:44Z",
  "published": "2026-08-20T18:42:44Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/django-cms/django-cms/security/advisories/GHSA-6x92-6vx4-5fwr"
    },
    {
      "type": "WEB",
      "url": "https://github.com/django-cms/django-cms/pull/8713"
    },
    {
      "type": "WEB",
      "url": "https://github.com/django-cms/django-cms/commit/3e1ccf7573eb1a74ebbbfaaa812c1f5cadf14e6c"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/django-cms/django-cms"
    },
    {
      "type": "WEB",
      "url": "https://github.com/django-cms/django-cms/releases/tag/5.0.9"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "django CMS: Broken access control in page *Duplicate* allows reading the content of any page (cross-site / restriction bypass)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…