From 6b6192755076d4ebb33af4b7736aecc6f35d2fa2 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 6 Mar 2026 18:52:04 +1300 Subject: [PATCH 1/8] Fix config state cache poisoning --- src/Database/Database.php | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 6fa6797da..b4f9dc9a3 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9206,8 +9206,28 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a if ($documentId) { $documentKey = $documentHashKey = "{$collectionKey}:{$documentId}"; + $hashParts = []; + if (!empty($selects)) { - $documentHashKey = $documentKey . ':' . \md5(\implode($selects)); + $hashParts[] = \implode($selects); + } + + // Include non-default config state to prevent cache poisoning when + // documents are fetched with filters/relationships disabled + if (!$this->filter || !$this->resolveRelationships || !empty($this->disabledFilters)) { + $configParts = [$this->filter ? '1' : '0', $this->resolveRelationships ? '1' : '0']; + + if (!empty($this->disabledFilters)) { + $disabled = \array_keys($this->disabledFilters); + \sort($disabled); + $configParts[] = \implode(',', $disabled); + } + + $hashParts[] = \implode(':', $configParts); + } + + if (!empty($hashParts)) { + $documentHashKey = $documentKey . ':' . \md5(\implode('|', $hashParts)); } } From e8b197a2c3225fd47f35aeabd6f67df7c255a7ca Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 6 Mar 2026 19:12:50 +1300 Subject: [PATCH 2/8] Avoid serialisation collision --- src/Database/Database.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index b4f9dc9a3..beb4cb9b4 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9220,10 +9220,10 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a if (!empty($this->disabledFilters)) { $disabled = \array_keys($this->disabledFilters); \sort($disabled); - $configParts[] = \implode(',', $disabled); + $configParts[] = \json_encode($disabled); } - $hashParts[] = \implode(':', $configParts); + $hashParts[] = \json_encode($configParts); } if (!empty($hashParts)) { From 2b002dc1ad8ffe083595ab57a9d3a42458a2f6db Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 6 Mar 2026 19:22:35 +1300 Subject: [PATCH 3/8] Just include all filters that were active --- src/Database/Database.php | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index beb4cb9b4..349b01a61 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9212,19 +9212,18 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a $hashParts[] = \implode($selects); } - // Include non-default config state to prevent cache poisoning when - // documents are fetched with filters/relationships disabled - if (!$this->filter || !$this->resolveRelationships || !empty($this->disabledFilters)) { - $configParts = [$this->filter ? '1' : '0', $this->resolveRelationships ? '1' : '0']; - - if (!empty($this->disabledFilters)) { - $disabled = \array_keys($this->disabledFilters); - \sort($disabled); - $configParts[] = \json_encode($disabled); - } - - $hashParts[] = \json_encode($configParts); - } + $allFilters = \array_merge( + \array_keys(self::$filters), + \array_keys($this->instanceFilters) + ); + $enabled = $this->filter + ? \array_values(\array_diff($allFilters, \array_keys($this->disabledFilters ?? []))) + : []; + \sort($enabled); + $hashParts[] = \json_encode([ + $this->resolveRelationships ? '1' : '0', + $enabled, + ]); if (!empty($hashParts)) { $documentHashKey = $documentKey . ':' . \md5(\implode('|', $hashParts)); From b18fa05e17b5929fae0dbbfd1b2d44e02825347f Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 6 Mar 2026 19:25:12 +1300 Subject: [PATCH 4/8] Sorted selects --- src/Database/Database.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 349b01a61..13adf249b 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9209,7 +9209,8 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a $hashParts = []; if (!empty($selects)) { - $hashParts[] = \implode($selects); + \sort($selects); + $hashParts[] = \json_encode($selects); } $allFilters = \array_merge( From 67f33919b50a5150a53df9a66b74711e591b3e91 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 6 Mar 2026 19:36:36 +1300 Subject: [PATCH 5/8] Collapse encode --- src/Database/Database.php | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 13adf249b..3368b6d2d 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9206,12 +9206,8 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a if ($documentId) { $documentKey = $documentHashKey = "{$collectionKey}:{$documentId}"; - $hashParts = []; - - if (!empty($selects)) { - \sort($selects); - $hashParts[] = \json_encode($selects); - } + $sortedSelects = $selects; + \sort($sortedSelects); $allFilters = \array_merge( \array_keys(self::$filters), @@ -9221,14 +9217,13 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a ? \array_values(\array_diff($allFilters, \array_keys($this->disabledFilters ?? []))) : []; \sort($enabled); - $hashParts[] = \json_encode([ - $this->resolveRelationships ? '1' : '0', - $enabled, - ]); - if (!empty($hashParts)) { - $documentHashKey = $documentKey . ':' . \md5(\implode('|', $hashParts)); - } + $payload = \json_encode([ + 'selects' => $sortedSelects, + 'relationships' => $this->resolveRelationships, + 'filters' => $enabled, + ]); + $documentHashKey = $documentKey . ':' . \md5($payload); } return [ From 0c482695b673ba751c0947817bd70894b736052b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 6 Mar 2026 20:16:25 +1300 Subject: [PATCH 6/8] Fix stan types --- src/Database/Database.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 3368b6d2d..bbc6b6e67 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9222,7 +9222,7 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a 'selects' => $sortedSelects, 'relationships' => $this->resolveRelationships, 'filters' => $enabled, - ]); + ]) ?: ''; $documentHashKey = $documentKey . ':' . \md5($payload); } From 0c39efee45e12a3aefef78e032f3ca733e3c3f15 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 6 Mar 2026 20:31:57 +1300 Subject: [PATCH 7/8] Check callable signature to handle in place overrides --- src/Database/Database.php | 53 ++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index bbc6b6e67..7ca7c048c 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -471,6 +471,10 @@ public function __construct( ) { $this->adapter = $adapter; $this->cache = $cache; + foreach ($filters as $name => $callbacks) { + $filters[$name]['signature'] = self::computeCallableSignature($callbacks['encode']) + . ':' . self::computeCallableSignature($callbacks['decode']); + } $this->instanceFilters = $filters; $this->setAuthorization(new Authorization()); @@ -8610,6 +8614,7 @@ public static function addFilter(string $name, callable $encode, callable $decod self::$filters[$name] = [ 'encode' => $encode, 'decode' => $decode, + 'signature' => self::computeCallableSignature($encode) . ':' . self::computeCallableSignature($decode), ]; } @@ -9209,19 +9214,34 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a $sortedSelects = $selects; \sort($sortedSelects); - $allFilters = \array_merge( - \array_keys(self::$filters), - \array_keys($this->instanceFilters) - ); - $enabled = $this->filter - ? \array_values(\array_diff($allFilters, \array_keys($this->disabledFilters ?? []))) - : []; - \sort($enabled); + $filterSignatures = []; + if ($this->filter) { + $disabled = $this->disabledFilters ?? []; + + foreach (self::$filters as $name => $callbacks) { + if (isset($disabled[$name])) { + continue; + } + if (\array_key_exists($name, $this->instanceFilters)) { + continue; + } + $filterSignatures[$name] = $callbacks['signature']; + } + + foreach ($this->instanceFilters as $name => $callbacks) { + if (isset($disabled[$name])) { + continue; + } + $filterSignatures[$name] = $callbacks['signature']; + } + + \ksort($filterSignatures); + } $payload = \json_encode([ 'selects' => $sortedSelects, 'relationships' => $this->resolveRelationships, - 'filters' => $enabled, + 'filters' => $filterSignatures, ]) ?: ''; $documentHashKey = $documentKey . ':' . \md5($payload); } @@ -9233,6 +9253,21 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a ]; } + private static function computeCallableSignature(callable $callable): string + { + if (\is_string($callable)) { + return $callable; + } + + if (\is_array($callable)) { + $class = \is_object($callable[0]) ? \get_class($callable[0]) : $callable[0]; + return $class . '::' . $callable[1]; + } + + $ref = new \ReflectionFunction($callable); + return $ref->getFileName() . ':' . $ref->getStartLine(); + } + /** * @param array $queries * @return void From f9df197a0578eda8558ee5dc8cde5a13060b8b26 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 6 Mar 2026 20:35:54 +1300 Subject: [PATCH 8/8] Fix stan --- src/Database/Database.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 7ca7c048c..52d2b2761 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -372,12 +372,12 @@ class Database protected string $cacheName = 'default'; /** - * @var array + * @var array */ protected static array $filters = []; /** - * @var array + * @var array */ protected array $instanceFilters = []; @@ -9264,8 +9264,9 @@ private static function computeCallableSignature(callable $callable): string return $class . '::' . $callable[1]; } - $ref = new \ReflectionFunction($callable); - return $ref->getFileName() . ':' . $ref->getStartLine(); + $closure = \Closure::fromCallable($callable); + $ref = new \ReflectionFunction($closure); + return ($ref->getFileName() ?: 'unknown') . ':' . $ref->getStartLine(); } /**