Repository navigation
[3.0.0] Bind interpolated values in SQL arguments; add ident(), literal(), raw() - #19
Open
OffgridwithJD wants to merge 7 commits into
Open
OffgridwithJD wants to merge 7 commits into
OffgridwithJD wants to merge 7 commits into
Conversation
Every plx language is trusted, so any role with USAGE can send arbitrary input to the transpiler through CREATE FUNCTION or DO. - plx_span_text: skip type-annotation tokens (which carry no source span) at the edges of a span. A span beginning on one computed its length from a NULL pointer and crashed the backend (plxjs/ts/php/ruby/python3). - plxtsql, plxgo, plxcobol: guard the expression emitters with check_stack_depth() and the PLX_MAX_DEPTH counter. Deeply nested calls, conversions or subscripts overflowed the C stack (SIGSEGV). - finally/ensure: cap finally-inside-finally nesting at 8 levels; each level doubled the generated body, so a few hundred bytes of source could demand gigabytes. The three copies of the try lowering are now one helper, plx_emit_try, which also runs the normal-path finally body after the protected block, so a finally that raises no longer runs twice. - plxgo fmt.Sprintf: require a letter verb before lowering to a || chain; a '%' followed by a quote consumed half of an escaped '' pair and let Go string text escape into the generated SQL. Regression tests cover each case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…growth Second round of the security audit (shared transpiler core): - emit_string_value: escape every literal backslash and force E''. A string ending in a lone backslash inside an interpolation was emitted bare in an E'' literal, escaping its closing quote so the next literal's text ran as code. The synthetic-string scanners no longer step past the end of their input on a trailing backslash. - Locals: index by lower-cased name in a hash table beside the ordered list; the linear lookup made many distinct variables quadratic. - plxpython3 lexer: resume after the blank/comment lines the indentation scan already covered, instead of rescanning the run from every newline (O(K^2), and line numbers were counted twice). - CHECK_FOR_INTERRUPTS in the lexer loop, the expression rewriter and the local table, so statement_timeout and cancel reach a long transpile. - Ternary rewrite: free the condition and both arms after composing the CASE, so a chain of N ternaries no longer holds N copies of its tail. - plxpython3 call(f"..."): reject an f-string procedure name, which was unquoted with its prefix into a broken identifier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d tests From the adversarial review of this PR: - standard_conforming_strings: literals emitted by plxgo, plxtsql, plxplsql and plxcobol, and Ruby/PHP single-quoted strings, were plain '...' literals. plpgsql reads the body with the caller's setting, so with it off a trailing backslash swallowed the closing quote and the next literal ran as code. A final pass over the generated body (a small SQL lexer that skips comments, quoted identifiers, dollar quotes, E'' and U&'' strings) now rewrites every ordinary literal containing a backslash as E'...' with the backslash doubled. One place covers every dialect and future emitters. - plxpython3 lexer: a #:: annotation line ended the blank-line scan's resume point but not the scan, so runs of annotation lines were still quadratic. The scan now stops at an annotation line. - Locals index: a name of 128+ bytes disabled the hash and brought back the linear scan. Keys are now downcase_truncate_identifier(), as the server reads an unquoted identifier, so every name is indexed and case folding matches pg_strncasecmp/the server for all encodings; the fallback is gone. - Tests: standard_conforming_strings off across six dialects; timeouts around 60,000 locals after an over-long name, 400,000 blank lines and 40,000 annotation lines; and a nesting depth under the 500-level counter with max_stack_depth = 100kB, which exercises check_stack_depth(). - CHANGELOG: the finally cap bounds depth, not output size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An interpolation in the SQL argument of query/fetch_one/execute/
open_cursor/return_query/perform was concatenated into dynamic SQL
unquoted, so data could inject SQL, and adding a bind argument to an
otherwise static (safe) call switched it to that form silently. In
static SQL, '#{x}' produced the literal text 'x'.
The SQL argument, when it is a string literal, is now treated as a
template (plx_sql_arg, used by every call site, including the query
loops in the brace, Ruby and Python front ends):
- a value outside quotes: the plpgsql expression (static) or a $n
parameter passed with USING, numbered after the call's own binds
(dynamic);
- '#{x}': the value alone; inside a longer literal the literal is
parenthesized and the value concatenated (E'' literals stay E'');
- in a statement that takes no $n parameters (anything but SELECT,
INSERT, UPDATE, DELETE, MERGE, WITH, VALUES, TABLE, CALL, EXPLAIN),
a value is spliced through quote_nullable() instead;
- ident(e) / literal(e) / raw(e) splice quote_ident(e) /
quote_nullable(e) / e, and make the call dynamic (plxphp:
{ident($t)}, recognized only inside a SQL argument);
- an interpolation inside a quoted identifier, ident()/raw() inside a
quoted string, and a value inside a longer literal of a statement that
takes no parameters are rejected with a hint.
Regression tests round-trip hostile values (quotes, backslashes,
statement terminators, dollar quotes) through every position, in both a
planned and a utility statement and with standard_conforming_strings
off, and check the generated SQL, static '#{x}', ident() (including
plxphp), raw(), and the rejections. doc/DYNAMIC-SQL.md documents the
rules and the upgrade path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plxphp and plxpython3 dynamic-SQL recipes interpolated a table name
directly ({$tbl}, {tbl}); under the new rules that is bound as a value
and the statement fails. Use {ident($tbl)} / {ident(tbl)}, and say why.
Every other runnable example in the README, USERGUIDE, MIGRATION,
DEBUGGING and the cookbooks runs as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"let x /*:: int */ = 0;" drops the "= 0" (commandprompt#20 makes that form an error). The test's result does not depend on it, but it should use the documented form so it keeps passing once commandprompt#20 is merged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Depends on #12. This branch is stacked on #12, so until #12 merges the diff also shows its three commits. Only the last two belong here. This is a breaking change, intended for 3.0.
Problem
An interpolation in the SQL argument of
query/fetch_one/execute/open_cursor/return_query/performwas lowered three different ways:'#{x}'silently became the literal text'x'.execute, or any call with a bind argument): the value was concatenated unquoted. Data could inject SQL, and adding a$1to a safe call silently switched it to this form. The core audit's repro:fetch_one("... WHERE name = '#{uname}' LIMIT $1", lim)withuname = "nobody' OR name = 'bob"returned bob.Change
A string-literal SQL argument is now a template:
id = #{n}id = n(unchanged)id = $k, passed withUSINGafter the call's own binds'#{s}'(s)($k)'%#{s}%'('%' || (s)::text || '%')('%' || ($k)::text || '%')(anE''literal staysE'')"#{t}"Utility statements: only planned statements take
$nparameters (SELECT, INSERT, UPDATE, DELETE, MERGE, WITH, VALUES, TABLE, CALL, EXPLAIN; checked on 18.4). In any other dynamic statement (CREATE,ALTER,SET,COMMENT,GRANT, ...), a value is spliced throughquote_nullable(), so it's still quoted.Explicit helpers may wrap an interpolation (plxphp spells them
{ident($t)},{literal($v)},{raw($s)}, recognized only inside a SQL argument). Each one makes the call dynamic:ident(e)→quote_ident(e), for table and column namesliteral(e)→quote_nullable(e), a quoted literal in any statementraw(e)→eunquoted, for a trusted fragment such as a sort orderSo data never becomes SQL unless the author writes
raw().Rejected with a hint:
"..."ident()/literal()/raw()inside a quoted stringNot covered (documented): a string built elsewhere and passed in a variable is ordinary concatenation, because the call can't see the interpolation.
Implementation: one helper,
plx_sql_arg, now backs every call site, including the query loops in the brace, Ruby and Python front ends, which each had their own copy of the static/dynamic switch.Breaking
A body that interpolated an identifier (
FROM #{t}) or a SQL fragment on purpose fails when re-created (syntax error at or near "$1") until it usesident()orraw(). Stored functions are unaffected until their DDL is re-run. New page:doc/DYNAMIC-SQL.md, added to the mkdocs nav.Two cookbook recipes interpolated a table name and are updated to use
ident(). I re-ran every runnable example in the README, USERGUIDE, MIGRATION, DEBUGGING and all cookbooks against this build: no other new errors. Once this merges, #13's injection paragraphs (USERGUIDE "Trust and security") should point to the new page.Testing
plx_featurescases:'); DROP TABLE ...; --,$$-quotedRAISE) round-trip exactly through every position, in a planned and in a utility statement (COMMENT), and again withstandard_conforming_strings = off. The table survives.'#{x}'.ident(), including plxphp{ident($t)}.raw().','',",E',\,$$, comments and all four interpolation kinds across the call forms. No crashes or internal errors.make installcheck(13/13) andmake differentialcheckpass on PG 18.4.test/fuzz.pyand the annotation fuzzer: no crashes.🤖 Generated with Claude Code