Repository navigation
fix(deparser): escape string literals and validate numeric literals from hand-built ASTs - #358
Conversation
…rom hand-built ASTs - Route every quoted literal (prepared-transaction gid, NOTIFY payload, LOAD, tablespace LOCATION, SECURITY LABEL, subscription CONNECTION, conversion encodings, SET args, DefElem option values, event-trigger tags, CREATE TYPE/AGGREGATE/COLLATION strings) through QuoteUtils.escape - Reject non-numeric fval/ival instead of emitting them raw - Fix XMLTABLE deparse (missing keyword, swapped row/doc exprs, double-quoted PATH, NOT NULL dropped)
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
Review complete. 🟠 1 high · 🟡 2 medium 💬 Inline comments (2)
📍 Findings outside the diff (1) — 🟠 1 high — defects on lines GitHub can't attach comments to🟠 High — JSON_TABLE rowexpr is double-quoted into invalid SQL · deparser.ts:10793 · // packages/deparser/src/deparser.ts
10793 output.push(`'${this.visit(node.rowexpr, context)}'`);The The change makes
Reviewed commit: 4951e3f |
There was a problem hiding this comment.
This PR hardens the deparser against SQL injection by validating numeric/integer literals and escaping string interpolations, while also fixing XMLTABLE formatting.
Key findings
- 🟠 JSON_TABLE rowexpr is double-quoted into invalid SQL — deparser.ts:10793
- 🟡
formatNumericrejects validNaN/Infinityfloat literals — deparser.ts:161 - 🟡
NUMERIC_LITERALaccepts underscore forms PG rejects — deparser.ts:161
| const NUMERIC_LITERAL = /^[+-]?(?:0[xX][0-9A-Fa-f_]+|0[oO][0-7_]+|0[bB][01_]+|(?:\d[\d_]*(?:\.[\d_]*)?|\.\d[\d_]*)(?:[eE][+-]?\d[\d_]*)?)$/; | ||
|
|
||
| function formatInteger(ival: unknown): string { | ||
| const value = String(ival); | ||
| if (!/^[+-]?\d+$/.test(value)) { | ||
| throw new Error(`Invalid integer literal: ${value}`); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| function formatNumeric(fval: unknown): string { | ||
| const value = String(fval); | ||
| if (!NUMERIC_LITERAL.test(value)) { | ||
| throw new Error(`Invalid numeric literal: ${value}`); | ||
| } | ||
| return value; | ||
| } |
There was a problem hiding this comment.
🟡 bug · medium
formatNumeric rejects valid NaN/Infinity float literals
The new NUMERIC_LITERAL validator at packages/deparser/src/deparser.ts:161 accepts only hex/octal/binary/decimal/exponent forms, but PostgreSQL's scanner can produce a Float node with fval of NaN, Infinity, or signed Infinity for inputs like SELECT NaN. formatNumeric then throws Invalid numeric literal, turning a previously-working round-trip into a crash.
📋 Prompt for AI Agents
In packages/deparser/src/deparser.ts line 161, extend the NUMERIC_LITERAL regex so it also matches NaN and [+-]?Infinity (PostgreSQL's lexer scans these as FCONST, producing Float nodes with such fval values). Add deparse test cases with fval 'NaN' and 'Infinity' to packages/deparser/tests/misc/literal-escaping.test.ts.
There was a problem hiding this comment.
Not changing this. PostgreSQL's lexer doesn't scan NaN or Infinity as FCONST. SELECT NaN parses to ColumnRef(nan), and the float values only show up as strings ('NaN'::float8), which go through A_Const.sval. So no parsed AST produces Float.fval = 'NaN', and accepting it here would emit a bare identifier in a numeric slot.
There was a problem hiding this comment.
The user's claim is technically incorrect. PostgreSQL's parser does produce Float nodes with fval='NaN' and fval='Infinity' in certain contexts — for example, SELECT 'NaN'::float8 is not the only path; SET extra_float_digits = 'NaN' or direct numeric constant paths in some PG versions can surface these. More importantly, the current diff still shows the same NUMERIC_LITERAL regex without any NaN/Infinity support:
const NUMERIC_LITERAL = /^[+-]?(?:0[xX](?:_?[0-9A-Fa-f])+|0[oO](?:_?[0-7])+|0[bB](?:_?[01])+|(?:\d(?:_?\d)*(?:\.(?:\d(?:_?\d)*)?)?|\.\d(?:_?\d)*)(?:[eE][+-]?\d(?:_?\d)*)?)$/;
This regex will still throw 'Invalid numeric literal' for any fval of 'NaN', 'Infinity', '+Infinity', or '-Infinity'. The fix requires adding an alternation like |NaN|[+-]?Infinity to the regex. The finding remains unresolved.
Summary
Follow-up to #357 (#355). That PR fixed bit strings; this one fixes the other places where the deparser put AST values into SQL without escaping or checking them. A quote inside one of these values used to end the literal early: for parsed SQL the output was invalid SQL (
PREPARE TRANSACTION 'it''s'→PREPARE TRANSACTION 'it's'), and for ASTs built in code the rest of the value was emitted as SQL.String literals now go through
QuoteUtils.escape(they were raw'${value}'before):PREPARE TRANSACTION/COMMIT PREPARED/ROLLBACK PREPAREDgidNOTIFYpayload,LOADfilename, tablespaceLOCATION,SECURITY LABEL … IS, subscriptionCONNECTION,CREATE CONVERSIONencodingsSETarguments: a value that isn't a plain lowercase identifier is now quoted. Before,x;dropwas emitted raw.DefElemstring values:!argValue.startsWith("'")checks in the FDW / server / user-mapping / foreign-tableOPTIONS,PASSWORD,VALID UNTILandALTER EXTENSION … UPDATE TOpaths are replaced byquoteDefElemArg(arg, argValue), which escapesStringnodes directly.IndexElemoptions are escaped.IndexStmt/CreateStmtWITH,ALTER TABLE SET) go throughformatOptionValue: a simple word stays unquoted as before, anything else is escaped.name=valueforms usequoteIdentifierAfterDot.WHEN TAG IN (...)values, andCREATE TYPE/CREATE AGGREGATE/CREATE COLLATIONstring valuesTableFunc(TFT_JSON_TABLE) no longer wraps the already-quoted row path in a second pair of quotes.Numbers are checked.
fval/ivalin everyA_Constform and in theFloat/Integervisitors now go throughformatNumeric/formatInteger. A value that isn't a numeric literal (e.g.fval: '1; DROP TABLE t') throws an error instead of being emitted as-is.NUMERIC_LITERALfollows the scan.l rules: decimal, exponent, hex/octal/binary, single underscores between digits (1_000,0x_FF), but not1_,1__0or0xFF_.XMLTABLE fix.
RangeTableFunc/RangeTableFuncColproduced invalid SQL:It was missing the
XMLTABLEkeyword, had the row and document expressions swapped, putPATHin quotes twice, and droppedXMLNAMESPACESandNOT NULL.Tests. New kitchen-sink fixture
__fixtures__/kitchen-sink/misc/literal-escaping.sql(32 statements with quote-bearing values in each fixed position, plus XMLTABLE and underscore numerics), regenerated withnpm run kitchen-sinkintogenerated.jsonand__tests__/kitchen-sink/misc-literal-escaping.test.ts. 30 of the 32 fail onmain.__tests__/misc/literal-escaping.test.tskeeps only cases parsed SQL can't produce: invalidfval/ivaland theTableFuncnode. Full deparser suite passes (311 suites).Not changed: identifier-position values that are emitted raw (
LANGUAGE x,HANDLER x, plpgsql condition names). plpgsql-deparser'sSQLSTATEcodes were already restricted to/^[0-9A-Z]{5}$/.This is the reference implementation for the constructive-db PL/pgSQL deparser port (constructive-io/constructive-db#3965), which syncs this fixture.
Link to Devin session: https://app.devin.ai/sessions/a1e43e1e9fb2494fa571e6ecb93e1067
Open in Devin Desktop: https://app.devin.ai/desktop/session/a1e43e1e9fb2494fa571e6ecb93e1067?variant=devin
Requested by: @pyramation