Skip to content

fix: escape enum labels in generated SQL to prevent SQL injection#295

Merged
jtayal-stripe merged 1 commit into
mainfrom
fix-enum-label-sqli
Jul 3, 2026
Merged

fix: escape enum labels in generated SQL to prevent SQL injection#295
jtayal-stripe merged 1 commit into
mainfrom
fix-enum-label-sqli

Conversation

@jtayal-stripe

@jtayal-stripe jtayal-stripe commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

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 CREATE privilege on any schema plants a malicious enum label, which is then executed with the plan runner's privileges (typically a superuser/DBA) when pg-schema-diff generates migration SQL. Because Postgres allows COPY ... 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 in Add
  • ALTER TYPE ... ADD VALUE '...' — new value in Alter
  • ... BEFORE '...' — the ordering anchor in Alter

Fix

  • Add EscapeLiteral() to internal/schema alongside the existing EscapeIdentifier(). It doubles single quotes per the SQL standard and strips null bytes, following the same strategy as pgx's QuoteString.
  • Route all three sinks through schema.EscapeLiteral(...) instead of raw interpolation.

Tests

Three layers, all passing:

  • Unit (internal/schema/escape_test.go): EscapeLiteral across normal values, single/multiple quotes, empty strings, backslashes, null bytes, and injection payloads.
  • Unit (pkg/diff/enum_sql_generator_test.go): generator output for Add, ADD VALUE, and ADD VALUE ... BEFORE produces correctly escaped DDL.
  • Acceptance (internal/migration_acceptance_tests/enum_cases_test.go): end-to-end cases (apply plan against real Postgres, then compare pg_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

--- PASS: TestEnumTestCases/create_enum_with_single-quote_labels
--- PASS: TestEnumTestCases/add_value_with_single-quote_label
--- PASS: TestEnumTestCases/add_value_before_a_single-quote_label
--- PASS: TestEnumTestCases/enum_label_with_sql_injection_payload
ok  github.com/stripe/pg-schema-diff/internal/migration_acceptance_tests

--- PASS: TestEscapeLiteral (8 subtests)
ok  github.com/stripe/pg-schema-diff/internal/schema

--- PASS: TestEnumSQLGenerator_Add_EscapesLabels / _Alter_EscapesLabels / _Alter_EscapesBEFORE
ok  github.com/stripe/pg-schema-diff/pkg/diff

How to test

docker build -t pg-schema-diff-test-runner -f ./build/Dockerfile.test --build-arg PG_VERSION=14 .
docker run --rm pg-schema-diff-test-runner -v -race ./internal/migration_acceptance_tests/ -run 'TestEnumTestCases' -timeout 10m

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 alexaub-stripe left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@jtayal-stripe

Copy link
Copy Markdown
Collaborator Author

Good call @alexaub-stripe. I reverted the escaping in enum_sql_generator.go (restoring the raw fmt.Sprintf("'%s'", val) in all three sinks) while keeping the new tests, and re-ran them. Both the unit and acceptance layers fail as expected.

Unit tests (pkg/diff) — without the fix

--- FAIL: TestEnumSQLGenerator_Add_EscapesLabels/label_with_single_quote
    expected: CREATE TYPE "public"."color" AS ENUM ('it''s', 'fine')
    actual  : CREATE TYPE "public"."color" AS ENUM ('it's', 'fine')
--- FAIL: TestEnumSQLGenerator_Add_EscapesLabels/SQL_injection_attempt
    expected: CREATE TYPE "public"."color" AS ENUM ('x''); DROP TABLE foo; --')
    actual  : CREATE TYPE "public"."color" AS ENUM ('x'); DROP TABLE foo; --')
--- FAIL: TestEnumSQLGenerator_Alter_EscapesLabels
    expected: ALTER TYPE "public"."status" ADD VALUE 'it''s complicated'
    actual  : ALTER TYPE "public"."status" ADD VALUE 'it's complicated'
--- FAIL: TestEnumSQLGenerator_Alter_EscapesBEFORE
    expected: ALTER TYPE "public"."status" ADD VALUE 'new''val' BEFORE 'it''s complicated'
    actual  : ALTER TYPE "public"."status" ADD VALUE 'new'val' BEFORE 'it's complicated'
FAIL	github.com/stripe/pg-schema-diff/pkg/diff

Acceptance tests (real Postgres) — without the fix

--- FAIL: TestEnumTestCases/create_enum_with_single-quote_labels
    CREATE TYPE "public"."quote_enum" AS ENUM ('O'Brien', 'it's', 'plain'): ERROR: syntax error at or near "Brien" (SQLSTATE 42601)
--- FAIL: TestEnumTestCases/add_value_with_single-quote_label
    ALTER TYPE "public"."quote_enum" ADD VALUE 'won't': ERROR: syntax error at or near "t" (SQLSTATE 42601)
--- FAIL: TestEnumTestCases/add_value_before_a_single-quote_label
    CREATE TYPE "public"."quote_enum" AS ENUM ('a', 'it's'): ERROR: syntax error at or near "s" (SQLSTATE 42601)
--- FAIL: TestEnumTestCases/enum_label_with_sql_injection_payload
    DDL generated: CREATE TYPE "public"."evil" AS ENUM ('x'); DROP TABLE foo; --')
    ERROR: relation "public.foo" does not exist (SQLSTATE 42P01)
FAIL	github.com/stripe/pg-schema-diff/internal/migration_acceptance_tests

The injection case is the clearest demonstration: without escaping, the generated statement becomes CREATE TYPE ... AS ENUM ('x'); DROP TABLE foo; --'), and Postgres then reports relation "public.foo" does not exist — i.e. the injected DROP TABLE foo actually executed against the database. With the fix in place, all of these pass. I've restored the fix; no code change from this exercise.

@alexaub-stripe alexaub-stripe left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That relation "public.foo" does not exist is perfect, thank you!

@jtayal-stripe jtayal-stripe merged commit 4ff84db into main Jul 3, 2026
12 checks passed
jtayal-stripe added a commit that referenced this pull request Jul 3, 2026
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
jtayal-stripe added a commit that referenced this pull request Jul 3, 2026
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
jtayal-stripe added a commit that referenced this pull request Jul 3, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants