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
88 changes: 88 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,94 @@ 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. The finally body is now
emitted once (see Changed), so output grows linearly with nesting.
- **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.

### Changed

- **`finally`/`ensure` now runs on `return`, `break` and `continue`** (plxjs,
plxts, plxphp, plxruby, plxpython3), as in the source languages. Before, a
`return` inside `try` left the function without running `finally`. The
finally body is now emitted once: an exit from the try (or from a catch arm)
records which exit is pending, leaves the block, runs the finally body, then
completes the exit; an error is caught, the finally body runs, and the error
is re-raised with its SQLSTATE, message, detail, hint and object names
(schema, table, column, constraint, type). A `return` or an error in the
finally body replaces the pending exit or error, as in the source languages.
Behaviour differences to be aware of: an error re-raised after `finally`
carries the location of the re-raise in its `CONTEXT`, not the original
statement's; when it names any object, the object names it does not have
are sent as empty fields rather than left out (psql shows them in verbose
mode). As before, the finally body does not run when the statement is
cancelled (a query cancel or `statement_timeout`) or an `assert` fails:
`WHEN OTHERS` does not catch those two errors. Only functions created or
replaced after upgrading are affected; stored functions keep the plpgsql
they were created with.

### 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
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ PLX_PYTHON ?= python3
.PHONY: differentialcheck
differentialcheck:
PLX_PSQL="$(shell $(PG_CONFIG) --bindir)/psql" $(PLX_PYTHON) test/differential.py
PLX_PSQL="$(shell $(PG_CONFIG) --bindir)/psql" PGDATABASE=contrib_regression \
$(PLX_PYTHON) test/finally_diff.py 300 1

# Check META.json against the PGXN Meta Spec. PGXN validates at upload time,
# which is after a release is tagged and published, so check it before then.
Expand Down
9 changes: 9 additions & 0 deletions doc/plxjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,15 @@ try {
`e.table`, `e.schema`, `e.datatype` to the matching `GET STACKED DIAGNOSTICS`
fields.

The `finally` body runs once, whether the `try` block completes, returns,
breaks, continues or raises an error. After an error it runs and then raises
the error again, with its SQLSTATE, message, detail, hint and the names of
the objects it is about (schema, table, column, constraint, type); the
`CONTEXT` of the raised error points at the re-raise, not the original
statement. It does not run when the statement is cancelled (a query cancel
or `statement_timeout`) or an `assert` fails, because `WHEN OTHERS` does not
catch those two errors; for the same reason `catch (e)` does not catch them.

### Assertions

```sql
Expand Down
10 changes: 10 additions & 0 deletions doc/plxphp.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,16 @@ Accessors: `$e->message` to `SQLERRM`, `$e->sqlstate` to `SQLSTATE`, and
`$e->detail`, `$e->hint`, `$e->constraint`, `$e->column`, `$e->table`,
`$e->schema`, `$e->datatype` to the matching `GET STACKED DIAGNOSTICS` fields.

The `finally` body runs once, whether the `try` block completes, returns,
breaks, continues or raises an error. After an error it runs and then raises
the error again, with its SQLSTATE, message, detail, hint and the names of
the objects it is about (schema, table, column, constraint, type); the
`CONTEXT` of the raised error points at the re-raise, not the original
statement. It does not run when the statement is cancelled (a query cancel
or `statement_timeout`) or an `assert` fails, because `WHEN OTHERS` does not
catch those two errors; for the same reason `catch (\Exception $e)` does not
catch them.

### Assertions

```sql
Expand Down
9 changes: 9 additions & 0 deletions doc/plxpython3.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,15 @@ to `unique_violation`. Accessors: `e.message` to `SQLERRM`, `e.sqlstate` to
`SQLSTATE`, and `e.detail`, `e.hint`, `e.constraint`, `e.column`, `e.table`,
`e.schema`, `e.datatype` to the matching `GET STACKED DIAGNOSTICS` fields.

The `finally` body runs once, whether the `try` block completes, returns,
breaks, continues or raises an error. After an error it runs and then raises
the error again, with its SQLSTATE, message, detail, hint and the names of
the objects it is about (schema, table, column, constraint, type); the
`CONTEXT` of the raised error points at the re-raise, not the original
statement. It does not run when the statement is cancelled (a query cancel
or `statement_timeout`) or an `assert` fails, because `WHEN OTHERS` does not
catch those two errors; for the same reason `except Exception` does not catch them.

### Assertions

```sql
Expand Down
9 changes: 9 additions & 0 deletions doc/plxruby.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,15 @@ Accessors: `e.message` to `SQLERRM`, `e.sqlstate` to `SQLSTATE`, and `e.detail`,
`e.hint`, `e.constraint`, `e.column`, `e.table`, `e.schema`, `e.datatype` to the
matching `GET STACKED DIAGNOSTICS` fields.

The `ensure` body runs once, whether the `begin` block completes, returns,
breaks, continues or raises an error. After an error it runs and then raises
the error again, with its SQLSTATE, message, detail, hint and the names of
the objects it is about (schema, table, column, constraint, type); the
`CONTEXT` of the raised error points at the re-raise, not the original
statement. It does not run when the statement is cancelled (a query cancel
or `statement_timeout`) or an `assert` fails, because `WHEN OTHERS` does not
catch those two errors; for the same reason `rescue` does not catch them.

### Assertions

```sql
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
54 changes: 14 additions & 40 deletions src/plx_dialect_python.c
Original file line number Diff line number Diff line change
Expand Up @@ -274,11 +274,13 @@ parse_py_try(Ctx *cx, int ind)
arms;
int nrescue = 0;
char *ensure_body = NULL;
PlxFin *fin;

colon = py_header_colon(cx, cx->pos + 1);
if (colon < 0)
plx_err(cx, hd, "try requires ':'");
cx->pos = colon + 1;
fin = plx_fin_push(cx);
saved = cx->out;
initStringInfo(&cx->out);
parse_py_block(cx, ind + 1);
Expand Down Expand Up @@ -343,12 +345,14 @@ parse_py_try(Ctx *cx, int ind)
cx->exc_var = save_ev;
cx->exc_varlen = save_evl;

appendStringInfoSpaces(&arms, (ind + 1) * 2);
plx_indent(&arms, ind + 1);
appendStringInfo(&arms, "WHEN %s THEN\n", cond);
appendStringInfoString(&arms, pfx);
appendStringInfoString(&arms, hb);
pfree(hb);
nrescue++;
}
plx_fin_pop(cx, fin);
if (cx->t[cx->pos].kind == T_KW && cx->t[cx->pos].kw == KW_ENSURE) /* finally */
{
colon = py_header_colon(cx, cx->pos + 1);
Expand All @@ -362,44 +366,7 @@ parse_py_try(Ctx *cx, int ind)
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(cx, o, ind, body, nrescue, arms.data, ensure_body, fin);
}

/* raise / raise Class("msg") */
Expand Down Expand Up @@ -463,10 +430,17 @@ parse_py_stmt_inner(Ctx *cx, int ind, bool toplevel)
cx->t[cx->pos + 2].kind == T_KW &&
(cx->t[cx->pos + 2].kw == KW_FOR || cx->t[cx->pos + 2].kw == KW_WHILE))
{
PlxLabel *mark = cx->labels;

plx_indent(&cx->out, ind);
appendStringInfo(&cx->out, "<<%.*s>>\n", tk->len, tk->s);
plx_label_note(cx, tk->s, tk->len);
cx->pos += 2;
tk = &cx->t[cx->pos];
/* the label is in scope only inside its loop: a later loop may
* reuse the name, and a break must not resolve to a closed one */
parse_py_stmt_inner(cx, ind, toplevel);
cx->labels = mark;
return;
}
if (tk->kind == T_KW)
{
Expand Down
Loading