Skip to content

Commit 6c587fa

Browse files
JeroenDeDauwclaude
andcommitted
Read-gate the projection list and canonicalize its casing
Two PR-review fixes to the RDF export resolution path: * The unknown-projection 400 listed every Mapping page title from the raw DB query, leaking the titles of read-restricted Mapping pages. ExportPageRdfApi now filters the list by the caller's own read permission (NeoWikiExtension::filterReadableProjectionNames), mirroring the Schema read filter (DatabaseSchemaNameLookup). The system-context getRdfProjectionNames() used by DumpRdf and the SPARQL store stays unfiltered. * WikiPageMappingLookup::getMapping() now names the returned Mapping from the resolved Title's text rather than the requested string, so ?projection=eDM and ?projection=EDM (the same Mapping:EDM page) mint the same projector, serializer, and named-graph IRI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5b81cd5 commit 6c587fa

4 files changed

Lines changed: 99 additions & 2 deletions

File tree

src/EntryPoints/REST/ExportPageRdfApi.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,17 @@ public function run( int $pageId ): Response {
3434
$resolution = $extension->resolveRdfProjection( $projectionName );
3535

3636
if ( $resolution->projection === null ) {
37+
// Read-filter the known-projection list with the caller's own authority, so the 400 never
38+
// leaks the titles of Mapping pages they may not read — the same gate placement as the
39+
// per-page read check below.
40+
$knownProjections = $extension->filterReadableProjectionNames(
41+
$resolution->knownProjectionNames,
42+
$this->getAuthority()
43+
);
44+
3745
return $this->getResponseFactory()->createHttpError( 400, [
3846
'message' => 'Unknown RDF projection: "' . $projectionName . '". Known projections: '
39-
. implode( ', ', $resolution->knownProjectionNames ) . '.',
47+
. implode( ', ', $knownProjections ) . '.',
4048
] );
4149
}
4250

src/NeoWikiExtension.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,35 @@ public function getRdfProjectionNames(): array {
404404
);
405405
}
406406

