components-web-app / components-web-app/api-components-bundle

Nested child page whose parent has no Route: the parent is invisible to the public (voter chain ignores parentPage/parentPageData)

Open
#225 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
PHP
Stars
32
Forks
8
PR merge metrics
No merged PRs in 30d

Description

Scenario

A nested page hierarchy where the parent has no Route:

PageData (parent, dynamic — uses a page template)   ← no Route
  └── Page (child, static)  →  Route /child-path    ← the URL the visitor hits

The child sets parentPageData to the parent. This is a legal hierarchy — AbstractPage::$route is nullable, and nothing requires a parent to be routed.

Rendering /child-path shows the child, but the parent depth's content is missing.

There are two independent mechanisms, and only one of them is ours

A. The parent's Page template is 403 for anonymous users (this issue)

RoutableVoter::voteOnAttribute() grants read_routable when:

  1. the routable has a Route that passes RouteVoter, or
  2. the routable is a Page and some AbstractPageData using it has a granted route (DenyAccessListener::isPageDataAllowedByRoute()), or
  3. the configured routable_security expression passes.

For the parent's page template: it has no route (templates never do), and the only page data using it is the parent — which has no route either, so isPageDataAllowedByRoute() returns null (abstain, no routes to check) and the if ($isGranted) branch does not fire. That falls through to routable_security, which apps set to is_granted('ROLE_ADMIN') (SRNTE does) — 403 for the public, so the parent's template never loads and none of its component groups render.

routable_security defaults to null, in which case RoutableVoter returns true immediately — so this only bites apps that configure it. That is the recommended configuration, so in practice it bites real deployments while being invisible to a default install.

ComponentVoter has the same blind spot in a milder form: getComponentPages() walks positions → groups → layouts/pages and getComponentRoutesFromPages() reads $page->getRoute(), and neither ever consults parentPage / parentPageData. Nothing in the voter chain knows that a routeless page is reachable because a routed child page declares it as its parent.

B. Dynamic (pageDataProperty) positions at the parent depth resolve to nothing — front-end side

ComponentPositionNormalizer::normalizeForPageData() resolves the property against PageDataProvider::getPageData(), which reads the path request header. The front-end can only put a route path in that header, and the routeless parent depth contributes none, so it falls back to the child route — whose getPageData() is null for a static child — and every dynamic position is serialised with no component. This hits admins too, which is why the page can look broken even when logged in.

This half looks fixable without any API change, because PageDataProvider::getPageData() already falls back to iriConverter->getResourceFromIri($path) and accepts an AbstractPageData. So the front-end can send the parent's page data IRI as the path header for that depth. Tracked at components-web-app/cwa-nuxt-module#288.

Worth confirming from this side: that fallback path is currently only reachable by a caller that knows to send an IRI, and it is untested as far as I can see. If we are going to rely on it, it deserves a Behat scenario pinning it, plus a check that the Vary: path cache behaviour is sane when the header holds an IRI rather than a path.

Proposal for A

Teach the reachability checks about the parent chain: a routable is publicly reachable if any descendant page/page-data (anything whose parentPage / parentPageData points at it, transitively) has a route that passes RouteVoter.

Things to work out:

  • Direction of the walk. The relation is stored on the child (parentPage / parentPageData are ManyToOne on AbstractPage), so finding descendants is a reverse lookup with no inverse side mapped today. Either add inverse collections or query the repositories.
  • Cost. ComponentVoter already issues sub-requests per candidate route (isPathReachable()); adding a recursive descendant walk on top could multiply that badly. Probably wants a bounded walk plus caching within the request.
  • Is this even the right rule? An alternative reading: a routeless parent used as a page hierarchy parent is intentionally not directly addressable, and its content is only ever served as part of a routed descendant's manifest. If so, the check could be narrower — grant only when the current request is resolving a descendant's route/manifest — which is cheaper and leaks less. This is the main design decision in this issue.
  • Interaction with the same cycle guard as AbstractPage::validateNoCircularParent() — the walk must not loop.
  • Whether ResourceManifestVoter needs the same treatment for the admin UUID-based manifest.

Acceptance criteria

  • With routable_security configured, an anonymous GET of a routed child page's manifest returns a parent depth whose Page and component groups are readable
  • A routeless page/page-data with no routed descendant stays admin-only (no regression in the protection this provides)
  • Circular/deep hierarchies terminate
  • The number of sub-requests/queries does not grow unreasonably with hierarchy depth
  • Behat covers: routeless parent + routed child (public allowed), routeless page with no routed descendant (public denied), admin unaffected
  • If B is fixed front-end-side, a Behat scenario pins the page-data-IRI path header contract so it cannot regress

Front-end counterpart: components-web-app/cwa-nuxt-module#288

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with RoutableVoter::voteOnAttribute(), DenyAccessListener::isPageDataAllowedByRoute(), and ComponentVoter's route checks to trace how parentPage and parentPageData are currently handled. Resolve whether descendant reachability or request-scoped manifest access is the intended rule, then cover the stated public, denied, cycle/depth, query-cost, and admin manifest cases with Behat scenarios; also inspect ComponentPositionNormalizer and PageDataProvider for the IRI-header contract.

Written by the indexing model from the issue text.

Assessment

Tech stack
php, symfony
Domain
api, backend, security
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.