fix(security): reject ambiguous backslash-escape in import/export query guard

_is_query_parens_balanced() always treated \' inside a single-quoted
string as an escaped quote, matching psql only when
standard_conforming_strings=off. Under the actual default (scs=on,
every supported PostgreSQL version), psql treats \ as a literal
character, so 'a\' closes the string right there. A crafted query
export payload like SELECT 'a\') TO PROGRAM 'cmd' x' passed the
balance check while the real ) it hid closed the wrapping \copy (...)
context in psql, exposing a live TO PROGRAM clause for RCE.

Since the correct interpretation depends on a server setting we
can't reliably know, reject any backslash inside a single-quoted
string outright instead of guessing.

Reported by Arpit Jain (arpitjain099).
This commit is contained in:
Ashesh Vashi committed 2026-07-27 19:04:29 +05:30
1 parent ef76102bcd
commit 1496fabe28
2 files changed
+37 -20

No files matched your search

+17 -10
View File
@@ -281,12 +281,18 @@ def _is_query_parens_balanced(query):
skip content inside those constructs either, or an attacker can
hide an unbalanced ) inside them to bypass validation.
Backslash escapes (\\x) are always handled inside single-quoted
strings. psql enables this for E'...' strings unconditionally
and for all strings when standard_conforming_strings=off. By
always handling backslashes, we match psql in all modes. This
may reject some valid queries (e.g. '\\') with scs=on) but
will never accept a dangerous one.
Backslash handling inside single-quoted strings is ambiguous:
psql treats \\ as a plain character when
standard_conforming_strings=on (the default since PG 9.1, and
the default on every PostgreSQL version pgAdmin 4 supports), but
as an escape character when scs=off or inside E'...' strings.
Silently picking one interpretation can let a crafted query be
accepted here while psql parses it differently (e.g. a \\'
that we treat as "still inside the string" while psql, under
the default scs=on, treats it as the string's closing quote,
exposing a real ')' immediately after). Rather than guess the
server's scs setting, we reject any query containing a
backslash inside a single-quoted string outright.
"""
depth = 0
i = 0
@@ -296,14 +302,15 @@ def _is_query_parens_balanced(query):
ch = query[i]
# Single-quoted string literal: skip to closing quote.
# Handles both '' and \' escape sequences to match psql's
# strtokx which enables backslash escaping for E-strings
# (always) and all strings when scs=off.
# Handles the '' escape sequence to match psql's strtokx.
# A bare backslash is rejected outright (see docstring):
# its meaning depends on standard_conforming_strings, which
# we cannot reliably know here, so we refuse to guess.
if ch == "'":
i += 1
while i < length:
if query[i] == '\\':
i += 2 # backslash escape: skip next char
return False # ambiguous escape: reject
elif query[i] == "'":
if i + 1 < length and query[i + 1] == "'":
i += 2 # '' escape
@@ -86,25 +86,35 @@ class IEQueryParensBalancedTest(BaseTestGenerator):
dict(query="SELECT ')", expected=False)),
('Rejected: unterminated double-quoted identifier',
dict(query='SELECT "col)', expected=False)),
# The parser handles backslash escapes inside single-quoted
# strings unconditionally (matching E-string and scs=off
# semantics), so the close paren is correctly identified as
# being inside the string literal. Original test labelled this
# as a "false positive" expecting over-rejection; in fact the
# parser is precise here. Keep the case to document handling.
('Balanced: E-string with escaped quote and paren inside literal',
dict(query="SELECT E'\\')'", expected=True)),
# Whether a backslash inside a single-quoted string is an
# escape character depends on standard_conforming_strings,
# which the parser cannot know. Rather than guess (and risk
# disagreeing with psql's actual parse), any backslash inside
# a single-quoted string is rejected outright.
('Rejected: backslash inside single-quoted string is ambiguous',
dict(query="SELECT E'\\')'", expected=False)),
('Unbalanced: E-string RCE bypass',
dict(query="E'\\'' ) TO PROGRAM 'cmd' --'",
expected=False)),
('Unbalanced: scs=off backslash escape bypass',
dict(query="'\\'' ) TO PROGRAM 'cmd' --'",
expected=False)),
('Balanced: backslash-backslash then close quote',
dict(query="SELECT '\\\\' FROM t", expected=True)),
('Rejected: backslash-backslash inside string is ambiguous',
dict(query="SELECT '\\\\' FROM t", expected=False)),
('Unbalanced: injection split across lines',
dict(query="SELECT 1)\nTO PROGRAM 'id' --(",
expected=False)),
# CVE regression: real psql (standard_conforming_strings=on,
# the default) treats \\ as a literal character, so 'a\\'
# closes the string literal at that second quote -- it does
# NOT escape it. The unescaped ')' immediately after is then
# live and closes the wrapping \\copy (...) context, exposing
# a TO PROGRAM clause for RCE. The old parser treated \\'
# as "still inside the string" unconditionally and missed
# this. Reported by Arpit Jain (arpitjain099), 2026-07-27.
('Unbalanced: scs=on backslash-quote RCE bypass (CVE)',
dict(query="SELECT 'a\\') TO PROGRAM 'echo pwned' x'",
expected=False)),
]
def runTest(self):