Skip to content

Commit 88ce188

Browse files
sandiyochristanclaudeAdriiiPRodrijfagoagas
authored
fix(api): [security] use psycopg2.sql to safely compose DDL in PostgresEnumMigration (#10166)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Adrián Peña <adrianjpr@gmail.com> Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
1 parent df680ef commit 88ce188

File tree

4 files changed

+82
-5
lines changed

4 files changed

+82
-5
lines changed

api/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ All notable changes to the **Prowler API** are documented in this file.
1313
- Attack Paths: Complete migration to private graph labels and properties, removing deprecated dual-write support [(#10268)](https://github.com/prowler-cloud/prowler/pull/10268)
1414
- Attack Paths: Added tenant and provider related labels to the nodes so they can be easily filtered on custom queries [(#10308)](https://github.com/prowler-cloud/prowler/pull/10308)
1515

16+
### 🔐 Security
17+
18+
- Use `psycopg2.sql` to safely compose DDL in `PostgresEnumMigration`, preventing SQL injection via f-string interpolation [(#10166)](https://github.com/prowler-cloud/prowler/pull/10166)
19+
1620
---
1721

1822
## [1.21.0] (Prowler v5.20.0)

api/src/backend/api/db_utils.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
)
1919
from django_celery_beat.models import PeriodicTask
2020
from psycopg2 import connect as psycopg2_connect
21+
from psycopg2 import sql as psycopg2_sql
2122
from psycopg2.extensions import AsIs, new_type, register_adapter, register_type
2223
from rest_framework_json_api.serializers import ValidationError
2324

@@ -280,15 +281,23 @@ def __init__(self, enum_name: str, enum_values: tuple):
280281
self.enum_values = enum_values
281282

282283
def create_enum_type(self, apps, schema_editor): # noqa: F841
283-
string_enum_values = ", ".join([f"'{value}'" for value in self.enum_values])
284284
with schema_editor.connection.cursor() as cursor:
285285
cursor.execute(
286-
f"CREATE TYPE {self.enum_name} AS ENUM ({string_enum_values});"
286+
psycopg2_sql.SQL("CREATE TYPE {} AS ENUM ({})").format(
287+
psycopg2_sql.Identifier(self.enum_name),
288+
psycopg2_sql.SQL(", ").join(
289+
psycopg2_sql.Literal(v) for v in self.enum_values
290+
),
291+
)
287292
)
288293

289294
def drop_enum_type(self, apps, schema_editor): # noqa: F841
290295
with schema_editor.connection.cursor() as cursor:
291-
cursor.execute(f"DROP TYPE {self.enum_name};")
296+
cursor.execute(
297+
psycopg2_sql.SQL("DROP TYPE {}").format(
298+
psycopg2_sql.Identifier(self.enum_name)
299+
)
300+
)
292301

293302

294303
class PostgresEnumField(models.Field):

api/src/backend/api/tests/test_db_utils.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
from django.conf import settings
77
from django.db import DEFAULT_DB_ALIAS, OperationalError
88
from freezegun import freeze_time
9+
from psycopg2 import sql as psycopg2_sql
910
from rest_framework_json_api.serializers import ValidationError
1011

1112
from api.db_utils import (
1213
POSTGRES_TENANT_VAR,
14+
PostgresEnumMigration,
1315
_should_create_index_on_partition,
1416
batch_delete,
1517
create_objects_in_batches,
@@ -910,3 +912,61 @@ def test_rls_transaction_cursor_yielded_correctly(self, tenants_fixture):
910912
cursor.execute("SELECT 1")
911913
result = cursor.fetchone()
912914
assert result[0] == 1
915+
916+
917+
class TestPostgresEnumMigration:
918+
"""
919+
Verify that PostgresEnumMigration builds DDL statements via psycopg2.sql
920+
so that enum type names and values are always properly quoted — preventing
921+
SQL injection through f-string interpolation.
922+
"""
923+
924+
def _make_mock_schema_editor(self):
925+
mock_cursor = MagicMock()
926+
mock_conn = MagicMock()
927+
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
928+
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
929+
mock_schema_editor = MagicMock()
930+
mock_schema_editor.connection = mock_conn
931+
return mock_schema_editor, mock_cursor
932+
933+
def test_create_enum_type_generates_correct_sql(self):
934+
"""create_enum_type builds a proper CREATE TYPE … AS ENUM via psycopg2.sql."""
935+
migration = PostgresEnumMigration("my_enum", ("val_a", "val_b"))
936+
schema_editor, mock_cursor = self._make_mock_schema_editor()
937+
938+
migration.create_enum_type(apps=None, schema_editor=schema_editor)
939+
940+
mock_cursor.execute.assert_called_once()
941+
query_arg = mock_cursor.execute.call_args[0][0]
942+
assert isinstance(
943+
query_arg, psycopg2_sql.Composable
944+
), "create_enum_type must pass a psycopg2.sql.Composable, not a raw string."
945+
# Verify the composed SQL structure: CREATE TYPE <Identifier> AS ENUM (<Literals>)
946+
parts = query_arg.seq
947+
assert parts[0] == psycopg2_sql.SQL("CREATE TYPE ")
948+
assert isinstance(parts[1], psycopg2_sql.Identifier)
949+
assert parts[1].strings == ("my_enum",)
950+
assert parts[2] == psycopg2_sql.SQL(" AS ENUM (")
951+
# The enum values are a Composed of Literal items joined by ", "
952+
enum_literals = [p for p in parts[3].seq if isinstance(p, psycopg2_sql.Literal)]
953+
assert [lit._wrapped for lit in enum_literals] == ["val_a", "val_b"]
954+
assert parts[4] == psycopg2_sql.SQL(")")
955+
956+
def test_drop_enum_type_generates_correct_sql(self):
957+
"""drop_enum_type builds a proper DROP TYPE via psycopg2.sql."""
958+
migration = PostgresEnumMigration("my_enum", ("val_a",))
959+
schema_editor, mock_cursor = self._make_mock_schema_editor()
960+
961+
migration.drop_enum_type(apps=None, schema_editor=schema_editor)
962+
963+
mock_cursor.execute.assert_called_once()
964+
query_arg = mock_cursor.execute.call_args[0][0]
965+
assert isinstance(
966+
query_arg, psycopg2_sql.Composable
967+
), "drop_enum_type must pass a psycopg2.sql.Composable, not a raw string."
968+
# Verify the composed SQL structure: DROP TYPE <Identifier>
969+
parts = query_arg.seq
970+
assert parts[0] == psycopg2_sql.SQL("DROP TYPE ")
971+
assert isinstance(parts[1], psycopg2_sql.Identifier)
972+
assert parts[1].strings == ("my_enum",)

api/src/backend/api/tests/test_views.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7858,8 +7858,12 @@ def test_create_relationship(
78587858
assert response.status_code == status.HTTP_204_NO_CONTENT
78597859
relationships = UserRoleRelationship.objects.filter(user=create_test_user.id)
78607860
assert relationships.count() == 4
7861-
for relationship in relationships[2:]: # Skip admin role
7862-
assert relationship.role.id in [r.id for r in roles_fixture[:2]]
7861+
# Use set membership instead of positional slicing — QuerySet ordering is
7862+
# non-deterministic without an explicit order_by, which makes slice-based
7863+
# checks intermittently fail.
7864+
added_role_ids = {r.id for r in roles_fixture[:2]}
7865+
relationship_role_ids = {rel.role.id for rel in relationships}
7866+
assert added_role_ids.issubset(relationship_role_ids)
78637867

78647868
def test_create_relationship_already_exists(
78657869
self, authenticated_client, roles_fixture, create_test_user

0 commit comments

Comments
 (0)