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
93 changes: 93 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,99 @@ 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.

### Changed (breaking; for 3.0)

- **An interpolated value in a SQL argument is bound, never spliced as SQL.**
In `query`, `fetch_one`, `execute`, `open_cursor`, `return_query` and
`perform`, an interpolation in the SQL string (`#{x}`, `{$x}`, `${x}`,
f-string `{x}`) used to be concatenated into dynamic SQL unquoted, so data
could inject SQL; adding a bind argument to a safe static call silently made
it injectable. Now a value becomes a `$n` parameter passed with `USING`
(after the call's own bind arguments), or, in a statement that takes no
parameters (`CREATE`, `ALTER`, `SET`, `COMMENT`, ...), is spliced through
`quote_nullable()`. `'#{x}'` is the value of `x` in static and dynamic SQL
alike (it used to be the literal text `'x'` in static SQL), and an
interpolation inside a longer literal concatenates the value. An
interpolation inside a quoted identifier is rejected.
- **`ident()`, `literal()` and `raw()`** may wrap an interpolation in a SQL
argument (plxphp: `{ident($t)}`): `ident(e)` splices `quote_ident(e)`,
`literal(e)` splices `quote_nullable(e)`, and `raw(e)` splices `e` unquoted,
for a trusted SQL fragment. Each makes the call dynamic.
- Upgrading: 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 uses `ident()` or `raw()`. Stored functions are unaffected until
their DDL is run again. See doc/DYNAMIC-SQL.md.

### 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
104 changes: 104 additions & 0 deletions doc/DYNAMIC-SQL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Interpolation in SQL

Every dialect with string interpolation (plxruby `#{x}`, plxphp `{$x}`/`$x`,
plxjs and plxts `${x}`, plxpython3 f-string `{x}`) can interpolate values into
the SQL argument of `query`, `fetch_one`, `fetch_one!`, `execute`,
`open_cursor`, `return_query` and `perform`. Since 3.0, an interpolated value
never becomes SQL text. It is bound as a parameter, or quoted as a literal, and
the author asks explicitly for anything else.

## Values

```ruby
u = fetch_one("SELECT id, name FROM users WHERE name = '#{uname}' LIMIT $1", lim)
```

becomes

```plpgsql
EXECUTE 'SELECT id, name FROM users WHERE name = ($2) LIMIT $1' INTO u USING lim, uname;
```

| Where the interpolation sits | Static SQL | Dynamic SQL |
|---|---|---|
| outside quotes: `id = #{n}` | `id = n` (plpgsql binds the variable) | `id = $k`, passed with `USING` |
| a whole quoted literal: `'#{s}'` | `(s)` | `($k)` |
| inside a longer literal: `'%#{s}%'` | `('%' \|\| (s)::text \|\| '%')` | `('%' \|\| ($k)::text \|\| '%')` |
| inside a quoted identifier: `"#{t}"` | rejected | rejected |

A call is static when its SQL argument is a single string literal and it has
no bind arguments; otherwise it runs as dynamic SQL (`EXECUTE`). `execute` is
always dynamic. Interpolated values are numbered after the call's own bind
arguments, so `$1` in the example above is still `lim`.

`'#{s}'` used to put the *text* of the expression into the SQL (a string literal
reading `uname`), and in a dynamic call a value was concatenated into the SQL
unquoted. Both are gone: a value is data wherever it appears.

## Statements that take no parameters

PostgreSQL accepts `$n` parameters only in planned statements: `SELECT`,
`INSERT`, `UPDATE`, `DELETE`, `MERGE`, `WITH`, `VALUES`, `TABLE`, `CALL` and
`EXPLAIN`. In any other dynamic statement (`CREATE`, `ALTER`, `SET`, `COMMENT`,
`GRANT`, and so on), a value is spliced through `quote_nullable()` instead:

```ruby
execute("COMMENT ON TABLE #{ident(t)} IS '#{c}'")
execute("SET application_name = #{appname}")
```

```plpgsql
EXECUTE 'COMMENT ON TABLE ' || quote_ident((t)::text) || ' IS ' || quote_nullable((c)::text);
EXECUTE 'SET application_name = ' || quote_nullable((appname)::text);
```

A value inside a longer quoted literal (`IS 'note: #{c}'`) cannot be quoted in
such a statement and is rejected; build the whole value first and interpolate
it in place of the literal.

## Identifiers and SQL fragments

Three helpers, recognized only as the whole of an interpolation in a SQL
argument (plxphp spells them `{ident($t)}`, `{literal($v)}`, `{raw($s)}`), make
the call dynamic and splice their result into the SQL text:

| Helper | Lowers to | Use for |
|---|---|---|
| `ident(e)` | `quote_ident(e)` | a table, column or other identifier |
| `literal(e)` | `quote_nullable(e)` | a value as a quoted literal, in any statement |
| `raw(e)` | `e` as text, unquoted | a trusted SQL fragment (for example a sort order) |

```js
let r = fetch_one(`SELECT count(*) AS c FROM ${ident(tbl)} WHERE id >= ${minid}`);
for (const row of query(`SELECT name FROM users ORDER BY ${raw(order)}`)) { ... }
```

`ident()` and `literal()` cannot appear inside quotes; `raw()` is the only way
to put caller-supplied text into SQL unquoted, and it is the author's
responsibility to make that text safe.

## What is not covered

Only interpolation written directly in the SQL argument is seen. A string
assembled earlier and passed in a variable is ordinary string concatenation:

```js
let sql = `SELECT * FROM t WHERE name = '${name}'`; // concatenation
execute(sql); // runs whatever sql holds
```

Write the interpolation in the call, or use bind arguments
(`execute("SELECT * FROM t WHERE name = $1", name)`).

## Upgrading from 2.x

Functions already in the catalog keep the plpgsql they were generated with.
When a function is created or replaced under 3.0:

- a value interpolated where SQL needs an identifier (`FROM #{t}`) becomes a
parameter and the statement fails with `syntax error at or near "$1"`; write
`#{ident(t)}`;
- a value interpolated to build SQL text on purpose (`ORDER BY #{col} DESC`
where `col` holds `name`) is now bound as data; write `#{raw(col)}` (or
`#{ident(col)}` when it is a column name);
- `'#{x}'` now compares against the value of `x`, not the string `'x'`.
4 changes: 2 additions & 2 deletions doc/cookbook/plxphp.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,13 +186,13 @@ SELECT id, name, tag FROM audit_item ORDER BY id;

## Dynamic SQL with bind parameters

Interpolation (`{$tbl}`) builds the SQL text, while the untrusted value is passed as a bind parameter to `execute`, so it is never spliced into the statement. `row_count()` reports how many rows the last statement affected.
The table name is an identifier, so it is interpolated with `{ident($tbl)}`, which quotes it with `quote_ident`. The value is passed as a bind parameter. (An interpolated value such as `{$val}` would be bound too; since 3.0 only `ident()`, `literal()` and `raw()` put text into the SQL itself. See [Interpolation in SQL](../DYNAMIC-SQL.md).) `row_count()` reports how many rows the last statement affected.

```php
CREATE TABLE note(id serial, msg text);

CREATE FUNCTION add_note(tbl text, val text) RETURNS bigint LANGUAGE plxphp AS $$
execute("INSERT INTO {$tbl}(msg) VALUES ($1)", $val);
execute("INSERT INTO {ident($tbl)}(msg) VALUES ($1)", $val);
$n = row_count();
return $n;
$$;
Expand Down
10 changes: 6 additions & 4 deletions doc/cookbook/plxpython3.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,15 @@ SELECT id, label, code FROM widget ORDER BY id;

## Dynamic SQL with bind parameters

Interpolate identifiers you control (a table name) with an f-string, but pass
untrusted values as bind parameters: `$1` in the SQL text and an extra argument
to `fetch_one`. This keeps the value out of the SQL string and avoids injection.
Interpolate an identifier (a table name) with `{ident(tbl)}`, which quotes it
with `quote_ident`, and pass values as bind parameters: `$1` in the SQL text and
an extra argument to `fetch_one`. An interpolated value would be bound as well;
only `ident()`, `literal()` and `raw()` put text into the SQL itself. See
[Interpolation in SQL](../DYNAMIC-SQL.md).

```python
CREATE FUNCTION count_at_least(tbl text, threshold int) RETURNS bigint LANGUAGE plxpython3 AS $$
r = fetch_one(f"SELECT count(*) AS c FROM {tbl} WHERE amount >= $1", threshold)
r = fetch_one(f"SELECT count(*) AS c FROM {ident(tbl)} WHERE amount >= $1", threshold)
return r.c
$$;
```
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ markdown_extensions:
nav:
- Home: index.md
- User guide: USERGUIDE.md
- Interpolation in SQL: DYNAMIC-SQL.md
- Migrating to PostgreSQL: MIGRATION.md
- Dialects:
- Ruby (plxruby): plxruby.md
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
Loading