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

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 as modifications. node:sqlite allowed an authorizer callback to 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 a different DatabaseSync stays usable from inside the callback.

Guarded: prepare, exec, deserialize, setAuthorizer, createSession, applyChangeset, createTagStore, function, aggregate, enableLoadExtension, enableDefensive, loadExtension, the limits setter; stmt.run/get/all/iterate/close and stmt[Symbol.dispose]; iter.next/return; sqlTagStore.run/get/all/iterate/clear. db.close() was already rejected by the existing callback-depth guard, so it 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.

Crash found while testing

That re-prepare case turned out to be worse than a contract violation: calling statement.close() or statement[Symbol.dispose]() from an authorizer invoked during sqlite3_step() segfaulted, because finalizing the statement frees the VM the enclosing step() is still executing. Both are now guarded.

Note that the same finalize-during-step crash is reachable from a user-defined function without any authorizer involved:

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', () => { stmt.close(); return 1; });
stmt = db.prepare('SELECT boom() FROM t');
stmt.get();  // segfault

That path is outside the scope of this PR and is left unfixed; it likely wants the deferred-finalize approach from #63183 rather than a throw, since UDFs are otherwise permitted to call back into the connection. Happy to file it separately.

Notes for reviewers

  • Since node:sqlite is Stability 1.2, this changes behavior directly rather than going through a deprecation cycle, per the discussion in the issue. Throwing immediately (rather than deferring) matches the preference expressed by both participants in that thread.
  • sqlTagStore.clear() only clears a JS-side statement cache and doesn't touch SQLite, so guarding it is arguably unnecessary. I included it because the issue lists it and because finalizing cached statements mid-callback is the kind of thing the contract exists to prevent. Easy to drop if reviewers prefer.

Verification

  • All 22 sqlite-related test files pass, plus test-webstorage.
  • make format-cpp, lint-cpp, lint-md, and eslint are clean.
  • To confirm the new tests aren't vacuous, I reverted the Prepare guard and rebuilt: 3 tests failed, then passed 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
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 the enclosing sqlite3_step() is still executing, which
crashes rather than throwing, and any callback SQLite invokes during
execution can reach it. statement.close() therefore rejects while any
callback is on the stack, not just an authorizer, so the equivalent
crash through a user-defined function is fixed as well. 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 ed08600 to 3536092 Compare August 9, 2026 15:36
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