Close the page-existence oracle on Subject write endpoints#1127
Open
alistair3149 wants to merge 3 commits into
Open
Close the page-existence oracle on Subject write endpoints#1127alistair3149 wants to merge 3 commits into
alistair3149 wants to merge 3 commits into
Conversation
The Create, SetMainSubject and SetSubjectsOrdering endpoints are keyed by dense integer page ids and answered 403 for a page the caller could not edit but 201/200 (a no-op) for a nonexistent page id, so a caller holding the wiki-global edit right could sweep ids to tell restricted pages apart from absent ones - the write-side mirror of the read oracle #1046 closed. Each endpoint now gates on PageReadAuthorizer::authorizeReadByPageId before the write check and before any no-op short-circuit: a page the caller may not read and a page that does not exist both answer the same 404 (presentPageNotFound), byte-identical, so a hidden page cannot be told apart from an absent one. A readable-but-not-editable page still answers 403, because its existence is already public through the read endpoints. Part of #1061. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SubjectRepository::savePageSubjects returned void and MediaWikiSubject- Repository dropped the PageContentSaver status on the floor (the "// TODO: expose failure information"), so a save that failed - most notably a page that no longer resolves - was reported to the caller as a successful create or change. savePageSubjects now returns the PageContentSavingStatus, and the Create, SetMainSubject and SetSubjectsOrdering actions turn an ERROR into the same not-found response the read gate produces. With that gate in front this is a backstop for the page being deleted between the gate and the save; on its own it stops the write path from claiming success for a write that never landed. Part of #1061. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The REST API permissions section still said Subject writes answer 403 on denial. The page-keyed create, main-subject and ordering endpoints now answer 404 for a page you may not read or that does not exist, so page ids cannot be swept, and 403 only for a page you may read but not edit. Part of #1061. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
alistair3149
marked this pull request as ready for review
July 21, 2026 22:05
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1061
The four page-id-keyed Subject write endpoints —
CreateSubject(main and child),SetMainSubject,SetSubjectsOrdering, all keyed by a dense integer MediaWiki page id — were a page-existence oracle. A caller holding the wiki-globaleditright (an ordinary editor) got a403for a page they could not edit but a201/200no-op for a nonexistent page id, so sweeping page ids distinguished "restricted page exists here" from "no page here". This is the write-side mirror of the read-side oracle #1058 (#1046) closed, and itsPOSTfinal commit noted these writes were "not yet enumeration-hardened".The fix
Each of the four endpoints now gates on
PageReadAuthorizer::authorizeReadByPageId()— the same binding per-pagereadcheck #1058 built — before the write authorization and before any no-op short-circuit. That authorizer already returns false for both a nonexistent page and a page the caller may not read.edit)201/200201/200(unchanged)403403(unchanged — existence already public via GET)$wgWhitelistRead)403404201/200no-op404A page you cannot read and a page that does not exist now return a byte-identical
404({"status":"error","message":"Page not found"}), so page ids can no longer be swept to reveal restricted pages. A readable-but-not-editable page still returns403, because its existence is already public through the read endpoints. The gate runs before theSetMainSubject/SetSubjectsOrderingno-op paths, which previously answered200 unchangedfor a nonexistent page.Backstop: surface swallowed save failures
SubjectRepository::savePageSubjectsreturnedvoidandMediaWikiSubjectRepositorydropped thePageContentSaverstatus on the floor (the// TODO: expose failure information), so a save that failed — most notably a page that no longer resolves — was reported to the caller as a successful create/change.savePageSubjectsnow returns thePageContentSavingStatus, and the three actions turn anERRORinto the same404. With the read gate in front this is a belt-and-suspenders for the page being deleted between the gate and the save; on its own it stops the write path from claiming success for a write that never landed.updateSubject/deleteSubjectkeep theirvoidcontract.Scope
In: the four page-id-keyed writes. Out, deliberately:
PUT/DELETE /subject/{id}(Replace/Delete) are subject-id-keyed, andSpecial:NeoJsonis title-keyed — page existence by title is already public, so neither is the dense-integer-id oracle this fixes.Considered, omitted
The backstop maps any post-gate
PageContentSavingStatus::ERRORto the same404, discardingerrorMessage— so a rare non-page-gone save failure (anEditFilterMergedContentabort, an edit conflict) on an existing editable page would also read as "Page not found". This is a strict improvement over the prior behaviour (a fabricated201/200that claimed a write which never landed), maps uniformly regardless of page existence (no oracle), and is narrowly reachable on these slot-write paths. Distinguishing failure kinds needs a typed reason out ofPageContentSaver, which is beyond this issue.Verification
make cs(phpcs + phpstan) clean. The gate is pinned by action-unit tests proven load-bearing by mutation (neutralising the gate creates the Subject and returns the wrong403), and by API integration tests asserting byte-identity between the unreadable-page and nonexistent-page404s on all three endpoints, plus a readable-but-not-editable403(mock grantsread, denies pageedit). The backstop is pinned by an action test that fails the save (InMemorySubjectRepository::failNextSave) and asserts not-found, not created/changed.Verified live on the dev wiki (per-page
readdenied on page 30 via agetUserPermissionsErrorshook):POST /page/30/mainSubject(read-restricted)404{"status":"error","message":"Page not found"}POST /page/999999/mainSubject(nonexistent)404POST /page/30/childSubjects,PUT /page/30/mainSubject,PUT /page/30/subjectsOrdering404POST /page/39/childSubjectsas an anon (readable, not editable)403POST /page/39/childSubjectsas an editor201 createdManual check
A sitewide read lockdown cannot exercise these gates — MediaWiki's REST framework rejects all routes on the wiki-global
readright first. Use a targeted per-page denial. Append toDocker/LocalSettings.local.php(edit in place withcat >>, replacing it breaks the bind mount):As a logged-in ordinary editor (demo data: page 30 = ACME Amsterdam HQ), with a CSRF token:
POST <wiki>/w/rest.php/neowiki/v0/page/30/mainSubject(a valid Subject body) →404{"status":"error","message":"Page not found"}POST .../page/999999/mainSubject(nonexistent id) → identical404POST .../page/30/childSubjects,PUT .../page/30/mainSubject,PUT .../page/30/subjectsOrdering→ same404403; any page you can edit →201/200.Remove the hook → full behaviour returns.
🤖 Generated with Claude Code