GHSA-MQQ5-J7W8-2HGH
Vulnerability from github – Published: 2026-06-19 17:47 – Updated: 2026-06-19 17:47Unauthenticated nested page API leaks restricted & unpublished content
- Location:
app/controllers/alchemy/api/pages_controller.rb:28(Api::PagesController#nested) - Affected version: Alchemy CMS 8.3.0.dev (Rails 8.1.3)
Description
The unauthenticated GET /api/pages/nested endpoint returns the full page tree to any anonymous caller, including restricted (member-only) pages and unpublished/draft pages that should be hidden.
Appending ?elements=true additionally dumps the element/ingredient content of restricted pages, fully bypassing the access control the sibling show and index actions enforce.
Root cause
Api::PagesController#nested calls no authorize! and applies no published/restricted scoping, unlike show (authorize! :show) and index (accessible_by(current_ability, :index)).
PageTreePreloader loads page.self_and_descendants unfiltered, and PageTreeSerializer emits every page's metadata (and, with elements, public_version.elements) with no ability check.
Evidence
An unauthenticated GET /api/pages/nested returns HTTP 200 with the restricted page ("restricted":true) and an unpublished draft ("public":false); ?elements=true leaks its content (e.g. TOPSECRET_RESTRICTED_BODY_proof123).
The same guest hitting GET /api/pages/3 (show) gets HTTP 403 {"error":"Not authorized"}, proving nested returns what show correctly denies.
Reproduction
# 1) Metadata leak (guest, no auth)
curl -s http://localhost:3000/api/pages/nested | python3 -m json.tool | grep -E '"name"|"restricted"|"public"'
# 2) Content leak of restricted page
curl -s "http://localhost:3000/api/pages/nested?elements=true" | grep -oE 'TOPSECRET_RESTRICTED_BODY_[A-Za-z0-9]+|RESTRICTED_RICHTEXT_[A-Za-z0-9]+'
# 3) Contrast — show denies the same guest
curl -s -o /dev/null -w "show /api/pages/3 -> HTTP %{http_code}\n" http://localhost:3000/api/pages/3
Suggested fix
def nested
@page = Page.find_by(id: params[:page_id]) || Language.current_root_page
authorize! :show, @page
preloaded_page = PageTreePreloader.new(page: @page, user: current_alchemy_user, ability: current_ability).call
render json: PageTreeSerializer.new(preloaded_page, ability: current_ability,
user: current_alchemy_user, elements: params[:elements])
end
Additionally scope PageTreePreloader's self_and_descendants via accessible_by(current_ability) and gate element emission in PageTreeSerializer#page_elements behind opts[:ability].can?(:show, page).
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 8.2.5"
},
"package": {
"ecosystem": "RubyGems",
"name": "alchemy_cms"
},
"ranges": [
{
"events": [
{
"introduced": "8.2.0"
},
{
"fixed": "8.2.6"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 8.1.13"
},
"package": {
"ecosystem": "RubyGems",
"name": "alchemy_cms"
},
"ranges": [
{
"events": [
{
"introduced": "8.1.0"
},
{
"fixed": "8.1.14"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 8.0.14"
},
"package": {
"ecosystem": "RubyGems",
"name": "alchemy_cms"
},
"ranges": [
{
"events": [
{
"introduced": "8.0.0.a"
},
{
"fixed": "8.0.15"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 7.4.14"
},
"package": {
"ecosystem": "RubyGems",
"name": "alchemy_cms"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "7.4.15"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-862"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-19T17:47:13Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "# Unauthenticated nested page API leaks restricted \u0026 unpublished content\n\n- **Location:** `app/controllers/alchemy/api/pages_controller.rb:28` (`Api::PagesController#nested`)\n- **Affected version:** Alchemy CMS 8.3.0.dev (Rails 8.1.3)\n\n## Description\n\nThe unauthenticated `GET /api/pages/nested` endpoint returns the full page tree to any anonymous caller, including restricted (member-only) pages and unpublished/draft pages that should be hidden.\nAppending `?elements=true` additionally dumps the element/ingredient **content** of restricted pages, fully bypassing the access control the sibling `show` and `index` actions enforce.\n\n## Root cause\n\n`Api::PagesController#nested` calls no `authorize!` and applies no `published`/`restricted` scoping, unlike `show` (`authorize! :show`) and `index` (`accessible_by(current_ability, :index)`).\n`PageTreePreloader` loads `page.self_and_descendants` unfiltered, and `PageTreeSerializer` emits every page\u0027s metadata (and, with `elements`, `public_version.elements`) with no ability check.\n\n## Evidence\n\nAn unauthenticated `GET /api/pages/nested` returns HTTP 200 with the restricted page (`\"restricted\":true`) and an unpublished draft (`\"public\":false`); `?elements=true` leaks its content (e.g. `TOPSECRET_RESTRICTED_BODY_proof123`).\nThe same guest hitting `GET /api/pages/3` (`show`) gets HTTP **403** `{\"error\":\"Not authorized\"}`, proving `nested` returns what `show` correctly denies.\n\n### Reproduction\n\n```bash\n# 1) Metadata leak (guest, no auth)\ncurl -s http://localhost:3000/api/pages/nested | python3 -m json.tool | grep -E \u0027\"name\"|\"restricted\"|\"public\"\u0027\n\n# 2) Content leak of restricted page\ncurl -s \"http://localhost:3000/api/pages/nested?elements=true\" | grep -oE \u0027TOPSECRET_RESTRICTED_BODY_[A-Za-z0-9]+|RESTRICTED_RICHTEXT_[A-Za-z0-9]+\u0027\n\n# 3) Contrast \u2014 show denies the same guest\ncurl -s -o /dev/null -w \"show /api/pages/3 -\u003e HTTP %{http_code}\\n\" http://localhost:3000/api/pages/3\n```\n\n### Suggested fix\n\n```ruby\ndef nested\n @page = Page.find_by(id: params[:page_id]) || Language.current_root_page\n authorize! :show, @page\n preloaded_page = PageTreePreloader.new(page: @page, user: current_alchemy_user, ability: current_ability).call\n render json: PageTreeSerializer.new(preloaded_page, ability: current_ability,\n user: current_alchemy_user, elements: params[:elements])\nend\n```\n\nAdditionally scope `PageTreePreloader`\u0027s `self_and_descendants` via `accessible_by(current_ability)` and gate element emission in `PageTreeSerializer#page_elements` behind `opts[:ability].can?(:show, page)`.",
"id": "GHSA-mqq5-j7w8-2hgh",
"modified": "2026-06-19T17:47:13Z",
"published": "2026-06-19T17:47:13Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/AlchemyCMS/alchemy_cms/security/advisories/GHSA-mqq5-j7w8-2hgh"
},
{
"type": "PACKAGE",
"url": "https://github.com/AlchemyCMS/alchemy_cms"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "AlchemyCMS: Unauthenticated nested page API leaks restricted \u0026 unpublished content"
}
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.