From a16c5ef718d82fe655ba527684c3bd179c303ca7 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 13 Oct 2026 12:25:48 +0530 Subject: [PATCH 1/2] fix(operators): reject operators on encrypted attributes An operator runs in the adapter on the stored value, which for an attribute with the encrypt filter is the ciphertext. A stringReplace rewrote the envelope itself, so the value could no longer be decrypted. The operator validator now refuses any operator on an encrypted attribute, as the query validator already refuses filtering on one. --- src/Database/Validator/Operator.php | 7 +++++ tests/e2e/Adapter/Scopes/OperatorTests.php | 34 ++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index 082895af4..6ada79777 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -233,6 +233,13 @@ private function validateOperatorForAttribute( $type = $attribute->type; $isArray = $attribute->array; + // Operators run on the stored value, which for an encrypted attribute is the ciphertext + if (\in_array('encrypt', $attribute->filters, true)) { + $this->message = "Cannot apply {$methodName} operator to encrypted attribute '{$operator->getAttribute()}'"; + + return false; + } + // Array operators that carry a caller-supplied value list are capped to guard against // memory exhaustion. Enforced here so every adapter rejects an oversized list the same way. // The payload may be spread across $values or wrapped in $values[0] (the same shape the diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 0bf19d41a..7365b15aa 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -694,6 +694,40 @@ public function testOperatorArrayErrorHandling(): void $database->deleteCollection($collectionId); } + public function testOperatorEncryptedAttribute(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->supports(Capability::Operators)) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->addFilter( + 'encrypt', + fn (mixed $value) => \is_null($value) ? null : \json_encode(['data' => \base64_encode((string) $value)]), + fn (mixed $value) => \is_null($value) ? null : \base64_decode(\json_decode((string) $value, true)['data'] ?? ''), + ); + + $collectionId = 'test_operator_encrypted'; + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'secret', size: 128, filters: ['encrypt'])); + + $database->createDocument($collectionId, new Document([ + '$id' => 'doc1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'secret' => 'banana', + ])); + + $this->expectException(StructureException::class); + $this->expectExceptionMessage("Cannot apply stringReplace operator to encrypted attribute 'secret'"); + + $database->updateDocument($collectionId, 'doc1', new Document([ + 'secret' => Operator::stringReplace('a', 'o'), + ])); + } + public function testOperatorInsertErrorHandling(): void { /** @var Database $database */ From 4f3ec882bf26685635d1172c015680378c8004a4 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 13 Oct 2026 12:33:53 +0530 Subject: [PATCH 2/2] test(operators): type the encrypt filter callbacks --- tests/e2e/Adapter/Scopes/OperatorTests.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 7365b15aa..86d616fd3 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -706,8 +706,12 @@ public function testOperatorEncryptedAttribute(): void $database->addFilter( 'encrypt', - fn (mixed $value) => \is_null($value) ? null : \json_encode(['data' => \base64_encode((string) $value)]), - fn (mixed $value) => \is_null($value) ? null : \base64_decode(\json_decode((string) $value, true)['data'] ?? ''), + fn (mixed $value): ?string => \is_string($value) ? (string) \json_encode(['data' => \base64_encode($value)]) : null, + function (mixed $value): ?string { + $decoded = \is_string($value) ? \json_decode($value, true) : null; + + return \is_array($decoded) && \is_string($decoded['data'] ?? null) ? (string) \base64_decode($decoded['data']) : null; + }, ); $collectionId = 'test_operator_encrypted';