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)
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:
- the routable has a Route that passes
RouteVoter, or - the routable is a
Pageand someAbstractPageDatausing it has a granted route (DenyAccessListener::isPageDataAllowedByRoute()), or - the configured
routable_securityexpression 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/parentPageDataareManyToOneonAbstractPage), so finding descendants is a reverse lookup with no inverse side mapped today. Either add inverse collections or query the repositories. - Cost.
ComponentVoteralready 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
ResourceManifestVoterneeds the same treatment for the admin UUID-based manifest.
Acceptance criteria
- With
routable_securityconfigured, an anonymousGETof a routed child page's manifest returns a parent depth whosePageand 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
pathheader 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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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