CVE-2023-47128 (published 2023-11-10T18:15Z)

Piccolo is an object-relational mapping and query builder which supports asyncio. Prior to version 1.1.1, the handling of named transaction `savepoints` in all database implementations is vulnerable to SQL Injection via f-strings. While the likelihood of an end developer exposing a `savepoints` `name` parameter to a user is highly unlikely, it would not be unheard of. If a malicious user was able to abuse this functionality they would have essentially direct access to the database and the ability to modify data to the level of permissions associated with the database user. A non exhaustive list of actions possible based on database permissions is: Read all data stored in the database, including usernames and password hashes; insert arbitrary data into the database, including modifying existing records; and gain a shell on the underlying server. Version 1.1.1 fixes this issue.

CVSS v3 base score: 9.1, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)

Weakness information:
cwe_id: CWE-89
cwe_name: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
description: The product constructs all or part of an SQL command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended SQL command when it is sent to a downstream component. Without sufficient removal or quoting of SQL syntax in user-controllable inputs, the generated SQL query can cause those inputs to be interpreted as SQL instead of ordinary user data.
extended_description: 
            
            
         
         

Fix information:
repo_url: https://github.com/piccolo-orm/piccolo
commit_details: repo_url: https://github.com/piccolo-orm/piccolo
author: Daniel Townsend
author_date: 2023-11-10 17:20:01+00:00
msg: Merge pull request from GHSA-xq59-7jf3-rjc6

Co-authored-by: skelmis <ethan.mckee-harris@zxsecurity.co.nz>
repository: repo_url: https://github.com/piccolo-orm/piccolo
repo_name: piccolo-orm/piccolo
description: A fast, user friendly ORM and query builder which supports asyncio.
repo_language: Python
owner: piccolo-orm
file_changes: filename: base.py
change_type: ModificationType.MODIFY
diff: @@ -3,6 +3,7 @@ from __future__ import annotations
 import contextvars
 import logging
 import pprint
+import string
 import typing as t
 from abc import ABCMeta, abstractmethod
 
@@ -15,6 +16,20 @@ if t.TYPE_CHECKING:  # pragma: no cover
 
 
 logger = logging.getLogger(__name__)
+# This is a set to speed up lookups from O(n) when
+# using str vs O(1) when using set[str]
+VALID_SAVEPOINT_CHARACTERS: t.Final[set[str]] = set(
+    string.ascii_letters + string.digits + "-" + "_"
+)
+
+
+def validate_savepoint_name(savepoint_name: str) -> None:
+    """Validates a save point's name meets the required character set."""
+    if not all(i in VALID_SAVEPOINT_CHARACTERS for i in savepoint_name):
+        raise ValueError(
+            "Savepoint names can only contain the following characters:"
+            f" {VALID_SAVEPOINT_CHARACTERS}"
+        )
 
 
 class Batch:

programming_language: Python
filename: postgres.py
change_type: ModificationType.MODIFY
diff: @@ -4,7 +4,7 @@ import contextvars
 import typing as t
 from dataclasses import dataclass
 
-from piccolo.engine.base import Batch, Engine
+from piccolo.engine.base import Batch, Engine, validate_savepoint_name
 from piccolo.engine.exceptions import TransactionError
 from piccolo.query.base import DDL, Query
 from piccolo.querystring import QueryString
@@ -129,11 +129,13 @@ class Savepoint:
         self.transaction = transaction
 
     async def rollback_to(self):
+        validate_savepoint_name(self.name)
         await self.transaction.connection.execute(
             f"ROLLBACK TO SAVEPOINT {self.name}"
         )
 
     async def release(self):
+        validate_savepoint_name(self.name)
         await self.transaction.connection.execute(
             f"RELEASE SAVEPOINT {self.name}"
         )
@@ -236,6 +238,7 @@ class PostgresTransaction:
 
     async def savepoint(self, name: t.Optional[str] = None) -> Savepoint:
         name = name or f"savepoint_{self.get_savepoint_id()}"
+        validate_savepoint_name(name)
         await self.connection.execute(f"SAVEPOINT {name}")
         return Savepoint(name=name, transaction=self)
 

programming_language: Python
filename: sqlite.py
change_type: ModificationType.MODIFY
diff: @@ -10,7 +10,7 @@ import uuid
 from dataclasses import dataclass
 from decimal import Decimal
 
-from piccolo.engine.base import Batch, Engine
+from piccolo.engine.base import Batch, Engine, validate_savepoint_name
 from piccolo.engine.exceptions import TransactionError
 from piccolo.query.base import DDL, Query
 from piccolo.querystring import QueryString
@@ -309,11 +309,13 @@ class Savepoint:
         self.transaction = transaction
 
     async def rollback_to(self):
+        validate_savepoint_name(self.name)
         await self.transaction.connection.execute(
             f"ROLLBACK TO SAVEPOINT {self.name}"
         )
 
     async def release(self):
+        validate_savepoint_name(self.name)
         await self.transaction.connection.execute(
             f"RELEASE SAVEPOINT {self.name}"
         )
@@ -413,6 +415,7 @@ class SQLiteTransaction:
 
     async def savepoint(self, name: t.Optional[str] = None) -> Savepoint:
         name = name or f"savepoint_{self.get_savepoint_id()}"
+        validate_savepoint_name(name)
         await self.connection.execute(f"SAVEPOINT {name}")
         return Savepoint(name=name, transaction=self)
 

programming_language: Python
filename: test_transaction.py
change_type: ModificationType.MODIFY
diff: @@ -2,6 +2,8 @@ import asyncio
 import typing as t
 from unittest import TestCase
 
+import pytest
+
 from piccolo.engine.postgres import Atomic
 from piccolo.engine.sqlite import SQLiteEngine, TransactionType
 from piccolo.table import drop_db_tables_sync
@@ -296,3 +298,14 @@ class TestSavepoint(TestCase):
         self.assertListEqual(
             Manager.select(Manager.name).run_sync(), [{"name": "Manager 1"}]
         )
+
+    def test_savepoint_sqli_checks(self):
+        # Added to test the fix for GHSA-xq59-7jf3-rjc6
+        async def run_test():
+            async with Manager._meta.db.transaction() as transaction:
+                await transaction.savepoint(
+                    "my_savepoint; SELECT * FROM Manager"
+                )
+
+        with pytest.raises(ValueError):
+            run_sync(run_test())

programming_language: Python