Skip to content

sqlite: reject connection access from authorizer callbacks - #65156

Draft
TrevorBurnham wants to merge 1 commit into
nodejs:mainfrom
TrevorBurnham:sqlite-authorizer-reentry
Draft

sqlite: reject connection access from authorizer callbacks#65156
TrevorBurnham wants to merge 1 commit into
nodejs:mainfrom
TrevorBurnham:sqlite-authorizer-reentry

Conversation

@TrevorBurnham

@TrevorBurnham TrevorBurnham commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Per sqlite3_set_authorizer(), an authorizer callback must not modify the connection that invoked it, and sqlite3_prepare_v2() and sqlite3_step() both count. node:sqlite allowed the callback to call prepare(), exec(), the statement execution methods, and other connection-mutating APIs on the same DatabaseSync.

Authorizer reentrancy. Track authorizer depth on DatabaseSync with an RAII guard around the callback and throw ERR_INVALID_STATE from the affected entry points while it is on the stack. Depth is per-connection, so a different DatabaseSync stays usable.

Guarded: prepare, exec, serialize, setAuthorizer, createSession, applyChangeset, createTagStore, function, aggregate, enableLoadExtension, enableDefensive, loadExtension, the limits setter; stmt.run/get/all/iterate; iter.next/return; sqlTagStore.run/get/all/iterate/clear; session.changeset/patchset. db.close() and db.deserialize() keep their existing callback-depth messages.

The guard covers every authorizer invocation, not just those from an explicit prepare(): SQLite may re-prepare during sqlite3_step() after a schema change, and serialize() and the session changeset methods prepare internally. Reentry through changeset() never terminated — it recursed until the process died, uncatchable from JavaScript.

Statement reentry. Covering the re-prepare path surfaced a memory-safety bug rather than a contract violation: a statement that is currently being stepped cannot be reentered. Finalizing it frees the virtual machine sqlite3_step() is running, and re-running it through run(), get(), all(), iterate(), or the equivalent tag store methods resets that virtual machine mid-execution. Both segfault, and neither is authorizer-specific — a user-defined function reaches them:

const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE t (x INTEGER, txt TEXT)');
for (let i = 0; i < 200; i++) db.exec(`INSERT INTO t VALUES (${i}, '${'z'.repeat(500)}${i}')`);
let stmt;
db.function('boom', (x) => { try { stmt.run(); } catch {} return 1; });
stmt = db.prepare('SELECT boom(x), txt FROM t');
for (const row of stmt.iterate()) { void row.txt; }  // SIGSEGV before this patch

Swapping stmt.run() for stmt.close() crashes the same way, as does re-entering the same cached tagged literal on a tag store. A single reentrant call on a small result set often returns cleanly, so the crash needs a row payload large enough to force a page fault, or nesting.

Gating on "any callback is running" would forbid a UDF from preparing, running, and finalizing its own helper statement, which is safe. Instead, track the statements currently being stepped and reject reentry into only those, with statement is already being executed. Tracking is a stack, so a UDF may reenter an inner statement it stepped but not the outer one, and it spans the paired sqlite3_reset() calls, which can run JavaScript through an aggregate's xFinal. statement[Symbol.dispose]() returns early when already finalized, so disposal stays idempotent and can't demote a using scope's real exception to a SuppressedError.

Notes for reviewers

  • node:sqlite is Stability 1.2, so this changes behavior directly and throws immediately, per the discussion in the issue.
  • backup() and Session.close() are reachable from an authorizer and left unguarded: sqlite3_backup_step takes the source connection's mutex and blocks until the in-progress step finishes (120 stress iterations at rate: 1, no corruption), and deleting a session doesn't touch the VM under step. Happy to add them.
  • sqlTagStore.clear() only clears a JS-side cache; guarded because the issue lists it, easy to drop.
  • iterator.next() on a drained iterator throws inside an authorizer instead of returning { done: true }.

Verification. 20 sqlite test files plus test-webstorage pass (31 tests in test-sqlite-authz.js, 32 in test-sqlite-udf-close.js). make format-cpp reports no changes; lint-cpp, lint-md, eslint, checkimports.py, and cpplint.py are clean. Mutation-tested: removing the eight statement-reentry guards while keeping close()/dispose() fails the new tests, and neutering the stepping gate entirely crashes both new test files with signal 11.

Fixes: #63207

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. sqlite Issues and PRs related to the SQLite subsystem. labels Aug 9, 2026
@TrevorBurnham
TrevorBurnham force-pushed the sqlite-authorizer-reentry branch 3 times, most recently from 47307a2 to b1eea0e Compare August 10, 2026 14:12
SQLite requires that an authorizer callback not modify the connection
that invoked it, and counts sqlite3_prepare_v2() and sqlite3_step() as
modifications. node:sqlite let an authorizer callback call prepare(),
exec(), the statement execution methods, and other connection-mutating
APIs on the same DatabaseSync.

Track authorizer depth on DatabaseSync with an RAII guard around the
callback, and throw ERR_INVALID_STATE from the affected entry points
while the callback is on the stack. The depth is per-connection, so
other connections stay usable from the callback.

The guard covers every authorizer invocation, not just those from an
explicit prepare(), since SQLite may re-prepare a statement during
sqlite3_step() after a schema change.

serialize() and the session changeset() and patchset() methods prepare
statements internally, so they re-enter the authorizer too. Reentry
through changeset() does not terminate: it recurses until the process
is killed, with no way to catch it from JavaScript.

Reentering a statement that is currently being stepped is a separate
hazard, and a memory-safety one rather than a contract violation.
Finalizing it frees the virtual machine that sqlite3_step() is running,
and re-running it through run(), get(), all(), iterate(), or the
equivalent tag store methods resets that virtual machine mid-execution.
Both crash. Any callback SQLite invokes during execution can reach
them, not only an authorizer, so a user-defined function is enough.

Track the statements currently being stepped and reject reentry into
those, which leaves a user-defined function free to prepare, run, and
finalize its own helper statements. Tracking is a stack so that nested
execution is handled, and covers the paired sqlite3_reset() calls,
which can run JavaScript through an aggregate's xFinal. Disposal stays
idempotent, since throwing for an already-finalized statement would
demote a `using` scope's exception to a SuppressedError.

Signed-off-by: Trevor Burnham <trevorburnham@gmail.com>
Fixes: nodejs#63207
Assisted-by: claude:opus-5
@TrevorBurnham
TrevorBurnham force-pushed the sqlite-authorizer-reentry branch from b1eea0e to e33b0cd Compare August 11, 2026 02:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. sqlite Issues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sqlite: authorizer callback can modify invoking connection despite SQLite contract

3 participants