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 the sqlite3_set_authorizer() docs, the authorizer callback must not do anything that modifies the database connection that invoked it, and sqlite3_prepare_v2() and sqlite3_step() both count. node:sqlite allowed an authorizer 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 the callback is on the stack. Depth is per-connection, so a different DatabaseSync stays usable from inside the callback.

Guarded: prepare, exec, serialize, deserialize, 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() was already rejected by the existing callback-depth guard and keeps its current message.

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 methods prepare statements internally, so they re-enter the authorizer too; reentry through changeset() never terminates, recursing until the process is killed with no way to catch it from JavaScript.

Finalize-during-step

Covering the re-prepare path surfaced a segfault rather than a contract violation: finalizing a statement frees the virtual machine that sqlite3_step() is executing. This isn't specific to authorizers. Any callback SQLite invokes during execution can reach it, including a user-defined function:

const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE t (x INTEGER)');
db.exec('INSERT INTO t VALUES (1)');
let stmt;
db.function('boom', (x) => { stmt.close(); return 1; });
stmt = db.prepare('SELECT boom(x) FROM t');
stmt.get();  // SIGSEGV before this patch

Gating close() on "any callback is running" would forbid a UDF from preparing and finalizing its own helper statement, which is safe. Instead this tracks the statements currently being stepped and rejects only those, with statement cannot be finalized while it is being executed. A UDF can still do using s = db.prepare(...). Tracking is a stack, so a UDF that steps an inner statement may finalize that inner statement but not the outer one still under step().

statement[Symbol.dispose]() returns early when already finalized, keeping disposal idempotent; throwing there would demote a using scope's real exception to a SuppressedError.

Notes for reviewers

  • node:sqlite is Stability 1.2, so this changes behavior directly rather than through a deprecation cycle, and throws immediately rather than deferring, per the discussion in the issue.
  • backup() and Session.close() are reachable from an authorizer and deliberately left unguarded. backup() schedules sqlite3_backup_step on the threadpool, but that call takes the source connection's mutex and blocks until the in-progress step finishes; 120 stress iterations at rate: 1 produced no corruption. Deleting a session object doesn't touch the VM under step. Guarding either would cost working behavior for symmetry alone, but I'll add them if you disagree.
  • 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 rather than returning { done: true }. A drained iterator is still an object whose method the contract forbids calling, but I don't feel strongly.

Verification

  • 20 sqlite-related test files pass, plus test-webstorage. 31 tests in test-sqlite-authz.js, 12 in test-sqlite-udf-close.js.
  • make format-cpp reports no changes; lint-cpp, lint-md, eslint, checkimports.py, and cpplint.py clean.
  • Mutation-tested: neutering the stepping gate crashes both test files with signal 11, and reverting the prepare() guard fails 3 tests. Both pass again on restore.

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 2 times, most recently from 3536092 to 47307a2 Compare August 9, 2026 19:14
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.

Finalizing a statement is a separate hazard. It frees the virtual
machine that sqlite3_step() is executing, which crashes rather than
throwing, and any callback SQLite invokes during execution can reach
it, not only an authorizer. Track the statements currently being
stepped and reject finalizing one of those, so a user-defined function
can still prepare and finalize its own helper statements. 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 47307a2 to b1eea0e Compare August 10, 2026 14:12
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