Skip to content

Memory safety fixes - #43

Merged
JerrySievert merged 8 commits into
mainfrom
memory-safety-fixes
Aug 31, 2026
Merged

JerrySievert merged 8 commits into
mainfrom
memory-safety-fixes

Conversation

@JerrySievert

Copy link
Copy Markdown
Member

extends #25 to fix additional issues found while testing this PR.

sfc-gh-okalaci and others added 8 commits August 18, 2026 12:28
Two things stop pljs compiling against the REL_19_STABLE branch that
.github/workflows/build_and_test.yml already tests:

* time_as_8601() calls strftime() and gmtime(), whose declarations arrived through a
  transitive include that PostgreSQL 19 no longer provides.  src/types.c includes
  <string.h> but never <time.h>.

* pljs_datum_to_jsvalue_fallback() passes a Datum straight to VARDATA() and
  VARSIZE_ANY_EXHDR(), which take a pointer.  That conversion is no longer implicit,
  so the Datum is unwrapped with DatumGetPointer() into a struct varlena * first.

Whether this is an error or a warning depends on the compiler, which is why CI has
stayed green: gcc on the runner image still warns, while clang treats both as errors
and stops.  Recent gcc does the same, so the CI leg is one image update away from
failing too.

Nothing about the behaviour changes -- the values passed to VARDATA() and
VARSIZE_ANY_EXHDR() are the same bytes as before, just with the type spelled out.
Detoasting is unchanged and is a separate question: this function still assumes its
argument is already detoasted, as it did before.

Verified: builds clean on PostgreSQL 16, 17, 18 and 19beta3, and the full suite passes
on all four.

No regression test accompanies this. What it fixes is a compile failure, so the evidence
that it works is that the tree builds -- which the CI matrix already checks on every
branch it tests. There is no SQL-level observable either way.
The BYTEAOID conversion iterated to bytes_per_element * length while indexing the
typed array by element and writing array_copy[i], where array_copy holds length
elements.  Converting an Int32Array therefore wrote four times past the end of the
allocation, and an Int16Array twice.

SET_VARSIZE used the correct byte count, so the returned bytea was right and the
overrun was pure collateral damage past the end of a palloc'd chunk.  That is the
worst shape for such a bug: nothing fails at the point of the error, the damage
depends on what the allocator had placed after the chunk, and it surfaces later as an
unrelated crash.

Adds sql/pg_typedarray_views.sql, which converts every typed-array width at lengths
spanning several allocation chunks, including offset views and a zero-length view.
The cached function body was copied two different wrong ways.

pljs_context_to_function_cache() allocated strlen + 1 bytes but copied only strlen of
them.  palloc does not zero, so the final byte was uninitialised and the cached string
had no terminator.

pljs_function_cache_to_context() then copied a fixed NAMEDATALEN bytes out of that
allocation.  For any function body shorter than 63 characters that reads past the end
of the chunk, and it is what then ran off the end of the unterminated string above.

Both sides now use pstrdup, which sizes and terminates in one step.

There is no regression test, and one cannot be written at this level: the over-read
touches memory the process already owns, so no value changes and nothing fails.  It
takes a sanitizer or a clobbering allocator to observe.

No regression test accompanies this. The over-read changes no query output -- the truncated copy is never compiled -- so nothing at the SQL level can distinguish the two versions. It shows up as a heap-buffer-overflow under a sanitizer build, which `make installcheck` has no way to express.
Returning from inside a PG_TRY block leaves PG_exception_stack pointing at a
sigjmp_buf in a stack frame that has already been unwound.  The next ereport(ERROR)
then siglongjmp()s into that dead frame, which terminates the backend.

pljs_find_function did exactly this on the permission-denied path.  It now leaves func
as JS_UNDEFINED and falls through to the shared return, so the PG_TRY is left by its
own PG_END_TRY.

