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
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,81 @@ 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.
- A `switch` (plxjs, plxts, plxphp), `case` (plxruby) or `EVALUATE` (plxcobol)
with no default arm raised `case not found` when no arm matched, because a
plpgsql `CASE` statement without `ELSE` does. It now does nothing, as in the
source language. plxplsql keeps the error, which is also Oracle's behaviour.
- plxgo `strconv.Itoa(x)` lowered to `(x)::integer` instead of `(x)::text`.

## [2.0.1] - 2026-08-25

Packaging only. **The extension is unchanged at 2.0.0**, so there is no
Expand Down
30 changes: 29 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 Expand Up @@ -1240,6 +1257,7 @@ static void
cob_evaluate(Cb *cb, int ind)
{
bool searched;
bool has_other = false;

cb->pos++; /* EVALUATE */
searched = cob_ci(cob_cur(cb), "TRUE");
Expand Down Expand Up @@ -1269,6 +1287,7 @@ cob_evaluate(Cb *cb, int ind)
plx_indent(&cb->cx->out, ind + 1);
appendStringInfoString(&cb->cx->out, "ELSE\n");
cob_block(cb, ind + 2);
has_other = true;
break;
}
if (searched)
Expand Down Expand Up @@ -1310,6 +1329,15 @@ cob_evaluate(Cb *cb, int ind)
}
}
cob_expect(cb, "END-EVALUATE");
/* no WHEN OTHER: an unmatched EVALUATE is a no-op in COBOL, but a plpgsql
* CASE with no match raises case_not_found */
if (!has_other)
{
plx_indent(&cb->cx->out, ind + 1);
appendStringInfoString(&cb->cx->out, "ELSE\n");
plx_indent(&cb->cx->out, ind + 2);
appendStringInfoString(&cb->cx->out, "NULL;\n");
}
plx_indent(&cb->cx->out, ind);
appendStringInfoString(&cb->cx->out, "END CASE;\n");
}
Expand Down
60 changes: 58 additions & 2 deletions 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 @@ -867,7 +908,7 @@ go_emit_call(Go *g, int i, int b, StringInfo out, int *ni)
/* strconv.Itoa(x) -> (x)::text ; strconv.Atoi(x) -> (x)::integer */
if (r[k].pg == NULL)
{
bool toint = (meth->len == 4); /* "Atoi" */
bool toint = (strncmp(meth->s, "Atoi", 4) == 0);

go_sp(out);
appendStringInfoChar(out, '(');
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
50 changes: 12 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 Expand Up @@ -624,6 +589,15 @@ parse_case(Ctx *cx, int ind)
cx->pos++;
parse_block(cx, ind + 2);
}
else
{
/* no default arm: falling through is a no-op in the source language, but a
* plpgsql CASE with no match raises case_not_found */
plx_indent(o, ind + 1);
appendStringInfoString(o, "ELSE\n");
plx_indent(o, ind + 2);
appendStringInfoString(o, "NULL;\n");
}
if (cx->t[cx->pos].kind == T_KW && cx->t[cx->pos].kw == KW_END)
cx->pos++;
else
Expand Down
Loading