407+
/**
408+
* The known projection names the given authority may read, for the REST 400 body. "native" is not a
409+
* page and is always kept; a Mapping name is kept only when its page is readable, so a read-restricted
410+
* Mapping page's title never leaks into the error (#1046, mirroring the Schema
411+
* {@see \ProfessionalWiki\NeoWiki\Persistence\MediaWiki\DatabaseSchemaNameLookup} read filter). This is
412+
* the REST boundary; the system-context {@see self::getRdfProjectionNames()} used by DumpRdf and the
413+
* SPARQL store stays unfiltered.
414+
*
415+
* @param string[] $names
416+
* @return string[]
417+
*/
418+
public function filterReadableProjectionNames( array $names, Authority $authority ): array {
419+
$readAuthorizer = $this->newPageReadAuthorizer( $authority );
420+
$titleFactory = MediaWikiServices::getInstance()->getTitleFactory();
421+
422+
return array_values( array_filter(
423+
$names,
424+
static function ( string $name ) use ( $readAuthorizer, $titleFactory ): bool {
425+
if ( $name === RdfPageProjector::PROJECTION ) {
426+
return true;
427+
}
428+
429+
$title = $titleFactory->newFromText( $name, NeoWikiExtension::NS_MAPPING );
430+
431+
return $title !== null && $readAuthorizer->authorizeReadByPageTitle( $title );
432+
}
433+
) );
434+
}
435+
407436
/**
408437
* The native prefixes (Subject IRIs stay native) plus the Mapping's declared ontology prefixes, for
409438
* readable output. Unsafe prefix namespaces are dropped defensively so a Mapping can never inject a
@@ -429,6 +458,7 @@ public function getMappingLookup(): MappingLookup {
429458
pageContentFetcher: $this->getPageContentFetcher(),
430459
authority: $this->getRequestAuthority(),
431460
mappingDeserializer: $this->getMappingPersistenceDeserializer(),
461+
titleParser: MediaWikiServices::getInstance()->getTitleParser(),
432462
),
433463
cache: MediaWikiServices::getInstance()->getMainWANObjectCache(),
434464
titleFactory: MediaWikiServices::getInstance()->getTitleFactory(),

src/Persistence/MediaWiki/WikiPageMappingLookup.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use InvalidArgumentException;
88
use LogicException;
99
use MediaWiki\Permissions\Authority;
10+
use MediaWiki\Title\MalformedTitleException;
11+
use MediaWiki\Title\TitleParser;
1012
use ProfessionalWiki\NeoWiki\Application\MappingLookup;
1113
use ProfessionalWiki\NeoWiki\Domain\Mapping\Mapping;
1214
use ProfessionalWiki\NeoWiki\Domain\Mapping\MappingName;
@@ -19,6 +21,7 @@ public function __construct(
1921
private readonly PageContentFetcher $pageContentFetcher,
2022
private readonly Authority $authority,
2123
private readonly MappingPersistenceDeserializer $mappingDeserializer,
24+
private readonly TitleParser $titleParser,
2225
) {
2326
}
2427

@@ -30,13 +33,30 @@ public function getMapping( MappingName $name ): ?Mapping {
3033
}
3134

3235
try {
33-
return $this->mappingDeserializer->deserialize( $name, $content->getText() );
36+
return $this->mappingDeserializer->deserialize( $this->canonicalName( $name ), $content->getText() );
3437
}
3538
catch ( InvalidArgumentException ) {
3639
return null;
3740
}
3841
}
3942

43+
/**
44+
* The requested name normalized to its Mapping page's canonical title text — MediaWiki capitalizes
45+
* the first letter — so a projection requested as "eDM" and one requested as "EDM" (the same page)
46+
* mint the same projector, serializer, and named-graph IRI. The content fetch already succeeded, so
47+
* the title parses; the guard is defensive.
48+
*/
49+
private function canonicalName( MappingName $name ): MappingName {
50+
try {
51+
return new MappingName(
52+
$this->titleParser->parseTitle( $name->getText(), NeoWikiExtension::NS_MAPPING )->getText()
53+
);
54+
}
55+
catch ( MalformedTitleException ) {
56+
return $name;
57+
}
58+
}
59+
4060
private function getContent( MappingName $name ): ?MappingContent {
4161
$content = $this->pageContentFetcher->getPageContent(
4262
$name->getText(),

tests/phpunit/EntryPoints/REST/ExportPageRdfApiTest.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,45 @@ public function testProjectionNameIsTheMappingPageTitle(): void {
241241
$this->assertSame( 400, $this->export( query: [ 'projection' => 'edm' ] )->getStatusCode() );
242242
}
243243

244+
public function testProjectionCasingResolvesToTheCanonicalMappingPageTitle(): void {
245+
// MediaWiki capitalizes only the first title letter, so "eDM" and "EDM" are the same Mapping:EDM
246+
// page. Both must mint the canonical "EDM" named graph, not one keyed on the requested casing.
247+
$this->createEdmMapping();
248+
249+
$lowerFirst = $this->export( query: [ 'projection' => 'eDM' ] );
250+
$exact = $this->export( query: [ 'projection' => 'EDM' ] );
251+
252+
$this->assertSame( 200, $lowerFirst->getStatusCode() );
253+
$this->assertSame( 200, $exact->getStatusCode() );
254+
$this->assertStringEndsWith(
255+
'/graph/EDM/page/' . $this->pageId,
256+
$this->soleGraphIn( $lowerFirst->getBody()->getContents() ),
257+
'A projection requested with non-canonical casing still mints the canonical named graph.'
258+
);
259+
$this->assertStringEndsWith(
260+
'/graph/EDM/page/' . $this->pageId,
261+
$this->soleGraphIn( $exact->getBody()->getContents() )
262+
);
263+
}
264+
265+
public function testReadRestrictedMappingNamesAreAbsentFromTheKnownProjectionsList(): void {
266+
// The 400 list must not leak the titles of Mapping pages the caller cannot read (#1046). A caller
267+
// that can read the page sees "EDM"; one that cannot must not — though "native" (not a page) stays.
268+
$this->createEdmMapping();
269+
270+
$readable = $this->export( query: [ 'projection' => 'bogus' ] );
271+
$restricted = $this->export(
272+
query: [ 'projection' => 'bogus' ],
273+
authority: $this->authorityWithGlobalReadButNoPageRead()
274+
);
275+
276+
$this->assertStringContainsString( 'EDM', $readable->getBody()->getContents() );
277+
278+
$restrictedBody = $restricted->getBody()->getContents();
279+
$this->assertStringNotContainsString( 'EDM', $restrictedBody, 'A read-restricted Mapping page name must not leak into the 400 list.' );
280+
$this->assertStringContainsString( 'native', $restrictedBody, 'The always-available native projection stays listed.' );
281+
}
282+
244283
/**
245284
* The export surface is where the projection name selected by the request reaches the projector that
246285
* mints the graph (#1053). Asserting the graph IRI here, rather than only on the projector, is what

0 commit comments

Comments
 (0)