sqlite: reject busy statement finalization in authorizer

An iterator can leave a statement active between sqlite3_step() calls.
Finalizing that statement from an authorizer callback can release its
locks and change the outcome of the statement being authorized.

Reject close() and Symbol.dispose when the statement is busy and the
connection is in an authorizer callback. Continue allowing idle
statements to be finalized.

Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com>
Assisted-by: codex:gpt-5.6-sol
PR-URL: https://github.com/nodejs/node/pull/65369
Fixes: https://github.com/nodejs/node/issues/65368
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Stephen Belanger <admin@stephenbelanger.com>
This commit is contained in:
Trivikram Kamat authored and GitHub committed 2026-08-21 03:30:10 +00:00
1 parent 9ea61828c7
commit c2543d9301
4 files changed
+53 -8

No files matched your search

+1 -1
View File
@@ -1128,7 +1128,7 @@ Finalizes the prepared statement. An exception is thrown if the statement is
already finalized. An [`ERR_INVALID_STATE`][] error is thrown if this statement
is currently executing, which happens when the method is called from a callback
that the statement itself triggered, such as a user-defined function, an
aggregate function, or a [`'sqlite.db.query'`][] subscriber. Other statements
aggregate function, or a [`'sqlite.db.query'`][] subscriber. Idle statements
on the same connection can be finalized from such a callback. This method is a
wrapper around [`sqlite3_finalize()`][].
+11
View File
@@ -160,6 +160,15 @@ inline MaybeLocal<Value> IntegerToValue(Isolate* isolate,
(db)->IsInAuthorizerCallback(), \
"database cannot be accessed from an authorizer callback")
// Finalizing a busy statement from an authorizer can release its locks and
// change the outer statement's outcome.
#define THROW_AND_RETURN_IF_BUSY_IN_AUTHORIZER(env, stmt) \
THROW_AND_RETURN_ON_BAD_STATE( \
(env), \
(stmt)->db_->IsInAuthorizerCallback() && \
sqlite3_stmt_busy((stmt)->statement_.get()), \
"database cannot be accessed from an authorizer callback")
// A statement's virtual machine cannot be reentered while sqlite3_step() is
// running it. Finalizing it frees the VM outright, and re-running it resets the
// VM mid-execution; both are use-after-free rather than merely a contract
@@ -2915,6 +2924,7 @@ void StatementSync::Close(const FunctionCallbackInfo<Value>& args) {
THROW_AND_RETURN_ON_BAD_STATE(
env, stmt->IsFinalized(), "statement has been finalized");
THROW_AND_RETURN_IF_STEPPING(env, stmt);
THROW_AND_RETURN_IF_BUSY_IN_AUTHORIZER(env, stmt);
stmt->Close();
}
@@ -2928,6 +2938,7 @@ void StatementSync::Dispose(const FunctionCallbackInfo<Value>& args) {
return;
}
THROW_AND_RETURN_IF_STEPPING(env, stmt);
THROW_AND_RETURN_IF_BUSY_IN_AUTHORIZER(env, stmt);
stmt->Close();
}
+2 -3
View File
@@ -299,9 +299,8 @@ class DatabaseSync : public BaseObject {
void DecrementAuthorizerDepth() { --authorizer_depth_; }
bool IsInAuthorizerCallback() const { return authorizer_depth_ > 0; }
// Finalizing a statement frees its virtual machine, so a callback that
// SQLite invokes from inside sqlite3_step() must not finalize the statement
// being stepped. Other statements on the connection are safe to finalize.
// A callback must not finalize the statement being stepped. Other statements
// are safe unless they are busy during an authorizer callback.
void PushSteppingStatement(sqlite3_stmt* stmt) {
stepping_statements_.push_back(stmt);
}
+39 -4
View File
@@ -397,10 +397,8 @@ suite('authorizer callback reentrancy', () => {
assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases));
});
// Only the statement being stepped is unsafe to finalize. Other statements
// on the connection have their own virtual machines, so finalizing them from
// a callback is allowed.
it('allows finalizing a statement that is not being executed', () => {
// An idle statement has no virtual-machine state or locks to release.
it('allows finalizing an idle statement', () => {
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE t (x INTEGER)');
db.exec('INSERT INTO t VALUES (1)');
@@ -417,6 +415,43 @@ suite('authorizer callback reentrancy', () => {
});
});
// A paused iterator is busy and may hold locks between sqlite3_step() calls.
it('rejects finalizing another active statement', () => {
for (const method of ['close', 'dispose']) {
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE t (x INTEGER)');
db.exec('INSERT INTO t VALUES (1), (2), (3)');
const stmt = db.prepare('SELECT x FROM t');
const iter = stmt.iterate();
iter.next();
let outcome = 'authorizer callback did not run';
db.setAuthorizer((actionCode) => {
if (actionCode === constants.SQLITE_DROP_TABLE) {
try {
if (method === 'close') {
stmt.close();
} else {
stmt[Symbol.dispose]();
}
outcome = 'did not throw';
} catch (err) {
outcome = `${err.code}: ${err.message}`;
}
}
return constants.SQLITE_OK;
});
assert.throws(() => db.exec('DROP TABLE t'), {
code: 'ERR_SQLITE_ERROR',
message: 'database table is locked',
});
assert.strictEqual(outcome, expectedError);
db.setAuthorizer(null);
iter.return();
}
});
// Disposal is idempotent, so a statement that is already finalized must stay
// a no-op even inside a callback. Throwing here would turn a `using` scope's
// real exception into a SuppressedError.