Security advisory GHSA-xxh2-68g9-8jqr: ormar is vulnerable to SQL Injection through aggregate functions min() and max()
Package: ormar (pypi), vulnerable version: 0.22.0, fixed in: 0.23.0
CWE: CWE-89
Files changed by the fix:
ormar/queryset/queryset.py

Fix diff:
diff --git a/docs/releases.md b/docs/releases.md
index 19350a2e6..a5752b024 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -1,8 +1,28 @@
 # Release notes
 
+## 0.23.0
+
+### ‼️🚨 Critical vulnerability fixed – please upgrade ASAP
+
+* In this version of ormar the critical vulnerability (`CVE-2026-26198`) in aggregate functions was patched - thanks @AAtomical
+ for reporting. The vulnerability was caused by the way ormar generated SQL queries for aggregate functions, allowing arbitrary SQL execution through user input.
+* Affected versions:
+  * `0.9.9 - 0.12.2`
+  * `0.20.0b1 - 0.22.0 (latest)`
+
+### ✨ Breaking changes
+
+* Drop support for Python 3.9
+
+### 🐛 Fixes
+
+* Fix selecting data with nested models with json fields [#1530](https://github.com/collerek/ormar/pull/1530)
+* Fix prefetching JSON list field throwing TypeError - thanks @jannyware-inc [#1402](https://github.com/collerek/ormar/pull/1402)
+
+
 ## 0.22.0
 
-### 🐛 Breaking changes
+### ✨ Breaking changes
 
 * **Migration from `databases` library to native async SQLAlchemy**
 
@@ -122,7 +142,7 @@
 
 ## 0.21.0
 
-### 🐛 Breaking changes
+### ✨ Breaking changes
 
 * Drop support for Python 3.8
 * Remove the possibility to exclude parents' fields in children models (discouraged as bad practice anyway)
diff --git a/ormar/queryset/queryset.py b/ormar/queryset/queryset.py
index 36274c92e..0fbd552a4 100644
--- a/ormar/queryset/queryset.py
+++ b/ormar/queryset/queryset.py
@@ -704,8 +704,13 @@ async def _query_aggr_function(self, func_name: str, columns: List) -> Any:
         if func_name in ["sum", "avg"]:
             if any(not x.is_numeric for x in select_actions):
                 raise QueryDefinitionError(
-                    "You can use sum and svg only with" "numeric types of columns"
+                    "You can use sum and svg only with numeric types of columns"
                 )
+        if any(x.field_name not in x.target_model.model_fields for x in select_actions):
+            raise QueryDefinitionError(
+                "You can use aggregate functions only on "
+                "existing columns of the target model"
+            )
         select_columns = [x.apply_func(func, use_label=True) for x in select_actions]
         expr = self.build_select_expression().alias(f"subquery_for_{func_name}")
         expr = sqlalchemy.select(*select_columns).select_from(expr)  # type: ignore
diff --git a/pyproject.toml b/pyproject.toml
index fcae9fe7b..08048252a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,7 +3,7 @@ name = "ormar"
 
 [tool.poetry]
 name = "ormar"
-version = "0.22.1"
+version = "0.23.0"
 description = "An async ORM with fastapi in mind and pydantic validation."
 authors = ["Radosław Drążkiewicz <collerek@gmail.com>"]
 license = "MIT"
diff --git a/tests/test_vulnerabilities/__init__.py b/tests/test_vulnerabilities/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/test_vulnerabilities/test_aggregated_functions.py b/tests/test_vulnerabilities/test_aggregated_functions.py
new file mode 100644
index 000000000..5c0f67d92
--- /dev/null
+++ b/tests/test_vulnerabilities/test_aggregated_functions.py
@@ -0,0 +1,69 @@
+from typing import Optional
+
+import ormar
+import pytest
+
+from tests.lifespan import init_tests
+from tests.settings import create_config
+
+base_ormar_config = create_config()
+
+
+class Category(ormar.Model):
+    ormar_config = base_ormar_config.copy(tablename="categories")
+
+    id: int = ormar.Integer(primary_key=True)
+    name: str = ormar.String(max_length=100)
+
+
+class Item(ormar.Model):
+    ormar_config = base_ormar_config.copy(tablename="items")
+
+    id: int = ormar.Integer(primary_key=True)
+    name: str = ormar.String(max_length=100)
+    price: float = ormar.Float(default=0)
+    category: Optional[Category] = ormar.ForeignKey(Category, nullable=True)
+
+
+create_test_database = init_tests(base_ormar_config)
+
+
+@pytest.mark.asyncio
+async def test_arbitrary_sql_execution():
+    async with base_ormar_config.database:
+        async with base_ormar_config.database.transaction(force_rollback=True):
+            if not await Item.objects.count():
+                cat = await Category.objects.create(name="Electronics")
+                await Item.objects.create(name="Tablet", price=449.99, category=cat)
+                await Item.objects.create(name="Monitor", price=329.99, category=cat)
+
+            column = "1 + 1"
+            with pytest.raises(ormar.exceptions.QueryDefinitionError):
+                await Item.objects.min(column)
+
+
+@pytest.mark.asyncio
+async def test_arbitrary_sql_execution_on_related_model():
+    async with base_ormar_config.database:
+        async with base_ormar_config.database.transaction(force_rollback=True):
+            if not await Item.objects.count():
+                cat = await Category.objects.create(name="Electronics")
+                await Item.objects.create(name="Tablet", price=449.99, category=cat)
+                await Item.objects.create(name="Monitor", price=329.99, category=cat)
+
+            column = "category__1 + 1"
+            with pytest.raises(ormar.exceptions.QueryDefinitionError):
+                await Item.objects.min(column)
+
+
+@pytest.mark.asyncio
+async def test_schema_extraction():
+    async with base_ormar_config.database:
+        async with base_ormar_config.database.transaction(force_rollback=True):
+            if not await Item.objects.count():
+                cat = await Category.objects.create(name="Electronics")
+                await Item.objects.create(name="Laptop", price=999.99, category=cat)
+
+            column = "(SELECT GROUP_CONCAT(name) FROM sqlite_master WHERE type='table')"
+            with pytest.raises(ormar.exceptions.QueryDefinitionError):
+                await Item.objects.min(column)