VYPR
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.

PackageAffected versionsPatched versions
github.com/jackc/pgx/v5Go
< 5.9.25.9.2
github.com/jackc/pgx/v4Go
<= 4.18.3
github.com/jackc/pgxGo
<= 3.6.2

Affected products

1

Patches

1
60644f84918a

Fix SQL sanitizer bugs with dollar-quoted strings and placeholder overflow

https://github.com/jackc/pgxJack ChristensenApr 18, 2026via ghsa
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

5

News mentions

1