Skip to content

Gate GET /subject-labels by per-page read permission#1126

Merged
JeroenDeDauw merged 1 commit into
masterfrom
fix/1060-subject-labels-read-gate
Jul 22, 2026
Merged

Gate GET /subject-labels by per-page read permission#1126
JeroenDeDauw merged 1 commit into
masterfrom
fix/1060-subject-labels-read-gate

Conversation

@alistair3149

@alistair3149 alistair3149 commented Jul 21, 2026

Copy link
Copy Markdown
Member

Fixes #1060

What

GET /neowiki/v0/subject-labels (the Subject autocomplete) returned the labels of Subjects on
pages the caller cannot read, and accepted an uncapped limit. This gates it by per-page read
permission and bounds the work, mirroring the read-gate work from #1058 / PR #1058.

Fixes the leak described in #1060.

Changes

  • Read gate. The Cypher now traverses (:Page)-[:HasSubject]->(:Subject) so the owning page id
    reaches PHP, and each row is dropped unless PageReadAuthorizer::authorizeReadByPageId allows it —
    the same authorizeRead('read', $title) binding check the other read endpoints use. Denied
    Subjects are simply absent (never a 403), so restricted pages cannot be enumerated. This
    mirrors DatabaseSchemaNameLookup::filterReadable.
  • limit cap. PARAM_MAX => 50 (and PARAM_MIN => 1), matching GET /layouts. Without it a
    per-result permission check was an unbounded-work vector (?limit=100000 was accepted).
  • Over-fetch, bounded. The query over-fetches (limit × 4), then rows are authorized one at a
    time and collection stops at limit, so read restrictions tend not to shorten the result while
    the expensive per-page check runs only for the rows actually needed, not the whole over-fetched
    window.
  • Wiki scoping on both ends. The traversed page is scoped to the current wiki
    ((:Page { wiki_id })) in addition to the existing subject scope (n.wiki_id). The page scope
    keeps the returned page id resolvable within this wiki (page ids are unique only per wiki, so a
    foreign page id would gate an unrelated local page); the subject scope keeps 5d802e6's ownership
    guarantee. Both are independently mutation-verified below.

Testing

  • Neo4jSubjectLabelLookupTest (14): adds a denial test, an over-fetch/re-limit test, and two
    wiki-scope tests (a foreign subject on a local page; a local subject also referenced by a foreign
    page). The two pre-existing cross-wiki isolation tests were made non-vacuous — their fixtures now
    create a Page + HasSubject edge so exclusion is attributable to the wiki filter, not to a
    missing page.
  • Mutation-verified load-bearing: removing n.wiki_id fails exactly
    testExcludesForeignSubjectsReachableThroughALocalPage; removing the page { wiki_id } scope
    fails exactly testReturnsALocallyOwnedSubjectOnceWhenAForeignPageAlsoReferencesIt.
  • GetSubjectLabelsApiTest (new): ?limit=100000400.
  • NoGraphBackendTest, RestApiDocsCoverageTest, ModuleSpecHandlerNeoWikiTest,
    DocsPathReferencesTest green; make cs (phpcs + phpstan) clean.
  • The frontend caller (RestSubjectLabelSearch.ts) sends no limit, so PARAM_MIN/PARAM_MAX
    cannot break it; no frontend change.

Smoke tested on a live wiki

Verified end-to-end on the dev wiki (/w/rest.php), beyond the automated suite:

  • limit cap: ?limit=100000 and ?limit=51400 ("must be between 1 and 50"); ?limit=50200; ?limit=0400; missing schema400.
  • Read gate: with anonymous read of the Acme Rocket page denied via a getUserPermissionsErrors hook, GET /neowiki/v0/subject-labels?schema=Product&search=a dropped that Subject's label — [Acme Anvil, Acme Rocket, Acme TNT][Acme Anvil, Acme TNT] — while other Subjects and other schemas were unaffected; lifting the restriction restored it. Denied Subjects are absent, identical to a Subject that does not exist.

AI-authored — Claude Code, Opus 4.8 (1M context); asked to implement #1060 by @alistair3149, with mid-run direction to re-review and smoke-test; diff not yet human-reviewed; TDD (tests seen failing before the fix) with both wiki-scope clauses mutation-checked, independently AI-reviewed (a 4-lens adversarial workflow then a fresh read-only code review, findings applied), targeted PHPUnit filters (lookup, API, wiring, doc-coverage) + phpcs + phpstan green locally, live-smoke-tested on the dev wiki (limit cap + read-gate omission), CI pending.

@alistair3149 alistair3149 added security alert('xss'); and removed security alert('xss'); labels Jul 21, 2026
The subject-labels autocomplete endpoint returned the labels of Subjects on
pages the caller cannot read, and accepted an uncapped limit, so a per-result
permission check would have been unbounded work.

- Cap limit at 50 (PARAM_MAX), matching the layouts endpoint.
- Traverse Page-[:HasSubject]->Subject, scoped to the current wiki on both the
  page and the subject, so the page id reaches PHP and resolves within this
  wiki. Drop rows the caller cannot read via PageReadAuthorizer, mirroring
  DatabaseSchemaNameLookup. Denied Subjects are simply absent, never an error,
  so restricted pages cannot be enumerated.
- Over-fetch, then authorize row by row and stop at the limit, so read
  restrictions tend not to shorten the result while the expensive per-page
  check stays bounded to the rows actually needed.

The cross-wiki wiki_id leak and Subject-node identity the issue also raised are
already handled elsewhere (5d802e6 and ADR 22 respectively).

For #1060

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alistair3149
alistair3149 force-pushed the fix/1060-subject-labels-read-gate branch from 9b33399 to b1ca656 Compare July 21, 2026 20:43
@alistair3149
alistair3149 marked this pull request as ready for review July 21, 2026 20:44
@JeroenDeDauw
JeroenDeDauw merged commit 606a960 into master Jul 22, 2026
6 checks passed
@JeroenDeDauw
JeroenDeDauw deleted the fix/1060-subject-labels-read-gate branch July 22, 2026 00:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GET /subject-labels leaks labels of Subjects on unreadable pages

2 participants