From 09434a2014a46bd20721276ae580d9d1d41b43f3 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 13 Oct 2026 13:42:50 +0530 Subject: [PATCH] fix(operators): reject operators on encrypted attributes Backport of #1011 to 7.4.x. An operator runs in the adapter on the stored value, which for an attribute with the encrypt filter is the ciphertext, so a stringReplace rewrote the envelope and the value could no longer be decrypted. The operator validator now refuses any operator on an encrypted attribute. --- src/Database/Validator/Operator.php | 7 ++++ tests/e2e/Adapter/Scopes/OperatorTests.php | 38 ++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index 655d5d511..03dc6c36d 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -149,6 +149,13 @@ private function validateOperatorForAttribute( // Handle both Document objects and arrays $type = $attribute instanceof Document ? $attribute->getAttribute('type') : $attribute['type']; $isArray = $attribute instanceof Document ? ($attribute->getAttribute('array') ?? false) : ($attribute['array'] ?? false); + $filters = $attribute instanceof Document ? $attribute->getAttribute('filters', []) : ($attribute['filters'] ?? []); + + // Operators run on the stored value, which for an encrypted attribute is the ciphertext + if (\in_array('encrypt', $filters, true)) { + $this->message = "Cannot apply {$method} 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. diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 76a6d883a..3052f3df3 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -680,6 +680,44 @@ public function testOperatorArrayErrorHandling(): void $database->deleteCollection($collectionId); } + public function testOperatorEncryptedAttribute(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->addFilter( + 'encrypt', + 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'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'secret', Database::VAR_STRING, 128, false, 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 */