Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,76 @@ All notable changes to plx are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/), and plx uses the extension
version in `plx.control` (currently `1.0`).

## [Unreleased]

Security fixes from a code audit. Code-only: no SQL objects change, so a new
build and a server restart (or new sessions) pick them up without
`ALTER EXTENSION`. Every fix concerns input to `CREATE FUNCTION` or `DO`, which
any role with USAGE on a plx language can send (the languages are trusted, so
PUBLIC has USAGE by default).

### Security

- **Backend crash on a type annotation at the start of an expression.** A
`/*:: T */` or `#:: T` annotation as the first token of an `if`/`while`
condition, a call argument, or a statement made the transpiler copy a span
from a NULL pointer, killing the backend with SIGSEGV (and resetting every
session on the server). Affected plxjs, plxts, plxphp, plxruby, plxpython3,
including DO blocks. Annotations are now skipped at span edges.
- **Stack-overflow crash on deeply nested expressions in plxtsql, plxgo and
plxcobol.** Their expression emitters recursed once per nested call,
subscript or literal without `check_stack_depth()`, so a body like
`ISNULL(ISNULL(...))` or `int(int(...))` some 50,000 levels deep crashed the
backend. They now raise `expression nested too deeply`, like the shared
rewriter already did.
- **Exponential output from nested `finally`/`ensure`.** Each finally body is
emitted once per exit path, so a finally nested inside a finally doubled the
output per level: a 668-byte function stored a 94 MB body, and a slightly
deeper one drove a backend to gigabytes of memory. Nesting a finally inside a
finally is now limited to 8 levels. This bounds the depth, not the size: at
the limit the output can still be some 2^8 times the source (a 195 KB body
stores about 144 MB), so a large body can still demand a lot of memory until
the finally body is emitted once.
- **String literal escaping its quotes.** A dialect string that ended in a lone
backslash, inside an interpolation (plxjs/plxts `${'...\'}`, plxpython3
f-strings), was emitted with that backslash bare inside an `E'...'` literal,
where it escaped the closing quote. The next string literal in the body then
ran as code, so a function whose source showed only inert string assignments
could execute arbitrary SQL.
- **String literals that depend on `standard_conforming_strings`.** plpgsql
reads a function body with the caller's setting, which any role may change.
Literals that plxgo, plxtsql, plxplsql and plxcobol emit, and Ruby and PHP
single-quoted strings, were written as plain `'...'`, so with the setting off
a literal ending in a backslash swallowed its closing quote and the following
text ran as code; the caller could change what a `SECURITY DEFINER` function
did. The finished body is now rewritten so that every ordinary literal
containing a backslash is an `E'...'` literal with the backslash doubled,
which reads the same under either setting and, with it on, has the value the
original had. This covers every dialect, and SQL passed through verbatim.
- **Quadratic, uninterruptible transpile time.** Local-variable lookup was a
linear scan, so a body with 40,000 distinct variables took 47 seconds; the
plxpython3 lexer rescanned every run of blank lines from each newline, so
400,000 blank lines took 43 seconds. Neither honoured `statement_timeout`.
Locals are now hashed (keyed as PostgreSQL reads an identifier, down-cased
and truncated to 63 bytes, so no name defeats the index), blank and
annotation lines are scanned once, and the lexer, rewriter and local table
check for interrupts. (The same fix stops plxpython3 from
counting blank lines twice in error line numbers.)
- **Memory amplification in chained ternaries.** Each `?:` level kept its own
copy of the rest of the chain, so peak memory was depth times source length
(568 MB for a 1 MB body). Intermediate results are now freed.
- **plxgo `fmt.Sprintf` directive without a letter verb.** A `%` followed by a
quote consumed half of the escaped `''` pair, so text inside a Go string
literal could end up as SQL in the generated body. Such directives now fall
back to the `format()` lowering, which keeps the text literal.

### Fixed

- A `finally`/`ensure` body that raised an error on the normal path ran a
second time from the error path. It now runs once.
- plxpython3 `call(f"...")` emitted an unterminated quoted identifier. An
f-string procedure name is now rejected.

## [2.0.1] - 2026-08-25

