sqlite: reject connection access from authorizer callbacks - #65156
Open
TrevorBurnham wants to merge 1 commit into
Open
sqlite: reject connection access from authorizer callbacks#65156TrevorBurnham wants to merge 1 commit into
TrevorBurnham wants to merge 1 commit into
Conversation
Collaborator
|
Review requested:
|
meixg
approved these changes
Aug 9, 2026
TrevorBurnham
force-pushed
the
sqlite-authorizer-reentry
branch
4 times, most recently
from
August 11, 2026 02:02
b1eea0e to
e33b0cd
Compare
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 the 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 it is on the stack. Covering every authorizer invocation, including the re-prepare that SQLite can run during sqlite3_step(), exposed a second and distinct hazard: reentering a statement that is currently being stepped is a use-after-free rather than a contract violation, since finalizing it frees the virtual machine under sqlite3_step() and re-running it resets that machine mid-execution. Any callback SQLite invokes during execution can reach it, so a user-defined function is enough. Track the statements currently being stepped and reject reentry into only those, which leaves a user-defined function free to prepare, run, and finalize its own helper statements. Signed-off-by: Trevor Burnham <trevorburnham@gmail.com> Fixes: nodejs#63207 Assisted-by: claude:opus-5
TrevorBurnham
force-pushed
the
sqlite-authorizer-reentry
branch
from
August 11, 2026 13:31
e33b0cd to
2a217a9
Compare
TrevorBurnham
marked this pull request as ready for review
August 11, 2026 14:21
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #63207
Per
sqlite3_set_authorizer(), an authorizer callback must not modify the connection that invoked it, andsqlite3_prepare_v2()andsqlite3_step()both count.node:sqliteallowed the callback to callprepare(),exec(), the statement execution methods, and other connection-mutating APIs on the sameDatabaseSync.Authorizer reentrancy. Track authorizer depth on
DatabaseSyncwith an RAII guard around the callback and throwERR_INVALID_STATEfrom the affected entry points while it is on the stack. Depth is per-connection, so a differentDatabaseSyncstays usable.Guarded:
prepare,exec,serialize,setAuthorizer,createSession,applyChangeset,createTagStore,function,aggregate,enableLoadExtension,enableDefensive,loadExtension, thelimitssetter;stmt.run/get/all/iterate;iter.next/return;sqlTagStore.run/get/all/iterate;session.changeset/patchset.db.close()anddb.deserialize()keep their existing callback-depth messages.The guard covers every authorizer invocation, not just those from an explicit
prepare(): SQLite may re-prepare duringsqlite3_step()after a schema change, andserialize()and the session changeset methods prepare internally. Reentry throughchangeset()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 throughrun(),get(),all(),iterate(),iter.next(),iter.return(), 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:Swapping
stmt.run()forstmt.close()oriter.return()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 pairedsqlite3_reset()calls, which can run JavaScript through an aggregate'sxFinal.statement[Symbol.dispose]()returns early when already finalized, so disposal stays idempotent and can't demote ausingscope's real exception to aSuppressedError.Deliberately unguarded. Three APIs reachable from a callback are left available:
backup()andSession.close().sqlite3_backup_init()runs synchronously butsqlite3_backup_step()takes the source connection's mutex and blocks until the in-progress step finishes; a backup started from inside an authorizer completes and copies every row (also 120 stress iterations atrate: 1, no corruption). Deleting a session doesn't touch the VM under step.sqlTagStore.clear(). Dropping the cache releases strong references but never finalizes a statement synchronously —decrease_refcount()reaching zero callsMakeWeak(), and the statement being stepped is held by a stackBaseObjectPtrregardless — so it touches no SQLite state. Invalidating the cache after a schema change is a legitimate use of an authorizer, so this is now allowed rather than guarded.iterator.next()anditerator.return()on a drained iterator. Thedone_check now precedes the guards, so a drained iterator keeps returning{ done: true }instead of throwing.