Adds sql/pg_find_function_no_perm.sql, which drives the crash sequence: look up a
function the caller lacks EXECUTE on, then force an unwrapped elog(ERROR) by returning
a non-array value for an int[] column.
pljs_datum_to_jsvalue_fallback() read variable-length datums with VARDATA(),
which assumes a 4-byte header, while sizing them with VARSIZE_ANY_EXHDR(),
which does not.  For any value carrying a 1-byte short header -- what
PostgreSQL stores for a small value in a column whose type is not PLAIN -- the
two disagree: the read started three bytes into the payload and ran three bytes
past its end.  Nothing detoasted the datum either, so a compressed or
out-of-line value was handed to JavaScript as its raw stored bytes.

Every type not named in the switch above reaches this path: domains, ltree and
other extension types, inet, tsvector, range types.  A literal was fine, because
one is built with a 4-byte header; the same value read back out of a column was
not.  With ltree the corrupted level count is enough to take the backend down.

PG_DETOAST_DATUM_PACKED() plus VARDATA_ANY() is the accessor pair that matches
VARSIZE_ANY_EXHDR().  The copy detoasting may allocate is freed here, so the
function still allocates nothing in the common case, as before.

The regression test extends the existing ltree coverage in types.sql with a
table-backed value, and adds a domain over text exercising all three shapes:
a literal, a short-header column value, and a 5000-byte value stored compressed.
As written the test passed on an unfixed tree.  Its longest case was three
elements, and at that size palloc rounds the request up to a chunk with enough
slack to absorb the overrun, so the output stayed correct and the corruption was
invisible -- which is exactly what the fix's own commit message says about the
bug.  sql/bytea.sql already returned an Int32Array(4) and passed for the same
reason.

ta_big() returns a 262144-element Uint32Array.  Past palloc's 8KB chunk limit
the allocation gets its own malloc'd block sized to the request, so the old loop
wrote 4MiB into 1MiB and ran off the end of the mapping: on an unfixed build
this segfaults the backend, and the test fails.  ta_nil() adds the zero-length
view the original commit message claimed was covered.

The cursor.fetch.apply case moves to sql/cursor.sql, where it belongs; it has
nothing to do with typed arrays or with this fix.
The test creates a role, and roles are cluster-wide.  It is also built to kill
the backend when the fix it guards is absent -- and when it does, the DROP ROLE
at the bottom never runs.  Every later run then failed on 'role "ff_limited"
already exists', which is precisely the revert-and-re-run loop anyone verifying
the fix will be in.  Dropping the role up front makes the test idempotent; the
notice is silenced so the output does not depend on whether it was there.

Also drops the CREATE EXTENSION IF NOT EXISTS: no test file in this suite
creates the extension except init-extension, which REGRESS runs first, and
baking the resulting "already exists, skipping" notice into the expected output
encoded the run order for no benefit.
PG_CATCH() restores PG_exception_stack, but it does not pop the errordata
stack -- only FlushErrorState() does.  Eleven PG_CATCH() blocks in functions.c
returned a thrown JavaScript value without flushing, so each error caught
through them consumed one of the five slots ERRORDATA_STACK_SIZE provides, for
the life of the session.

The fifth such error raised "ERRORDATA_STACK_SIZE exceeded" at PANIC level,
which takes down every backend in the cluster, not just the one running the
query.  Reaching it needs no privileges and nothing unusual:

  for (let i = 0; i < 5; i++) {
    try { pljs.execute('SELECT * FROM missing'); } catch (e) { }
  }

pljs.execute(), plan.execute(), pljs.prepare(), cursor open, cursor fetch and
move, cursor close, pljs.commit(), pljs.rollback(), pljs.find_function() and the
window storage accessor were all affected.  The block in pljs_elog() already had
the right shape and is left alone; the blocks that end in PG_RE_THROW() are
correct as they are, since the error stays live for an outer handler.

Where the message is copied out with CopyErrorData(), the flush goes between the
copy and the subtransaction rollback, matching plpgsql's exec_stmt_block(): the
copy is made in the caller's context so it survives the reset of ErrorContext,
and the rollback then runs with a clean error state.  Those sites also free the
copy, which they previously leaked.

sql/pg_caught_error_stack.sql drives six of the eleven sites twenty times each
in a single session -- the stack is per-backend, so spreading them across
sessions would hide the bug.  Cursor open is reachable too but reports an
unrelated syscache leak whose message names a tuple id, so it is left out.
@JerrySievert
JerrySievert merged commit 0bdd066 into main Aug 31, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants