Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -288,14 +288,14 @@ parameters:
-
message: "#^Parameter \\#1 \\$string of function strtoupper expects string, string\\|null given\\.$#"
count: 1
path: src/CypherQueryFilter.php
path: src/Persistence/Neo4j/KeywordCypherQueryValidator.php

-
message: "#^Parameter \\#2 \\$subject of function preg_match expects string, string\\|null given\\.$#"
count: 2
path: src/CypherQueryFilter.php
path: src/Persistence/Neo4j/KeywordCypherQueryValidator.php

-
message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, string\\|null given\\.$#"
count: 1
path: src/CypherQueryFilter.php
path: src/Persistence/Neo4j/KeywordCypherQueryValidator.php
11 changes: 11 additions & 0 deletions src/Application/CypherQueryValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare( strict_types=1 );

namespace ProfessionalWiki\NeoWiki\Application;

interface CypherQueryValidator {

public function queryIsAllowed( string $cypher ): bool;

}
6 changes: 3 additions & 3 deletions src/EntryPoints/CypherRawParserFunction.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@

use Exception;
use MediaWiki\Parser\Parser;
use ProfessionalWiki\NeoWiki\CypherQueryFilter;
use ProfessionalWiki\NeoWiki\Application\CypherQueryValidator;
use ProfessionalWiki\NeoWiki\Persistence\QueryEngine;
use RuntimeException;

