Low severityGHSA Advisory· Published May 8, 2026· Updated May 13, 2026
CVE-2026-41889
CVE-2026-41889
Description
pgx is a PostgreSQL driver and toolkit for Go. Prior to version 5.9.2, SQL injection can occur when the non-default simple protocol is used, a dollar quoted string literal is used in the SQL query, that string literal contains text that would be would be interpreted as a placeholder outside of a string literal, and the value of that placeholder is controllable by the attacker. This issue has been patched in version 5.9.2.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/jackc/pgx/v5Go | < 5.9.2 | 5.9.2 |
github.com/jackc/pgx/v4Go | <= 4.18.3 | — |
github.com/jackc/pgxGo | <= 3.6.2 | — |
Affected products
1Patches
160644f84918aFix SQL sanitizer bugs with dollar-quoted strings and placeholder overflow
4 files changed · +185 −8
internal/sanitize/sanitize_fuzz_test.go+17 −0 modified@@ -73,6 +73,23 @@ func FuzzNewQuery(f *testing.F) { f.Add("E'esc' 'norm' \"ident\" $1 -- comment\n$2 /* block */ $3") f.Add("$1'$2'$3\"$4\"$5--$6\n$7/*$8*/$9") + // Dollar-quoted strings (PostgreSQL $[tag]$...$[tag]$). Placeholders + // inside must NOT be substituted. + f.Add("select $$hello $1 world$$, $1") + f.Add("select $tag$body $1 body$tag$, $2") + f.Add("select $a$ $b$ $a$") // mismatched inner tag + f.Add("select $outer$ $$ $1 $$ $outer$") // nested inner anonymous quote + f.Add("$$unterminated $1") // unterminated $$ run + f.Add("$t$unterminated") // unterminated tagged run + f.Add("$ $1") // $ alone is not a dollar-quote open + f.Add("$abc no closing quote $1") + f.Add("$1abc$") // $1 is placeholder, abc$ is raw + f.Add("$日本$body$日本$") // non-ASCII tag + + // Large placeholder numbers that would overflow naive int accumulation. + f.Add("$92233720368547758070") + f.Add("$999999999999999999999999999999999999999") + f.Fuzz(func(t *testing.T, input string) { query, err := sanitize.NewQuery(input) if err != nil {
internal/sanitize/sanitize.go+89 −8 modified@@ -4,6 +4,7 @@ import ( "bytes" "encoding/hex" "fmt" + "math" "slices" "strconv" "strings" @@ -202,12 +203,13 @@ func QuoteBytes(dst, buf []byte) []byte { } type sqlLexer struct { - src string - start int - pos int - nested int // multiline comment nesting level. - stateFn stateFn - parts []Part + src string + start int + pos int + nested int // multiline comment nesting level. + dollarTag string // active tag while inside a dollar-quoted string (may be empty for $$). + stateFn stateFn + parts []Part } type stateFn func(*sqlLexer) stateFn @@ -237,6 +239,15 @@ func rawState(l *sqlLexer) stateFn { l.start = l.pos return placeholderState } + // PostgreSQL dollar-quoted string: $[tag]$...$[tag]$. The $ was + // just consumed; try to match the rest of the opening tag. + // Without this, placeholders embedded inside dollar-quoted + // literals would be incorrectly substituted. + if tagLen, ok := scanDollarQuoteTag(l.src[l.pos:]); ok { + l.dollarTag = l.src[l.pos : l.pos+tagLen] + l.pos += tagLen + 1 // advance past tag and closing '$' + return dollarQuoteState + } case '-': nextRune, width := utf8.DecodeRuneInString(l.src[l.pos:]) if nextRune == '-' { @@ -319,8 +330,16 @@ func placeholderState(l *sqlLexer) stateFn { l.pos += width if '0' <= r && r <= '9' { - num *= 10 - num += int(r - '0') + // Clamp rather than silently wrap on pathological input like + // "$92233720368547758070" which would otherwise overflow int and + // could land on a valid args index. Any value above MaxInt32 far + // exceeds any plausible args length, so Sanitize will correctly + // return "insufficient arguments". + if num > (math.MaxInt32-9)/10 { + num = math.MaxInt32 + } else { + num = num*10 + int(r-'0') + } } else { l.parts = append(l.parts, num) l.pos -= width @@ -330,6 +349,68 @@ func placeholderState(l *sqlLexer) stateFn { } } +// dollarQuoteState consumes the body of a PostgreSQL dollar-quoted string +// ($[tag]$...$[tag]$). The opening tag (including its terminating '$') has +// already been consumed. +func dollarQuoteState(l *sqlLexer) stateFn { + closer := "$" + l.dollarTag + "$" + idx := strings.Index(l.src[l.pos:], closer) + if idx < 0 { + // Unterminated — mirror the behavior of other quoted-string states by + // consuming the remaining input into the current part and stopping. + if len(l.src)-l.start > 0 { + l.parts = append(l.parts, l.src[l.start:]) + l.start = len(l.src) + } + l.pos = len(l.src) + return nil + } + l.pos += idx + len(closer) + l.dollarTag = "" + return rawState +} + +// scanDollarQuoteTag checks whether src begins with an optional dollar-quoted +// string tag followed by a closing '$'. src must point just past the opening +// '$'. Returns the byte length of the tag (zero for an anonymous $$) and +// whether a valid tag was found. +// +// Tag grammar matches the PostgreSQL lexer (scan.l): +// +// dolq_start: [A-Za-z_\x80-\xff] +// dolq_cont: [A-Za-z0-9_\x80-\xff] +func scanDollarQuoteTag(src string) (int, bool) { + first := true + for i := 0; i < len(src); { + r, w := utf8.DecodeRuneInString(src[i:]) + if r == '$' { + return i, true + } + if !isDollarTagRune(r, first) { + return 0, false + } + first = false + i += w + } + return 0, false +} + +func isDollarTagRune(r rune, first bool) bool { + switch { + case r == '_': + return true + case 'a' <= r && r <= 'z': + return true + case 'A' <= r && r <= 'Z': + return true + case !first && '0' <= r && r <= '9': + return true + case r >= 0x80 && r != utf8.RuneError: + return true + } + return false +} + func escapeStringState(l *sqlLexer) stateFn { for { r, width := utf8.DecodeRuneInString(l.src[l.pos:])
internal/sanitize/sanitize_test.go+56 −0 modified@@ -2,6 +2,7 @@ package sanitize_test import ( "encoding/hex" + "math" "strings" "testing" "time" @@ -100,6 +101,54 @@ func TestNewQuery(t *testing.T) { sql: "select 'hello world", expected: sanitize.Query{Parts: []sanitize.Part{"select 'hello world"}}, }, + { + // Dollar-quoted string (anonymous) must not be treated as placeholders. + sql: "select $$hello $1 world$$, $1", + expected: sanitize.Query{Parts: []sanitize.Part{"select $$hello $1 world$$, ", 1}}, + }, + { + // Dollar-quoted string with tag. + sql: "select $tag$hello $1 world$tag$, $2", + expected: sanitize.Query{Parts: []sanitize.Part{"select $tag$hello $1 world$tag$, ", 2}}, + }, + { + // Dollar-quoted string with tag containing digits. + sql: "select $t1$body$2$t1$, $3", + expected: sanitize.Query{Parts: []sanitize.Part{"select $t1$body$2$t1$, ", 3}}, + }, + { + // Dollar-quoted string may contain nested $$ sequences that don't match the outer tag. + sql: "select $outer$ $$ still inside $1 $$ $outer$, $1", + expected: sanitize.Query{Parts: []sanitize.Part{"select $outer$ $$ still inside $1 $$ $outer$, ", 1}}, + }, + { + // Unterminated dollar-quoted string: consume the rest of input. + sql: "select $$hello $1 world", + expected: sanitize.Query{Parts: []sanitize.Part{"select $$hello $1 world"}}, + }, + { + // $digit is still a placeholder, not a dollar-quote open. + sql: "select $1 $2", + expected: sanitize.Query{Parts: []sanitize.Part{"select ", 1, " ", 2}}, + }, + { + // Dollar sign not followed by identifier/$/digit is literal. + sql: "select $ + $1", + expected: sanitize.Query{Parts: []sanitize.Part{"select $ + ", 1}}, + }, + { + // Dollar followed by a non-tag identifier that never meets a closing $ is not a dollar-quoted string. + sql: "select $abc + $1", + expected: sanitize.Query{Parts: []sanitize.Part{"select $abc + ", 1}}, + }, + { + // Overflow-sized placeholder number must not wrap: it should be + // preserved as some value that Sanitize will reject with + // "insufficient arguments" rather than silently wrapping to a + // small/negative index that aliases a real argument. + sql: "select $92233720368547758070", + expected: sanitize.Query{Parts: []sanitize.Part{"select ", math.MaxInt32}}, + }, } for i, tt := range successTests { @@ -220,6 +269,13 @@ func TestQuerySanitize(t *testing.T) { args: []any{42}, expected: `invalid arg type: int`, }, + { + // An overflow-clamped placeholder must not silently map onto a + // real argument; it must produce an error. + query: sanitize.Query{Parts: []sanitize.Part{"select ", math.MaxInt32}}, + args: []any{int64(42)}, + expected: `insufficient arguments`, + }, } for i, tt := range errorTests {
query_test.go+23 −0 modified@@ -2253,6 +2253,29 @@ func TestQueryWithProcedureParametersInAndOut(t *testing.T) { }) } +func TestConnQuerySanitizeSQLWithDollarQuotesStrings(t *testing.T) { + t.Parallel() + + conn := mustConnectString(t, os.Getenv("PGX_TEST_DATABASE")) + defer closeConn(t, conn) + + ctx := t.Context() + + tx, err := conn.Begin(ctx) + require.NoError(t, err) + defer tx.Rollback(ctx) + + _, err = tx.Exec(ctx, `create table canary(id text primary key)`) + require.NoError(t, err) + + attackValue := `$tag$; drop table canary; --` + _, err = tx.Exec(ctx, `select $tag$ $1 $tag$, $1`, pgx.QueryExecModeSimpleProtocol, attackValue) + require.NoError(t, err) + + _, err = tx.Exec(ctx, `select * from canary`) + require.NoError(t, err) +} + type byteCounterConn struct { conn net.Conn bytesRead int
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5News mentions
1- Patch Tuesday - May 2026Rapid7 Blog · May 13, 2026