Repository navigation
[2.1.0] Security audit: fix backend crashes, string-literal breakout, and transpiler DoS - #12
OffgridwithJD wants to merge 4 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>
jdatcmd
left a comment
There was a problem hiding this comment.
Adversarial review. Tested on PostgreSQL 18.4: built this branch and ran make installcheck (13/13 pass, no compiler warnings). The new plxjs annotation test, run against a master build, crashes the backend, so it does reproduce the bug.
CI has not run on this PR yet (fork run waiting for approval), so PostgreSQL 13 to 17 are untested. That matters here because the hash_create call is version-dependent.
Must fix
1. The string-literal breakout is still open
The PR and CHANGELOG say "Literal backslashes are now always escaped". That is not true. These are still emitted as plain '...' literals with a bare backslash:
- Ruby and PHP single-quoted (raw) strings
- plxgo strings
- plxtsql strings
plpgsql reads a function body with the session's standard_conforming_strings, which any role can set. With it off, the backslash escapes the closing quote. Repro:
CREATE FUNCTION go1() RETURNS text LANGUAGE plxgo AS $P$
a := "C:\\"
b := "; RAISE NOTICE $$go: injected code ran$$; --"
return a
$P$;
SET standard_conforming_strings = off;
SELECT go1();
-- NOTICE: go: injected code ranplxtsql (DECLARE @a varchar(10) = 'C:\';), plxruby and plxphp (a = 'C:\\') break out of the quote the same way. The caller controls this setting, so it can change what a SECURITY DEFINER function does. The bug is older than this PR, but it is the class of bug this PR says it closes.
Suggested fix: in every emitter, whenever a literal contains a backslash, emit E'...' and double the backslash. That form does not depend on standard_conforming_strings. Add a regression test that sets the GUC off.
2. The lexer fix can be bypassed with annotation lines
A line starting with #:: sets held, but the scan still runs to the next real line from every newline. It is still quadratic:
| body | time |
|---|---|
x = 1 + 20,000 lines of #:: int |
1.3 s |
x = 1 + 40,000 lines of #:: int |
5.1 s |
3. The locals hash can be bypassed with one long name
A name of 128 or more characters destroys the hash for the rest of the body, which brings back the linear scan:
| plxruby body | time |
|---|---|
| 20,000 locals | 52 ms |
| one 131-char name, then 10,000 locals | 0.9 s |
| one 131-char name, then 20,000 locals | 5.4 s |
PostgreSQL truncates identifiers at 63 bytes anyway, so a key truncated to that length plus a collision chain (or a full-name compare on hit) avoids the fallback.
Findings 2 and 3 do respect statement_timeout now. Both stopped cleanly at 3 s, so they cost CPU time but cannot lock up a backend.
Should fix
4. The finally cap limits depth, not output size
At the 8-level cap, the expansion is about 740 times the source. A 195 KB plxjs body stores a 144 MB prosrc (1.4 s). The PR table reads as if this were bounded. Please either say in the PR and CHANGELOG that the output still scales at about 2^8 times the source, or add a cap on output size.
5. Test gaps
- No test covers the two quadratic fixes or the ternary memory fix. A coarse check would do, for example a body that took tens of seconds before, run under a short
statement_timeout. - The nesting tests use 2,000 levels, so they hit the 500-level counter and never exercise
check_stack_depth().
Minor
plx_local_keylower-cases withpg_ascii_tolower, but the fallback scan usespg_strncasecmp, which also folds high-bit characters. In a single-byte encoding the two paths can disagree on whether two names match.
…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>
|
Thanks for the review. Every point is addressed in 2717c2f. Must fix
Should fix |
jdatcmd
left a comment
There was a problem hiding this comment.
Re-review of 2717c2f on PostgreSQL 18.4. It builds without warnings, make installcheck passes 13/13 and make differentialcheck passes.
Fixed
| Finding | Before | Now |
|---|---|---|
Backslash breakout with standard_conforming_strings = off |
injected code ran from plxgo | plxgo, plxtsql ('...' and N'...'), plxruby, plxphp and plxplsql '...' all become E'C:\\' and return C:\ |
#:: annotation lines |
40,000 lines: 5.1 s | 6 ms |
| Long name disabling the locals hash | long name + 20,000 locals: 5.4 s | 35 ms |
| Tests for the above | none | added |
The plxplsql check included an Oracle-style v$path identifier, which the new lexer correctly does not treat as a dollar quote.
Still open: N'...' literals in plxplsql
escape_backslash_literals skips any literal with a letter prefix (prefixed), so a national-character literal is left alone. Under standard_conforming_strings = off, an N'...' literal treats a backslash exactly like a plain '...' literal does. plxplsql passes Oracle N'...' literals through, and NVARCHAR2 code uses them routinely:
CREATE FUNCTION plsn2() RETURNS text LANGUAGE plxplsql AS $P$
DECLARE
a NVARCHAR2(100);
b NVARCHAR2(100);
BEGIN
a := N'C:\';
b := N'; RAISE NOTICE $$plsql N-string: injected code ran as %$$, current_user; --';
RETURN a;
END;
$P$;
SET standard_conforming_strings = off;
SELECT plsn2();
-- NOTICE: plsql N-string: injected code ran as postgres(plxtsql is not affected: its emitter drops the N prefix.)
Suggested fix: treat an N or n prefix like no prefix, and rewrite N'C:\' as E'C:\\' (the national-character form only implies the type char, which the assigned variable's type overrides anyway), or keep it as N followed by an E-safe form. Add N'...' to the standard_conforming_strings test.
Checked and not a problem: a standalone #:: line before a dedented statement fails with unsupported statement, but master fails the same way.
"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>
Summary
Fixes from a security audit of the transpiler. Every plx language is
TRUSTED, so by default any role can send arbitrary input to this C code throughCREATE FUNCTIONorDO. Each issue below was reproduced against PostgreSQL 18.4 before fixing, and each fix has a regression test.Code-only: no SQL objects change, so this needs no
ALTER EXTENSION.Backend crashes (any role, including from a
DOblock)These kill the backend with SIGSEGV, so the postmaster resets every session on the server.
/*:: T */or#:: Tas the first token of a condition, call argument or statement madeplx_span_textcopy from a NULL pointer. Affected plxjs, plxts, plxphp, plxruby and plxpython3. Repro:DO LANGUAGE plxjs $q$ assert(/*:: int */ 1 > 0); $q$;ISNULL(ISNULL(...))orint(int(...))about 50,000 levels deep overflowed the C stack. These emitters now usecheck_stack_depth()plus thePLX_MAX_DEPTHcounter, matching the shared rewriter.Generated code that does not match its source
E'...', where it escaped the closing quote. The next string literal's text then ran as code. Source that shows only inert assignments could therefore runset_config(...)or any other SQL, which matters forSECURITY DEFINERfunctions and for code review.standard_conforming_strings(found in review). Literals emitted by plxgo, plxtsql, plxplsql and plxcobol, and Ruby/PHP single-quoted strings, were plain'...'. 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 now rewrites every ordinary literal containing a backslash asE'...'with the backslash doubled. That reads the same under either setting and keeps the value with it on. It covers every dialect and SQL passed through verbatim.fmt.Sprintfdirective without a letter verb.%'consumed half of an escaped'', so Go string text could become SQL. Such directives now fall back to theformat()lowering.Resource exhaustion (memory / CPU, uninterruptible)
prosrc; 24 levels drove a backend to 2.3 GBstatement_timeoutdowncase_truncate_identifier)statement_timeout#::lines take 0.02 sCHECK_FOR_INTERRUPTS()now runs in the lexer, the rewriter and the local table.plx_emit_try.Behaviour fixes that fell out of the above
finally/ensurebody that raised on the normal path ran a second time from the error path. It now runs once.call(f"...")emitted an unterminated identifier. It is now rejected.Not in this PR (need a design decision)
/*plx:v1:header is stored without transpiling. Even without that, the plx languages are a syntax layer over plpgsql. AfterREVOKE USAGE ON LANGUAGE plpgsql FROM PUBLIC,DO LANGUAGE plxplsqlstill works. This is documented in the docs PR; a code-level check is a separate decision.execute("... #{x}"), andfetch_one/queryonce a bind argument is added, concatenate the value without quoting, so data can inject SQL. This is documented as the author's responsibility, but the inconsistency is a trap.plx_strbuild/plx_sb_appendin generated code. They resolve throughsearch_pathat run time, which matters forSECURITY DEFINER. The extension is relocatable, so this can't be schema-qualified without changing that.finallydoesn't run onreturn/break/continue. Fixing it requires emitting the body once.Review follow-up
Addressed in 2717c2f:
standard_conforming_stringsbreakout (above)standard_conforming_stringsoff across six dialectsmax_stack_depth = 100kB, socheck_stack_depth()itself is exercisedNot covered by a test: the ternary memory fix. Its effect is peak memory, which pg_regress can't measure.
Testing
make installcheck: all 13 suites pass on PG 18.4. CI covers 13 to 18.make differentialcheckpasses.test/fuzz.py(500 iterations): no crashes.🤖 Generated with Claude Code