Packaging only. **The extension is unchanged at 2.0.0**, so there is no
Expand Down
19 changes: 18 additions & 1 deletion src/plx_dialect_cobol.c
Original file line number Diff line number Diff line change
Expand Up @@ -442,8 +442,10 @@ cob_is_array(Cb *cb, const char *name)
* noise word, figurative constants, ** -> ^, data-name mapping, and array
* subscripts (WS-ARR(I) -> ws_arr[i]).
*/
static void cob_emit_range(Cb *cb, int a, int b, StringInfo out);

static void
cob_emit_range(Cb *cb, int a, int b, StringInfo out)
cob_emit_range_inner(Cb *cb, int a, int b, StringInfo out)
{
CbTok *t = cb->t;
int i = a;
Expand Down Expand Up @@ -621,6 +623,21 @@ cob_emit_range(Cb *cb, int a, int b, StringInfo out)
}
}

/*
* Recursion guard for the expression emitter. Nested calls, subscripts and
* literals recurse through here once per level; without check_stack_depth()
* hostile nesting overflows the C stack and crashes the backend (SIGSEGV).
*/
static void
cob_emit_range(Cb *cb, int a, int b, StringInfo out)
{
check_stack_depth();
if (++cb->cx->depth > PLX_MAX_DEPTH)
cob_err(cb, "expression nested too deeply");
cob_emit_range_inner(cb, a, b, out);
cb->cx->depth--;
}

/* true if the current token stops a value scan */
static bool
cob_value_stop(Cb *cb, const char *const *stops, int nstops)
Expand Down
58 changes: 57 additions & 1 deletion src/plx_dialect_go.c
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,36 @@ go_emit_format_str(GoTok *tk, StringInfo out)
pfree(raw.data);
}

/*
* Index of the verb of the fmt directive whose '%' is at raw->data[pct], after
* skipping flags, width and precision; -1 when that character is not an ASCII
* letter (or the directive runs into the closing quote).
*/
static int
go_sprintf_verb(StringInfo raw, int pct)
{
int j = pct + 1;

while (j + 1 < raw->len && (raw->data[j] == '-' || raw->data[j] == '+' ||
raw->data[j] == ' ' || raw->data[j] == '#' ||
raw->data[j] == '0'))
j++;
while (j + 1 < raw->len && raw->data[j] >= '0' && raw->data[j] <= '9')
j++;
if (j + 1 < raw->len && raw->data[j] == '.')
{
j++;
while (j + 1 < raw->len && raw->data[j] >= '0' && raw->data[j] <= '9')
j++;
}
if (j + 1 >= raw->len)
return -1;
if (!((raw->data[j] >= 'a' && raw->data[j] <= 'z') ||
(raw->data[j] >= 'A' && raw->data[j] <= 'Z')))
return -1;
return j;
}

/* fmt.Sprintf with a literal format string, lowered to a || chain.

* format() renders a NULL operand as the empty string and cannot be made to do
Expand Down Expand Up @@ -565,6 +595,17 @@ go_emit_sprintf_concat(Go *g, int lp, int close, StringInfo out)
i++;
continue;
}
/*
* The verb must be a letter. raw is already a SQL literal ('
* doubled), so a quote here would otherwise be consumed as the verb
* and leave half of a '' pair unescaped in the output; fall back to
* the format() path, which validates verbs, instead.
*/
if (go_sprintf_verb(&raw, i) < 0)
{
pfree(raw.data);
return false;
}
ndir++;
}
if (ndir != nargs)
Expand Down Expand Up @@ -961,7 +1002,7 @@ go_emit_call(Go *g, int i, int b, StringInfo out, int *ni)

/* emit tokens [a,b) as a plpgsql expression */
static void
go_emit_range(Go *g, int a, int b, StringInfo out)
go_emit_range_inner(Go *g, int a, int b, StringInfo out)
{
int i = a;

Expand Down Expand Up @@ -1117,6 +1158,21 @@ go_emit_range(Go *g, int a, int b, StringInfo out)
}
}

