Skip to content

Commit b1ca656

Browse files
alistair3149claude
andcommitted
Gate GET /subject-labels by per-page read permission
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>
1 parent c0aba23 commit b1ca656

8 files changed

Lines changed: 227 additions & 19 deletions

File tree

docs/api/rest-api.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ Every endpoint is also published as a complete [OpenAPI 3.0 description](#full-s
1515

1616
## Permissions
1717

18-
The Subject, page-subjects, Schema, Layout, RDF export, and entity-dereference read endpoints enforce the caller's
19-
per-page `read` permission; page protection and `$wgNamespaceProtection` do not restrict them, because MediaWiki's
20-
`read` action ignores both. When you may not read a page they respond as if the data were absent — a `null` value, an
21-
empty list, or a `404` — never a `403`. `GET /subject-labels` is the exception: it filters only by wiki and Schema, not
22-
by per-page `read`.
18+
The Subject, page-subjects, subject-labels, Schema, Layout, RDF export, and entity-dereference read endpoints enforce
19+
the caller's per-page `read` permission; page protection and `$wgNamespaceProtection` do not restrict them, because
20+
MediaWiki's `read` action ignores both. When you may not read a page they respond as if the data were absent — a `null`
21+
value, an empty list, or a `404` — never a `403`. `GET /subject-labels` omits the labels of Subjects whose page you
22+
cannot read; because that filter runs per result, it caps `limit` at 50.
2323

2424
Subject write endpoints require per-page `edit` permission and answer `403` on denial.
2525

src/Application/SubjectLabelLookup.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
interface SubjectLabelLookup {
88

99
/**
10+
* @param int $limit Maximum results to return. Callers must cap this to a small value: an
11+
* implementation may over-fetch and run a per-result permission check, so an unbounded limit
12+
* is unbounded work. The REST entry point caps it at 50.
1013
* @return SubjectLabelLookupResult[]
1114
*/
1215
public function getSubjectLabelsMatching( string $search, int $limit, string $schemaName ): array;

src/EntryPoints/REST/GetSubjectLabelsApi.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use ProfessionalWiki\NeoWiki\Application\SubjectLabelLookupResult;
1111
use ProfessionalWiki\NeoWiki\NeoWikiExtension;
1212
use Wikimedia\ParamValidator\ParamValidator;
13+
use Wikimedia\ParamValidator\TypeDef\IntegerDef;
1314

1415
class GetSubjectLabelsApi extends SimpleHandler {
1516

@@ -57,6 +58,8 @@ public function getParamSettings(): array {
5758
ParamValidator::PARAM_TYPE => 'integer',
5859
ParamValidator::PARAM_REQUIRED => false,
5960
ParamValidator::PARAM_DEFAULT => 10,
61+
IntegerDef::PARAM_MIN => 1,
62+
IntegerDef::PARAM_MAX => 50,
6063
self::PARAM_DESCRIPTION => 'Maximum number of items to return.',
6164
],
6265
];

src/GraphDatabasePlugins/Neo4j/Persistence/Neo4jSubjectLabelLookup.php

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,24 @@
77
use Laudis\Neo4j\Contracts\ClientInterface;
88
use Laudis\Neo4j\Contracts\TransactionInterface;
99
use Laudis\Neo4j\Databags\SummarizedResult;
10+
use ProfessionalWiki\NeoWiki\Application\PageReadAuthorizer;
1011
use ProfessionalWiki\NeoWiki\Application\SubjectLabelLookup;
1112
use ProfessionalWiki\NeoWiki\Application\SubjectLabelLookupResult;
13+
use ProfessionalWiki\NeoWiki\Domain\Page\PageId;
1214

1315
class Neo4jSubjectLabelLookup implements SubjectLabelLookup {
1416

17+
/**
18+
* Rows are read past the requested limit so that dropping Subjects on pages the caller cannot
19+
* read still tends to fill it. A heuristic, not a guarantee: a run of unreadable Subjects longer
20+
* than the extra headroom can still shorten the result.
21+
*/
22+
private const int OVERFETCH_FACTOR = 4;
23+
1524
public function __construct(
1625
private readonly ClientInterface $client,
1726
private readonly string $wikiId,
27+
private readonly PageReadAuthorizer $readAuthorizer,
1828
) {
1929
}
2030

@@ -26,36 +36,63 @@ public function getSubjectLabelsMatching( string $search, int $limit, string $sc
2636
return [];
2737
}
2838

39+
$results = [];
40+
41+
// Authorize rows one at a time and stop once the limit is filled, so when readable rows are
42+
// plentiful the expensive per-page check runs about `limit` times rather than across the
43+
// whole over-fetched window. A mostly-unreadable search still checks the whole window.
44+
foreach ( $this->fetchLabels( $search, $limit * self::OVERFETCH_FACTOR, $schemaName ) as $row ) {
45+
if ( count( $results ) >= $limit ) {
46+
break;
47+
}
48+
49+
if ( $this->readAuthorizer->authorizeReadByPageId( new PageId( $row['pageId'] ) ) ) {
50+
$results[] = new SubjectLabelLookupResult( id: $row['id'], label: $row['name'] );
51+
}
52+
}
53+
54+
return $results;
55+
}
56+
57+
/**
58+
* The Subject is reached through a Page owned by the current wiki, so the caller can check
59+
* per-page read access and the returned page id always resolves within this wiki (page ids
60+
* are unique only per wiki).
61+
*
62+
* @return list<array{id: string, name: string, pageId: int}>
63+
*/
64+
private function fetchLabels( string $search, int $limit, string $schemaName ): array {
2965
return $this->client->readTransaction(
3066
function ( TransactionInterface $transaction ) use ( $search, $limit, $schemaName ): array {
3167
/**
3268
* @var SummarizedResult $result
3369
*/
3470
$result = $transaction->run(
35-
"MATCH (n:Subject)
71+
"MATCH (page:Page { wiki_id: \$wikiId })-[:HasSubject]->(n:Subject)
3672
WHERE toLower(n.name) STARTS WITH toLower(\$search)
3773
AND \$schemaName IN labels(n)
3874
AND n.wiki_id = \$wikiId
39-
RETURN n.id AS id, n.name AS name
75+
RETURN n.id AS id, n.name AS name, page.id AS pageId
4076
ORDER BY n.name
4177
LIMIT \$limit",
4278
[
4379
'search' => $search,
44-
'limit' => (int)$limit,
80+
'limit' => $limit,
4581
'schemaName' => $schemaName,
4682
'wikiId' => $this->wikiId,
4783
]
4884
);
4985

50-
$subjects = [];
86+
$rows = [];
5187
foreach ( $result as $row ) {
52-
$subjects[] = new SubjectLabelLookupResult(
53-
id: $row->get( 'id' ),
54-
label: $row->get( 'name' )
55-
);
88+
$rows[] = [
89+
'id' => $row->get( 'id' ),
90+
'name' => $row->get( 'name' ),
91+
'pageId' => (int)$row->get( 'pageId' ),
92+
];
5693
}
5794

58-
return $subjects;
95+
return $rows;
5996
}
6097
);
6198
}

src/NeoWikiExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,6 +997,7 @@ public function getSubjectLabelLookup(): SubjectLabelLookup {
997997
return new Neo4jSubjectLabelLookup(
998998
client: $this->getReadOnlyNeo4jClient(),
999999
wikiId: $this->config->wikiId,
1000+
readAuthorizer: $this->newPageReadAuthorizer( $this->getRequestAuthority() ),
10001001
);
10011002
}
10021003

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare( strict_types = 1 );
4+
5+
namespace ProfessionalWiki\NeoWiki\Tests\EntryPoints\REST;
6+
7+
use MediaWiki\Rest\HttpException;
8+
use MediaWiki\Rest\RequestData;
9+
use MediaWiki\Tests\Rest\Handler\HandlerTestTrait;
10+
use ProfessionalWiki\NeoWiki\EntryPoints\REST\GetSubjectLabelsApi;
11+
use ProfessionalWiki\NeoWiki\Tests\NeoWikiIntegrationTestCase;
12+
13+
/**
14+
* @covers \ProfessionalWiki\NeoWiki\EntryPoints\REST\GetSubjectLabelsApi
15+
* @group Database
16+
*/
17+
class GetSubjectLabelsApiTest extends NeoWikiIntegrationTestCase {
18+
use HandlerTestTrait;
19+
20+
public function testRejectsLimitAboveMaximum(): void {
21+
// The endpoint runs a per-result read-permission check, so an uncapped limit is an
22+
// unbounded-work vector. The cap is enforced before the handler runs any query (#1060).
23+
$this->expectException( HttpException::class );
24+
$this->expectExceptionCode( 400 );
25+
26+
$this->executeHandler(
27+
new GetSubjectLabelsApi(),
28+
new RequestData( [
29+
'method' => 'GET',
30+
'queryParams' => [ 'schema' => 'Person', 'limit' => '100000' ],
31+
] )
32+
);
33+
}
34+
35+
}

tests/phpunit/GraphDatabasePlugins/Neo4j/Persistence/Neo4jSubjectLabelLookupTest.php

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace ProfessionalWiki\NeoWiki\Tests\GraphDatabasePlugins\Neo4j\Persistence;
66

77
use Laudis\Neo4j\Contracts\ClientInterface;
8+
use ProfessionalWiki\NeoWiki\Application\PageReadAuthorizer;
89
use ProfessionalWiki\NeoWiki\Application\SubjectLabelLookupResult;
910
use ProfessionalWiki\NeoWiki\Domain\GraphDatabase\GraphDatabasePlugin;
1011
use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName;
@@ -19,6 +20,8 @@
1920
use ProfessionalWiki\NeoWiki\Tests\Data\TestSubject;
2021
use ProfessionalWiki\NeoWiki\Tests\NeoWikiIntegrationTestCase;
2122
use ProfessionalWiki\NeoWiki\Tests\TestDoubles\InMemorySchemaLookup;
23+
use ProfessionalWiki\NeoWiki\Tests\TestDoubles\SelectivePageReadAuthorizer;
24+
use ProfessionalWiki\NeoWiki\Tests\TestDoubles\StubPageReadAuthorizer;
2225

2326
/**
2427
* @covers \ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Persistence\Neo4jSubjectLabelLookup
@@ -142,6 +145,77 @@ public function testExcludesSubjectsWithoutWikiId(): void {
142145
);
143146
}
144147

148+
public function testExcludesForeignSubjectsReachableThroughALocalPage(): void {
149+
$this->saveSubjects( new SubjectMap(
150+
TestSubject::build( id: self::SUBJECT_ID_1, label: new SubjectLabel( 'Apple Pie' ) ),
151+
) );
152+
153+
// A cross-wiki-shared Subject id (ADR 22) can leave a local Page holding a Subject stamped
154+
// for another wiki. The Subject filter must still withhold it so its foreign label, and a
155+
// page id that resolves against the wrong wiki, cannot surface.
156+
$this->getClient()->run(
157+
'CREATE (:Page { id: 500, wiki_id: $wikiId })-[:HasSubject { isMain: false }]->'
158+
. '(:Subject:' . Cypher::escape( TestSubject::DEFAULT_SCHEMA_ID )
159+
. ' { id: "sTestSLL4444441", name: "Apple Tart", wiki_id: $otherWikiId })',
160+
[ 'wikiId' => $this->currentWikiId(), 'otherWikiId' => $this->currentWikiId() . '-other' ]
161+
);
162+
163+
$this->assertEquals(
164+
[ new SubjectLabelLookupResult( self::SUBJECT_ID_1, 'Apple Pie' ) ],
165+
$this->getSubjectLabelsMatching( 'Apple' )
166+
);
167+
}
168+
169+
public function testReturnsALocallyOwnedSubjectOnceWhenAForeignPageAlsoReferencesIt(): void {
170+
$this->saveSubjectOnPage( pageId: 7, subjectId: self::SUBJECT_ID_1, label: 'Apple Pie' );
171+
172+
// In a shared graph the same Subject node (ADR 22 keeps it by bare id) can be referenced by
173+
// a foreign wiki's Page too. Only the local owning Page is followed, so the label appears
174+
// once and the page id used for the read check resolves within this wiki, not the foreign
175+
// one whose colliding page id would gate an unrelated local page.
176+
$this->getClient()->run(
177+
'MATCH (subject:Subject { id: $subjectId }) '
178+
. 'CREATE (:Page { id: 7, wiki_id: $otherWikiId })-[:HasSubject { isMain: false }]->(subject)',
179+
[ 'subjectId' => self::SUBJECT_ID_1, 'otherWikiId' => $this->currentWikiId() . '-other' ]
180+
);
181+
182+
$this->assertEquals(
183+
[ new SubjectLabelLookupResult( self::SUBJECT_ID_1, 'Apple Pie' ) ],
184+
$this->getSubjectLabelsMatching( 'Apple' )
185+
);
186+
}
187+
188+
public function testSubjectsOnUnreadablePagesAreOmitted(): void {
189+
$this->saveSubjects( new SubjectMap(
190+
TestSubject::build( id: self::SUBJECT_ID_1, label: new SubjectLabel( 'Apple Pie' ) ),
191+
) );
192+
193+
$this->assertSame(
194+
[],
195+
$this->newLookup( readAuthorizer: new StubPageReadAuthorizer( allowed: false ) )
196+
->getSubjectLabelsMatching( 'Apple', 10, TestSubject::DEFAULT_SCHEMA_ID )
197+
);
198+
}
199+
200+
public function testOverFetchesPastUnreadableRowsToFillTheLimit(): void {
201+
$this->saveSubjectOnPage( pageId: 1, subjectId: 'sTestSLL1111141', label: 'Apple 1' );
202+
$this->saveSubjectOnPage( pageId: 2, subjectId: 'sTestSLL1111142', label: 'Apple 2' );
203+
$this->saveSubjectOnPage( pageId: 3, subjectId: 'sTestSLL1111143', label: 'Apple 3' );
204+
205+
// Page 2 is hidden and sorts between the two readable rows, so a naive "LIMIT 2 then
206+
// filter" would return only Apple 1. Over-fetching then re-limiting must still yield two.
207+
$results = $this->newLookup( readAuthorizer: new SelectivePageReadAuthorizer( deniedPageIds: [ 2 ] ) )
208+
->getSubjectLabelsMatching( 'Apple', 2, TestSubject::DEFAULT_SCHEMA_ID );
209+
210+
$this->assertEquals(
211+
[
212+
new SubjectLabelLookupResult( 'sTestSLL1111141', 'Apple 1' ),
213+
new SubjectLabelLookupResult( 'sTestSLL1111143', 'Apple 3' ),
214+
],
215+
$results
216+
);
217+
}
218+
145219
private function saveSubjects( SubjectMap $subjects ): void {
146220
$this->newProjectionStore()->savePage( TestPage::build(
147221
id: 1,
@@ -150,6 +224,16 @@ private function saveSubjects( SubjectMap $subjects ): void {
150224
) );
151225
}
152226

227+
private function saveSubjectOnPage( int $pageId, string $subjectId, string $label ): void {
228+
$this->newProjectionStore()->savePage( TestPage::build(
229+
id: $pageId,
230+
properties: TestPageProperties::build( title: 'Page ' . $pageId ),
231+
childSubjects: new SubjectMap(
232+
TestSubject::build( id: $subjectId, label: new SubjectLabel( $label ) )
233+
)
234+
) );
235+
}
236+
153237
protected function newProjectionStore(): GraphDatabasePlugin {
154238
return NeoWikiExtension::getInstance()->newNeo4jProjectionStore(
155239
new InMemorySchemaLookup(
@@ -165,10 +249,14 @@ private function getSubjectLabelsMatching( string $search, int $limit = 10 ): ar
165249
return $this->newLookup()->getSubjectLabelsMatching( $search, $limit, TestSubject::DEFAULT_SCHEMA_ID );
166250
}
167251

168-
private function newLookup( ClientInterface $client = null ): Neo4jSubjectLabelLookup {
252+
private function newLookup(
253+
ClientInterface $client = null,
254+
?PageReadAuthorizer $readAuthorizer = null
255+
): Neo4jSubjectLabelLookup {
169256
return new Neo4jSubjectLabelLookup(
170257
client: $client ?? $this->getClient(),
171258
wikiId: $this->currentWikiId(),
259+
readAuthorizer: $readAuthorizer ?? new StubPageReadAuthorizer( allowed: true ),
172260
);
173261
}
174262

@@ -179,17 +267,24 @@ private function currentWikiId(): string {
179267
/**
180268
* Creates a Subject node directly in the graph, bypassing the projection store, so a test can
181269
* plant a node with a chosen wiki_id (or none at all, as written before wiki_id stamping existed).
270+
*
271+
* The node is attached to a Page carrying the same wiki_id via a HasSubject edge, so it is
272+
* reachable by the lookup's Page->Subject traversal. Without the Page it would be dropped for
273+
* having no Page at all, which would make the wiki filter these tests target untested.
182274
*/
183275
private function createSubjectNode( string $id, string $name, ?string $wikiId ): void {
184-
$properties = [ 'id' => $id, 'name' => $name ];
276+
$subjectProperties = [ 'id' => $id, 'name' => $name ];
277+
$pageProperties = [ 'id' => 0 ];
185278

186279
if ( $wikiId !== null ) {
187-
$properties['wiki_id'] = $wikiId;
280+
$subjectProperties['wiki_id'] = $wikiId;
281+
$pageProperties['wiki_id'] = $wikiId;
188282
}
189283

190284
$this->getClient()->run(
191-
'CREATE (n:Subject:' . Cypher::escape( TestSubject::DEFAULT_SCHEMA_ID ) . ' $properties)',
192-
[ 'properties' => $properties ]
285+
'CREATE (:Page $pageProperties)-[:HasSubject { isMain: false }]->'
286+
. '(:Subject:' . Cypher::escape( TestSubject::DEFAULT_SCHEMA_ID ) . ' $subjectProperties)',
287+
[ 'pageProperties' => $pageProperties, 'subjectProperties' => $subjectProperties ]
193288
);
194289
}
195290

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare( strict_types=1 );
4+
5+
namespace ProfessionalWiki\NeoWiki\Tests\TestDoubles;
6+
7+
use MediaWiki\Title\Title;
8+
use ProfessionalWiki\NeoWiki\Application\PageReadAuthorizer;
9+
use ProfessionalWiki\NeoWiki\Domain\Page\PageId;
10+
11+
/**
12+
* Authorizes every page read except for a fixed set of denied page ids. Lets a list-filter test
13+
* hide specific pages without touching the MediaWiki database, so the caller's own filtering and
14+
* over-fetch logic can be exercised in isolation.
15+
*/
16+
class SelectivePageReadAuthorizer implements PageReadAuthorizer {
17+
18+
/**
19+
* @param int[] $deniedPageIds
20+
*/
21+
public function __construct(
22+
private array $deniedPageIds
23+
) {
24+
}
25+
26+
public function authorizeReadByPageId( PageId $pageId ): bool {
27+
return !in_array( $pageId->id, $this->deniedPageIds, true );
28+
}
29+
30+
public function authorizeReadByPageTitle( Title $title ): bool {
31+
return !in_array( $title->getId(), $this->deniedPageIds, true );
32+
}
33+
34+
}

0 commit comments

Comments
 (0)