fix: escape enum labels in generated SQL to prevent SQL injection#295
Conversation
Enum labels were interpolated into generated DDL using raw fmt.Sprintf("'%s'", val), so a label containing a single quote could break out of the string literal and inject arbitrary SQL. This is a second-order injection: an attacker with CREATE privilege plants a payload as an enum label, which then executes with the plan runner's (often superuser) privileges when pg-schema-diff generates migration SQL, enabling RCE via COPY ... TO PROGRAM.
Add EscapeLiteral() to internal/schema alongside EscapeIdentifier(), following pgx's QuoteString strategy (double single quotes, strip null bytes). All three vulnerable sinks in enum_sql_generator.go now use it. Adds unit tests for EscapeLiteral and the enum generator, plus end-to-end acceptance tests covering CREATE, ADD VALUE, ADD VALUE ... BEFORE, and an injection-payload regression.
Co-authored-by: Cursor <cursoragent@cursor.com>
Committed-By-Agent: cursor
alexaub-stripe
left a comment
There was a problem hiding this comment.
This looks good to me, and I think these tests are correct and cover the bad behavior, but could you attach some evidence that they fail when the escaping isn't implemented?
|
Good call @alexaub-stripe. I reverted the escaping in Unit tests (
|
alexaub-stripe
left a comment
There was a problem hiding this comment.
That relation "public.foo" does not exist is perfect, thank you!
Two additional SQL injection sinks identified via the same root cause as the enum label fix (#295): schema-derived values re-emitted into DDL without proper escaping. 1. Policy role names (policy_sql_generator.go): AppliesTo role names from pg_roles.rolname were interpolated raw into CREATE/ALTER POLICY ... TO statements. A user with CREATEROLE could create a role with an embedded double-quote to inject arbitrary SQL. Fixed by adding escapeRoleNames() that applies EscapeIdentifier to each role, preserving PUBLIC as an unquoted SQL keyword (matching the existing pattern in privilege_sql_generator.go). 2. Function/procedure names (schema.go buildProcName): The function used hand-rolled quoting with fmt.Sprintf that did not double embedded double-quotes, allowing a user with CREATE FUNCTION to inject SQL via function names containing double-quote characters. Fixed by replacing the hand-rolled quoting with EscapeIdentifier. Includes unit tests, acceptance tests against a live PostgreSQL instance, and verified failure evidence when the fix is reverted. Co-authored-by: Cursor <cursoragent@cursor.com> Committed-By-Agent: cursor Co-authored-by: Cursor <cursoragent@cursor.com> Committed-By-Agent: cursor
Two additional SQL injection sinks identified via the same root cause as the enum label fix (#295): schema-derived values re-emitted into DDL without proper escaping. 1. Policy role names (policy_sql_generator.go): AppliesTo role names from pg_roles.rolname were interpolated raw into CREATE/ALTER POLICY ... TO statements. A user with CREATEROLE could create a role with an embedded double-quote to inject arbitrary SQL. Fixed by adding escapeRoleNames() that applies EscapeIdentifier to each role, preserving PUBLIC as an unquoted SQL keyword (matching the existing pattern in privilege_sql_generator.go). 2. Function/procedure names (schema.go buildProcName): The function used hand-rolled quoting with fmt.Sprintf that did not double embedded double-quotes, allowing a user with CREATE FUNCTION to inject SQL via function names containing double-quote characters. Fixed by replacing the hand-rolled quoting with EscapeIdentifier. Includes unit tests, acceptance tests against a live PostgreSQL instance, and verified failure evidence when the fix is reverted. Co-authored-by: Cursor <cursoragent@cursor.com> Committed-By-Agent: cursor Co-authored-by: Cursor <cursoragent@cursor.com> Committed-By-Agent: cursor Co-authored-by: Cursor <cursoragent@cursor.com> Committed-By-Agent: cursor
Two additional SQL injection sinks identified via the same root cause as the enum label fix (#295): schema-derived values re-emitted into DDL without proper escaping. 1. Policy role names (policy_sql_generator.go): AppliesTo role names from pg_roles.rolname were interpolated raw into CREATE/ALTER POLICY ... TO statements. A user with CREATEROLE could create a role with an embedded double-quote to inject arbitrary SQL. Fixed by adding escapeRoleNames() that applies EscapeIdentifier to each role, preserving PUBLIC as an unquoted SQL keyword (matching the existing pattern in privilege_sql_generator.go). 2. Function/procedure names (schema.go buildProcName): The function used hand-rolled quoting with fmt.Sprintf that did not double embedded double-quotes, allowing a user with CREATE FUNCTION to inject SQL via function names containing double-quote characters. Fixed by replacing the hand-rolled quoting with EscapeIdentifier. Includes unit tests, acceptance tests against a live PostgreSQL instance, and verified failure evidence when the fix is reverted. Committed-By-Agent: cursor Committed-By-Agent: cursor Committed-By-Agent: cursor Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Enum labels are interpolated into generated DDL statements using raw
fmt.Sprintf("'%s'", val). A label containing a single quote breaks out of the string literal, allowing arbitrary SQL to be injected into the generated migration.This is a second-order SQL injection: a user with
CREATEprivilege on any schema plants a malicious enum label, which is then executed with the plan runner's privileges (typically a superuser/DBA) whenpg-schema-diffgenerates migration SQL. Because Postgres allowsCOPY ... TO PROGRAM, this escalates to remote code execution on the database host.Vulnerable sinks (
pkg/diff/enum_sql_generator.go)CREATE TYPE ... AS ENUM (...)— enum values inAddALTER TYPE ... ADD VALUE '...'— new value inAlter... BEFORE '...'— the ordering anchor inAlterFix
EscapeLiteral()tointernal/schemaalongside the existingEscapeIdentifier(). It doubles single quotes per the SQL standard and strips null bytes, following the same strategy aspgx'sQuoteString.schema.EscapeLiteral(...)instead of raw interpolation.Tests
Three layers, all passing:
internal/schema/escape_test.go):EscapeLiteralacross normal values, single/multiple quotes, empty strings, backslashes, null bytes, and injection payloads.pkg/diff/enum_sql_generator_test.go): generator output forAdd,ADD VALUE, andADD VALUE ... BEFOREproduces correctly escaped DDL.internal/migration_acceptance_tests/enum_cases_test.go): end-to-end cases (apply plan against real Postgres, then comparepg_dump) for CREATE, ADD VALUE, ADD VALUE ... BEFORE, and an injection-payload regression (x'); DROP TABLE foo; --). If escaping regresses, the payload would drop the table and the dump comparison would fail.Test output
How to test