/*
* Recursion guard for the expression emitter. Nested calls, subscripts and
* literals recurse through here once per level; without check_stack_depth()
* hostile nesting overflows the C stack and crashes the backend (SIGSEGV).
*/
static void
go_emit_range(Go *g, int a, int b, StringInfo out)
{
check_stack_depth();
if (++g->cx->depth > PLX_MAX_DEPTH)
go_err(g, "expression nested too deeply");
go_emit_range_inner(g, a, b, out);
g->cx->depth--;
}

/* end of a simple statement: the next top-level ; (brackets/braces nest) */
static int
go_stmt_end(Go *g, int from)
Expand Down
41 changes: 3 additions & 38 deletions src/plx_dialect_python.c
Original file line number Diff line number Diff line change
Expand Up @@ -354,52 +354,17 @@ parse_py_try(Ctx *cx, int ind)
colon = py_header_colon(cx, cx->pos + 1);
if (colon < 0)
plx_err(cx, cx->t[cx->pos].line, "finally requires ':'");
plx_ensure_enter(cx, cx->t[cx->pos].line);
cx->pos = colon + 1;
saved = cx->out;
initStringInfo(&cx->out);
parse_py_block(cx, ind + 1);
cx->ensuredepth--;
ensure_body = cx->out.data;
cx->out = saved;
}

if (!ensure_body)
{
plx_indent(o, ind);
appendStringInfoString(o, "BEGIN\n");
appendStringInfoString(o, body);
if (nrescue)
{
plx_indent(o, ind);
appendStringInfoString(o, "EXCEPTION\n");
appendStringInfoString(o, arms.data);
}
plx_indent(o, ind);
appendStringInfoString(o, "END;\n");
}
else
{
plx_indent(o, ind);
appendStringInfoString(o, "BEGIN\n");
plx_indent(o, ind + 1);
appendStringInfoString(o, "BEGIN\n");
appendStringInfoString(o, body);
if (nrescue)
{
plx_indent(o, ind + 1);
appendStringInfoString(o, "EXCEPTION\n");
appendStringInfoString(o, arms.data);
}
plx_indent(o, ind + 1);
appendStringInfoString(o, "END;\n");
appendStringInfoString(o, ensure_body);
plx_indent(o, ind);
appendStringInfoString(o, "EXCEPTION WHEN OTHERS THEN\n");
appendStringInfoString(o, ensure_body);
plx_indent(o, ind + 1);
appendStringInfoString(o, "RAISE;\n");
plx_indent(o, ind);
appendStringInfoString(o, "END;\n");
}
plx_emit_try(o, ind, body, nrescue, arms.data, ensure_body);
}

/* raise / raise Class("msg") */
Expand Down
41 changes: 3 additions & 38 deletions src/plx_dialect_ruby.c
Original file line number Diff line number Diff line change
Expand Up @@ -484,53 +484,18 @@ parse_begin(Ctx *cx, int ind)

if (cx->t[cx->pos].kind == T_KW && cx->t[cx->pos].kw == KW_ENSURE)
{
plx_ensure_enter(cx, cx->t[cx->pos].line);
cx->pos++;
ensure_body = capture_block(cx, ind + 1);
cx->ensuredepth--;
}

if (cx->t[cx->pos].kind == T_KW && cx->t[cx->pos].kw == KW_END)
cx->pos++;
else
plx_err(cx, bl, "unterminated begin block");

if (!ensure_body)
{
plx_indent(o, ind);
appendStringInfoString(o, "BEGIN\n");
appendStringInfoString(o, body);
if (nrescue)
{
plx_indent(o, ind);
appendStringInfoString(o, "EXCEPTION\n");
appendStringInfoString(o, arms.data);
}
plx_indent(o, ind);
appendStringInfoString(o, "END;\n");
}
else
{
plx_indent(o, ind);
appendStringInfoString(o, "BEGIN\n");
plx_indent(o, ind + 1);
appendStringInfoString(o, "BEGIN\n");
appendStringInfoString(o, body);
if (nrescue)
{
plx_indent(o, ind + 1);
appendStringInfoString(o, "EXCEPTION\n");
appendStringInfoString(o, arms.data);
}
plx_indent(o, ind + 1);
appendStringInfoString(o, "END;\n");
appendStringInfoString(o, ensure_body);
plx_indent(o, ind);
appendStringInfoString(o, "EXCEPTION WHEN OTHERS THEN\n");
appendStringInfoString(o, ensure_body);
plx_indent(o, ind + 1);
appendStringInfoString(o, "RAISE;\n");
plx_indent(o, ind);
appendStringInfoString(o, "END;\n");
}
plx_emit_try(o, ind, body, nrescue, arms.data, ensure_body);
}

