sqlite: reject connection access from authorizer callbacks - #65156
Draft
TrevorBurnham wants to merge 1 commit into
Draft
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
3 times, most recently
from
August 10, 2026 14:12
47307a2 to
b1eea0e
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 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
force-pushed
the
sqlite-authorizer-reentry
branch
from
August 11, 2026 02:02
b1eea0e to
e33b0cd
Compare
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.
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/clear;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(), 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()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.Notes for reviewers
node:sqliteis Stability 1.2, so this changes behavior directly and throws immediately, per the discussion in the issue.backup()andSession.close()are reachable from an authorizer and left unguarded:sqlite3_backup_steptakes the source connection's mutex and blocks until the in-progress step finishes (120 stress iterations atrate: 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-webstoragepass (31 tests intest-sqlite-authz.js, 32 intest-sqlite-udf-close.js).make format-cppreports no changes;lint-cpp,lint-md, eslint,checkimports.py, andcpplint.pyare clean. Mutation-tested: removing the eight statement-reentry guards while keepingclose()/dispose()fails the new tests, and neutering the stepping gate entirely crashes both new test files with signal 11.Fixes: #63207