From 380d0c1e81c75645ff30203199a4c4664d4be59a Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Wed, 7 Oct 2026 22:58:45 +0200 Subject: [PATCH] fix(security): allowlist TV filter operators and use the stored document id in getTemplateVars The operator reaches raw SQL for numeric casts, so match it against an exact list instead of a word pattern (tv:price:OR:1:UNSIGNED used to pass). The document id and template id in getTemplateVars come from the fetched record as integers instead of the caller's value. Co-Authored-By: Claude Sonnet 5.5 --- core/src/Core.php | 6 +++-- core/src/Models/SiteContent.php | 11 ++++++++- .../Unit/Security/TreeSortAllowlistTest.php | 23 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/core/src/Core.php b/core/src/Core.php index e65d768f97..a780e97bae 100644 --- a/core/src/Core.php +++ b/core/src/Core.php @@ -5398,7 +5398,7 @@ public function getTemplateVars($idnames = [], $fields = '*', $docid = '', $publ // get document record if (empty($docid)) { - $docid = $this->documentIdentifier; + $docid = (int)$this->documentIdentifier; $docRow = $this->documentObject; } else { $docRow = $this->getDocument($docid, '*', $published, 0, $checkAccess); @@ -5407,6 +5407,8 @@ public function getTemplateVars($idnames = [], $fields = '*', $docid = '', $publ $cached[$cacheKey] = false; return false; } + // The id reaches raw SQL below: use the one the database returned, never the caller's value + $docid = (int)($docRow['id'] ?? 0); } $table = $this->getDatabase()->getFullTableName('site_tmplvars'); // get user defined template variables @@ -5442,7 +5444,7 @@ public function getTemplateVars($idnames = [], $fields = '*', $docid = '', $publ $join->on('site_tmplvar_contentvalues.tmplvarid', '=', 'site_tmplvars.id'); $join->on('site_tmplvar_contentvalues.contentid', '=', \DB::raw($docid)); }) - ->whereRaw($query . " AND " . $this->getDatabase()->getConfig('prefix') . "site_tmplvar_templates.templateid = '" . $docRow['template'] . "'"); + ->whereRaw($query . " AND " . $this->getDatabase()->getConfig('prefix') . "site_tmplvar_templates.templateid = '" . (int)$docRow['template'] . "'"); if ($sort != '') { $rs = $rs->orderByRaw($sort); } diff --git a/core/src/Models/SiteContent.php b/core/src/Models/SiteContent.php index 57c4036dfb..3bb7ddd080 100644 --- a/core/src/Models/SiteContent.php +++ b/core/src/Models/SiteContent.php @@ -101,6 +101,15 @@ class SiteContent extends Eloquent\Model */ const MAX_TV_QUERY_TERMS = 20; + /** + * Operators a tvFilter() term may use. The operator is spliced into raw SQL for numeric casts, + * so it has to be an exact match, not a pattern. + */ + const TV_FILTER_OPERATORS = [ + '=', '!=', '<>', '<', '>', '<=', '>=', + 'like', 'like-l', 'like-r', 'in', 'not_in', 'isnull', 'null', 'isnotnull', '!null', + ]; + /** * ClosureTable model instance. * @@ -2244,7 +2253,7 @@ public function scopeTvFilter($query, $filters = '', $outerSep = ';', $innerSep $cast = !empty($parts[4]) ? $parts[4] : ''; // The name, operator and cast end up in raw SQL below, so refuse anything that is not plain if (!preg_match('/^[\w\-]+$/D', (string)$tvname) - || !preg_match('/^(=|!=|<>|<=|>=|<|>|[a-z_\-!]+)$/iD', (string)$op) + || !in_array(strtolower((string)$op), self::TV_FILTER_OPERATORS, true) || !preg_match('/^([A-Za-z]+(\(\d+(,\d+)?\))?)?$/D', (string)$cast)) { // Fail closed: dropping a malformed filter would widen the result set $query = $query->whereRaw('1 = 0'); diff --git a/core/tests/Unit/Security/TreeSortAllowlistTest.php b/core/tests/Unit/Security/TreeSortAllowlistTest.php index 51698e1881..eb8af2b908 100644 --- a/core/tests/Unit/Security/TreeSortAllowlistTest.php +++ b/core/tests/Unit/Security/TreeSortAllowlistTest.php @@ -49,6 +49,13 @@ public function whereRaw($sql) return $this; } + public function __call($method, $args) + { + $this->raw[] = $method . ':' . json_encode($args); + + return $this; + } + public function orderBy(...$args) { $this->raw[] = 'orderBy:' . json_encode($args); @@ -104,3 +111,19 @@ public function where(...$args) expect($q->raw)->toHaveCount($max); $GLOBALS['evo'] = null; }); + +test('a tv filter operator outside the allowlist matches nothing', function (string $op) { + $q = recordingTvQuery(); + (new \EvolutionCMS\Models\SiteContent())->scopeTvFilter($q, "tv:price:{$op}:1:UNSIGNED"); + + expect($q->raw)->toBe(['1 = 0']); + $GLOBALS['evo'] = null; +})->with(['OR', 'AND', 'xor', 'is-not', 'div', 'regexp-x']); + +test('every allowlisted tv filter operator is still applied', function (string $op) { + $q = recordingTvQuery(); + (new \EvolutionCMS\Models\SiteContent())->scopeTvFilter($q, "tv:price:{$op}:1:UNSIGNED"); + + expect($q->raw)->not->toBe(['1 = 0']); + $GLOBALS['evo'] = null; +})->with(\EvolutionCMS\Models\SiteContent::TV_FILTER_OPERATORS);