/* rewrite a comma-separated value list (for CASE WHEN v1, v2) */
Expand Down
19 changes: 17 additions & 2 deletions src/plx_dialect_tsql.c
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,7 @@ tq_emit_call(Tq *tq, int i, int b, StringInfo out, int *ni)

/* emit tokens [a,b) as a plpgsql expression */
static void
tq_emit_range(Tq *tq, int a, int b, StringInfo out)
tq_emit_range_inner(Tq *tq, int a, int b, StringInfo out)
{
int i = a;

Expand Down Expand Up @@ -782,6 +782,21 @@ tq_emit_range(Tq *tq, int a, int b, StringInfo out)
}
}

/*
* Recursion guard for the expression emitter. Nested calls, subscripts and
* literals recurse through here once per level; without check_stack_depth()
* hostile nesting overflows the C stack and crashes the backend (SIGSEGV).
*/
static void
tq_emit_range(Tq *tq, int a, int b, StringInfo out)
{
check_stack_depth();
if (++tq->cx->depth > PLX_MAX_DEPTH)
tq_err(tq, "expression nested too deeply");
tq_emit_range_inner(tq, a, b, out);
tq->cx->depth--;
}

/* end of an expression/raw statement: first depth-0 ; EOF END or ELSE */
static int
tq_value_end(Tq *tq, int from)
Expand Down Expand Up @@ -1519,7 +1534,7 @@ plx_tsql_parse_body(Ctx *cx)
tq.pos = save;
cx->out.len = 0;
cx->out.data[0] = '\0';
cx->locals = cx->ltail = NULL;
plx_locals_reset(cx);
}
while (tq_cur(&tq)->kind != TQ_EOF)
{
Expand Down
8 changes: 8 additions & 0 deletions src/plx_engine.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
#endif

#define PLX_MAX_DEPTH 500 /* recursion cap (parsers + expr rewriter) */
#define PLX_MAX_ENSURE_DEPTH 8 /* finally-in-finally cap; each level doubles
* the inner body (see plx_emit_try) */

/* stacked-diagnostics field bits (e.detail, e.hint, ...); used by the engine's
* diagnostics emitter and by the dialect rescue/try parsers. */
Expand Down Expand Up @@ -89,9 +91,11 @@ typedef struct PlxCtx
const PlxSurface *surf;
StringInfoData out; /* emitted BEGIN..END body */
PlxLocal2 *locals, *ltail;
struct HTAB *lhash; /* lower-cased name -> local (see plx_local_find) */
int loopdepth;
int depth; /* recursion depth guard */
int handlerdepth; /* inside a rescue handler body */
int ensuredepth; /* finally/ensure nested inside finally/ensure */
int subq; /* __plx_fo_N / __plx_p_N counter */
const char *exc_var; /* current rescue exception var name */
int exc_varlen;
Expand Down Expand Up @@ -133,8 +137,12 @@ bool plx_tok_is(Tok *tk, const char *op);
bool plx_name_eq(Tok *tk, const char *w);
int plx_stmt_end(Ctx *cx, int from);
void plx_indent(StringInfo o, int n);
void plx_ensure_enter(Ctx *cx, int line);
void plx_emit_try(StringInfo o, int ind, const char *body, int nrescue,
const char *arms, const char *ensure_body);
PlxLocal2 *plx_local_add(Ctx *cx, const char *name, int len);
PlxLocal2 *plx_local_find(Ctx *cx, const char *name, int len);
void plx_locals_reset(Ctx *cx);
bool plx_is_param(Ctx *cx, const char *name, int len);

/* ---------------------------------------------- dialect front ends (vtable) */
Expand Down
Loading