class CypherRawParserFunction {

public function __construct(
private readonly QueryEngine $queryEngine,
private readonly CypherQueryFilter $queryFilter
private readonly CypherQueryValidator $queryFilter
) {
}

Expand All @@ -25,7 +25,7 @@ public function handle( Parser $parser, string $cypherQuery ): string {
return $this->formatError( wfMessage( 'neowiki-cypher-raw-error-empty-query' )->text() );
}

if ( !$this->queryFilter->isReadQuery( $cypherQuery ) ) {
if ( !$this->queryFilter->queryIsAllowed( $cypherQuery ) ) {
return $this->formatError( wfMessage( 'neowiki-cypher-raw-error-write-query' )->text() );
}

Expand Down
4 changes: 2 additions & 2 deletions src/EntryPoints/NeoWikiHooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
use MediaWiki\Revision\SlotRoleRegistry;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;
use ProfessionalWiki\NeoWiki\CypherQueryFilter;
use ProfessionalWiki\NeoWiki\Persistence\Neo4j\KeywordCypherQueryValidator;
use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName;
use ProfessionalWiki\NeoWiki\EntryPoints\Content\SchemaContent;
use ProfessionalWiki\NeoWiki\EntryPoints\Content\SubjectContent;
Expand Down Expand Up @@ -131,7 +131,7 @@ public static function onParserFirstCallInit( Parser $parser ): void {
static function ( Parser $parser, string $cypherQuery ): string {
$parserFunction = new CypherRawParserFunction(
NeoWikiExtension::getInstance()->getQueryStore(),
new CypherQueryFilter()
new KeywordCypherQueryValidator()
);
return $parserFunction->handle( $parser, $cypherQuery );
}
Expand Down
189 changes: 189 additions & 0 deletions src/Persistence/Neo4j/ExplainCypherQueryValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\Persistence\Neo4j;

use Laudis\Neo4j\Contracts\ClientInterface;
use Laudis\Neo4j\Contracts\TransactionInterface;
use Laudis\Neo4j\Databags\Plan;
use ProfessionalWiki\NeoWiki\Application\CypherQueryValidator;

readonly class ExplainCypherQueryValidator implements CypherQueryValidator {

/**
* Read-only plan operators known to Neo4j 5.x. Any operator not in this list
* causes the query to be classified as non-read-only.
*/
private const array ALLOWED_OPERATORS = [
// Result production
'ProduceResults',
'EmptyResult',

// Node scans and seeks
'AllNodesScan',
'NodeByLabelScan',
'NodeByIdSeek',
'NodeByElementIdSeek',
'NodeUniqueIndexSeek',
'NodeIndexSeek',
'NodeIndexScan',
'NodeIndexContainsScan',
'NodeIndexEndsWithScan',
'IntersectionNodeByLabelsScan',
'UnionNodeByLabelsScan',
'MultiNodeIndexSeek',

// Relationship scans and seeks
'DirectedRelationshipTypeScan',
'UndirectedRelationshipTypeScan',
'DirectedRelationshipByIdSeek',
'UndirectedRelationshipByIdSeek',
'DirectedRelationshipByElementIdSeek',
'UndirectedRelationshipByElementIdSeek',
'DirectedAllRelationshipsScan',
'UndirectedAllRelationshipsScan',
'DirectedRelationshipIndexSeek',
'UndirectedRelationshipIndexSeek',
'DirectedRelationshipIndexScan',
'UndirectedRelationshipIndexScan',
'DirectedRelationshipIndexContainsScan',
'UndirectedRelationshipIndexContainsScan',
'DirectedRelationshipIndexEndsWithScan',
'UndirectedRelationshipIndexEndsWithScan',
'DirectedUnionRelationshipTypesScan',
'UndirectedUnionRelationshipTypesScan',

// Expand (path traversal)
'Expand(All)',
'Expand(Into)',
'OptionalExpand(All)',
'OptionalExpand(Into)',
'VarLengthExpand(All)',
'VarLengthExpand(Into)',
'VarLengthExpand(Pruning)',
'BFSPruningVarExpand',

// Shortest path
'ShortestPath',
'AllShortestPaths',
'StatefulShortestPath',

// Filter and transform
'Filter',
'Projection',
'Limit',
'ExhaustiveLimit',
'Skip',
'Sort',
'PartialSort',
'Top',
'PartialTop',
'Distinct',
'OrderedDistinct',
'Aggregation',
'OrderedAggregation',
'EagerAggregation',
'Eager',
'CacheProperties',
'UnwindCollection',
'Unwind',
'NodeCountFromCountStore',
'RelationshipCountFromCountStore',

// Joins
'CartesianProduct',
'NodeHashJoin',
'ValueHashJoin',
'NodeLeftOuterHashJoin',
'NodeRightOuterHashJoin',
'AssertSameNode',
'AssertSameRelationship',

// Apply variants
'Apply',
'SemiApply',
'AntiSemiApply',
'SelectOrSemiApply',
'SelectOrAntiSemiApply',
'LetSemiApply',
'LetAntiSemiApply',
'ConditionalApply',
'AntiConditionalApply',
'RollUpApply',

// Set operations
'Union',
'OrderedUnion',

// Other
'Argument',
'Input',
'Optional',

// Parallel variants
'PartitionedAllNodesScan',
'PartitionedNodeByLabelScan',
'PartitionedDirectedRelationshipTypeScan',
'PartitionedUndirectedRelationshipTypeScan',
'PartitionedDirectedAllRelationshipsScan',
'PartitionedUndirectedAllRelationshipsScan',
'PartitionedNodeIndexScan',
'PartitionedNodeIndexSeek',
'PartitionedDirectedRelationshipIndexScan',
'PartitionedUndirectedRelationshipIndexScan',
'PartitionedDirectedRelationshipIndexSeek',
'PartitionedUndirectedRelationshipIndexSeek',
'PartitionedIntersectionNodeByLabelsScan',
'PartitionedUnionNodeByLabelsScan',
'PartitionedDirectedUnionRelationshipTypesScan',
'PartitionedUndirectedUnionRelationshipTypesScan',
];

public function __construct(
private ClientInterface $client,
) {
}

public function queryIsAllowed( string $cypher ): bool {
$plan = $this->getExplainPlan( $cypher );

return $this->allOperatorsAllowed( $plan );
}

private function getExplainPlan( string $cypher ): Plan {
$result = $this->client->readTransaction(
function ( TransactionInterface $transaction ) use ( $cypher ) {
return $transaction->run( 'EXPLAIN ' . $cypher );
}
);

return $result->getSummary()->getPlan()
?? throw new \RuntimeException( 'EXPLAIN did not return a plan' );
}

private function allOperatorsAllowed( Plan $plan ): bool {
if ( !$this->isAllowedOperator( $plan->getOperator() ) ) {
return false;
}

foreach ( $plan->getChildren() as $child ) {
if ( !$this->allOperatorsAllowed( $child ) ) {
return false;
}
}

return true;
}

private function isAllowedOperator( string $operator ): bool {
return in_array( $this->stripDatabaseSuffix( $operator ), self::ALLOWED_OPERATORS );
}

private function stripDatabaseSuffix( string $operator ): string {
$atPosition = strpos( $operator, '@' );

return $atPosition !== false ? substr( $operator, 0, $atPosition ) : $operator;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki;
namespace ProfessionalWiki\NeoWiki\Persistence\Neo4j;

class CypherQueryFilter {
use ProfessionalWiki\NeoWiki\Application\CypherQueryValidator;

class KeywordCypherQueryValidator implements CypherQueryValidator {

private const array WRITE_KEYWORDS = [
'CREATE', 'SET', 'DELETE', 'REMOVE', 'MERGE', 'DROP',
Expand All @@ -13,8 +15,8 @@ class CypherQueryFilter {
'SHOW',
];

public function isReadQuery( string $query ): bool {
$normalizedQuery = $this->normalizeQuery( $query );
public function queryIsAllowed( string $cypher ): bool {
$normalizedQuery = $this->normalizeQuery( $cypher );

return !$this->containsWriteOperations( $normalizedQuery );
}
Expand Down
14 changes: 7 additions & 7 deletions tests/phpunit/EntryPoints/CypherRawParserFunctionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
use Laudis\Neo4j\Types\CypherMap;
use MediaWiki\Parser\Parser;
use PHPUnit\Framework\TestCase;
use ProfessionalWiki\NeoWiki\CypherQueryFilter;
use ProfessionalWiki\NeoWiki\Persistence\Neo4j\KeywordCypherQueryValidator;
use ProfessionalWiki\NeoWiki\EntryPoints\CypherRawParserFunction;
use ProfessionalWiki\NeoWiki\Persistence\QueryEngine;

Expand Down Expand Up @@ -55,7 +55,7 @@ private function createQueryEngineWithException( Exception $exception ): QueryEn
public function testEmptyQueryReturnsError(): void {
$parserFunction = new CypherRawParserFunction(
$this->createDummyQueryEngine(),
new CypherQueryFilter()
new KeywordCypherQueryValidator()
);

$result = $parserFunction->handle( $this->createMockParser(), '' );
Expand All @@ -66,7 +66,7 @@ public function testEmptyQueryReturnsError(): void {
public function testWriteQueryIsRejected(): void {
$parserFunction = new CypherRawParserFunction(
$this->createDummyQueryEngine(),
new CypherQueryFilter()
new KeywordCypherQueryValidator()
);

$result = $parserFunction->handle( $this->createMockParser(), "CREATE (n:Person {name: 'Alice'})" );
Expand All @@ -82,7 +82,7 @@ public function testValidReadQueryReturnsFormattedResult(): void {

$parserFunction = new CypherRawParserFunction(
$this->createQueryEngineWithData( $testData ),
new CypherQueryFilter()
new KeywordCypherQueryValidator()
);

$result = $parserFunction->handle( $this->createMockParser(), 'MATCH (n:Person) RETURN n' );
Expand All @@ -95,7 +95,7 @@ public function testValidReadQueryReturnsFormattedResult(): void {
public function testQueryExecutionExceptionReturnsError(): void {
$parserFunction = new CypherRawParserFunction(
$this->createQueryEngineWithException( new Exception( 'Connection failed' ) ),
new CypherQueryFilter()
new KeywordCypherQueryValidator()
);

$result = $parserFunction->handle( $this->createMockParser(), 'MATCH (n) RETURN n' );
Expand All @@ -106,7 +106,7 @@ public function testQueryExecutionExceptionReturnsError(): void {
public function testTrimWhitespaceFromQuery(): void {
$parserFunction = new CypherRawParserFunction(
$this->createQueryEngineWithData( [] ),
new CypherQueryFilter()
new KeywordCypherQueryValidator()
);

$result = $parserFunction->handle( $this->createMockParser(), ' MATCH (n) RETURN n ' );
Expand All @@ -121,7 +121,7 @@ public function testOutputIsHTMLEscaped(): void {

$parserFunction = new CypherRawParserFunction(
$this->createQueryEngineWithData( $testData ),
new CypherQueryFilter()
new KeywordCypherQueryValidator()
);

$result = $parserFunction->handle( $this->createMockParser(), 'MATCH (n) RETURN n' );
Expand Down
Loading