From 47307a2a93afc15bd79262095b3d2228281f62d7 Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Sat, 8 Aug 2026 19:56:16 -0400 Subject: [PATCH] sqlite: reject connection access from authorizer callbacks 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 Fixes: https://github.com/nodejs/node/issues/63207 Assisted-by: claude:opus-5 --- doc/api/sqlite.md | 22 +++ src/node_sqlite.cc | 81 ++++++++- src/node_sqlite.h | 49 +++++ test/parallel/test-sqlite-authz.js | 239 ++++++++++++++++++++++++- test/parallel/test-sqlite-udf-close.js | 69 +++++++ 5 files changed, 455 insertions(+), 5 deletions(-) diff --git a/doc/api/sqlite.md b/doc/api/sqlite.md index 57a0007baabc..923f9ad9e231 100644 --- a/doc/api/sqlite.md +++ b/doc/api/sqlite.md @@ -439,6 +439,11 @@ wrapper around [`sqlite3_create_function_v2()`][]. * `callback` {Function|null} The authorizer function to set, or `null` to @@ -464,6 +469,23 @@ The callback must return one of the following constants: * `SQLITE_DENY` - Deny the operation (causes an error). * `SQLITE_IGNORE` - Ignore the operation (silently skip). +SQLite requires that the authorizer callback not modify the database connection +that invoked it, which includes preparing and stepping statements. Methods that +would do so throw an error with code `ERR_INVALID_STATE` while the callback is +on the stack, including `database.prepare()`, `database.exec()`, the execution +methods of that connection's statements, iterators, and tag stores, and +`database.setAuthorizer()` itself. Other connections remain usable. + +The callback can also be invoked from within `statement.run()`, +`statement.get()`, and similar methods, because SQLite may re-prepare a +statement during execution after a schema change. + +Separately, `statement.close()` throws if the statement is the one currently +being executed, because finalizing it would free the virtual machine that is +running. This applies to any callback SQLite invokes during execution, such as +a user-defined function. Other statements on the connection can still be +finalized. + ```cjs const { DatabaseSync, constants } = require('node:sqlite'); const db = new DatabaseSync(':memory:'); diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 027372e42daa..6ebe56dd9787 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -96,6 +96,25 @@ inline MaybeLocal Utf8StringMaybeOneByte(Isolate* isolate, } \ } while (0) +// SQLite requires that an authorizer callback not modify the connection that +// invoked it. Preparing and stepping statements both count as modifying it. +// See https://www.sqlite.org/c3ref/set_authorizer.html. +#define THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db) \ + THROW_AND_RETURN_ON_BAD_STATE( \ + (env), \ + (db)->IsInAuthorizerCallback(), \ + "database cannot be accessed from an authorizer callback") + +// Finalizing a statement frees its virtual machine. A callback that SQLite +// invokes from inside sqlite3_step() therefore must not finalize the statement +// being stepped, which would be a use-after-free rather than merely a contract +// violation. Finalizing other statements on the connection stays allowed. +#define THROW_AND_RETURN_IF_STEPPING(env, stmt) \ + THROW_AND_RETURN_ON_BAD_STATE( \ + (env), \ + (stmt)->db_->IsSteppingStatement((stmt)->statement_), \ + "statement cannot be finalized while it is being executed") + #define SQLITE_VALUE_TO_JS(from, isolate, use_big_int_args, result, ...) \ do { \ switch (sqlite3_##from##_type(__VA_ARGS__)) { \ @@ -825,6 +844,12 @@ Intercepted DatabaseSyncLimits::LimitsSetter( return Intercepted::kYes; } + if (limits->database_->IsInAuthorizerCallback()) { + THROW_ERR_INVALID_STATE( + env, "database cannot be accessed from an authorizer callback"); + return Intercepted::kYes; + } + if (!value->IsNumber()) { THROW_ERR_INVALID_ARG_TYPE( isolate, "Limit value must be a non-negative integer or Infinity."); @@ -1081,6 +1106,7 @@ void DatabaseSync::CreateTagStore(const FunctionCallbackInfo& args) { THROW_ERR_INVALID_STATE(env, "database is not open"); return; } + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); int capacity = 1000; if (args.Length() > 0 && !args[0]->IsUndefined()) { if (!args[0]->IsNumber()) { @@ -1483,6 +1509,7 @@ void DatabaseSync::Prepare(const FunctionCallbackInfo& args) { ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); if (!args[0]->IsString()) { THROW_ERR_INVALID_ARG_TYPE(env->isolate(), @@ -1606,6 +1633,7 @@ void DatabaseSync::Exec(const FunctionCallbackInfo& args) { ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); if (!args[0]->IsString()) { THROW_ERR_INVALID_ARG_TYPE(env->isolate(), @@ -1630,6 +1658,7 @@ void DatabaseSync::CustomFunction(const FunctionCallbackInfo& args) { ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); if (!args[0]->IsString()) { THROW_ERR_INVALID_ARG_TYPE(env->isolate(), @@ -1803,6 +1832,7 @@ void DatabaseSync::Serialize(const FunctionCallbackInfo& args) { ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); std::string db_name = "main"; if (!args[0]->IsUndefined()) { @@ -1858,6 +1888,7 @@ void DatabaseSync::Deserialize(const FunctionCallbackInfo& args) { ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); if (!args[0]->IsUint8Array()) { THROW_ERR_INVALID_ARG_TYPE(env->isolate(), @@ -1933,6 +1964,7 @@ void DatabaseSync::AggregateFunction(const FunctionCallbackInfo& args) { ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); Utf8Value name(env->isolate(), args[0].As()); Local options = args[1].As(); Local start_v; @@ -2144,6 +2176,7 @@ void DatabaseSync::CreateSession(const FunctionCallbackInfo& args) { DatabaseSync* db; ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); sqlite3_session* pSession; int r = sqlite3session_create(db->connection_, db_name.c_str(), &pSession); @@ -2313,6 +2346,7 @@ void DatabaseSync::ApplyChangeset(const FunctionCallbackInfo& args) { ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); if (!args[0]->IsUint8Array()) { THROW_ERR_INVALID_ARG_TYPE( @@ -2447,6 +2481,7 @@ void DatabaseSync::EnableLoadExtension( ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); Isolate* isolate = env->isolate(); if (!args[0]->IsBoolean()) { @@ -2475,6 +2510,7 @@ void DatabaseSync::EnableDefensive(const FunctionCallbackInfo& args) { ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); Isolate* isolate = env->isolate(); if (!args[0]->IsBoolean()) { @@ -2500,6 +2536,7 @@ void DatabaseSync::LoadExtension(const FunctionCallbackInfo& args) { env, !db->allow_load_extension_, "extension loading is not allowed"); THROW_AND_RETURN_ON_BAD_STATE( env, !db->enable_load_extension_, "extension loading is not allowed"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); if (!args[0]->IsString()) { THROW_ERR_INVALID_ARG_TYPE(env->isolate(), @@ -2528,6 +2565,7 @@ void DatabaseSync::SetAuthorizer(const FunctionCallbackInfo& args) { ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db); Isolate* isolate = env->isolate(); @@ -2564,6 +2602,7 @@ int DatabaseSync::AuthorizerCallback(void* user_data, const char* param4) { DatabaseSync* db = static_cast(user_data); CallbackDepthGuard guard(db); + AuthorizerDepthGuard authorizer_guard(db); Environment* env = db->env(); Isolate* isolate = env->isolate(); HandleScope handle_scope(isolate); @@ -2677,12 +2716,20 @@ void StatementSync::Close(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE( env, stmt->IsFinalized(), "statement has been finalized"); + THROW_AND_RETURN_IF_STEPPING(env, stmt); stmt->Close(); } void StatementSync::Dispose(const FunctionCallbackInfo& args) { StatementSync* stmt; ASSIGN_OR_RETURN_UNWRAP(&stmt, args.This()); + Environment* env = Environment::GetCurrent(args); + // Disposal is idempotent, so an already-finalized statement is a no-op even + // inside a callback. + if (stmt->IsFinalized()) { + return; + } + THROW_AND_RETURN_IF_STEPPING(env, stmt); stmt->Close(); } @@ -2963,6 +3010,7 @@ MaybeLocal StatementExecutionHelper::All(Environment* env, LocalVector row_values(isolate); LocalVector row_keys(isolate); + SteppingStatementGuard stepping(db, stmt); while ((r = sqlite3_step(stmt)) == SQLITE_ROW) { if (num_cols == 0) { num_cols = sqlite3_column_count(stmt); @@ -3005,7 +3053,10 @@ MaybeLocal StatementExecutionHelper::Run(Environment* env, bool use_big_ints) { Isolate* isolate = env->isolate(); EscapableHandleScope scope(isolate); - sqlite3_step(stmt); + { + SteppingStatementGuard stepping(db, stmt); + sqlite3_step(stmt); + } int r = sqlite3_reset(stmt); CHECK_ERROR_OR_THROW(isolate, db, r, SQLITE_OK, MaybeLocal()); @@ -3083,7 +3134,11 @@ MaybeLocal StatementExecutionHelper::Get(Environment* env, EscapableHandleScope scope(isolate); auto reset = OnScopeLeave([&]() { sqlite3_reset(stmt); }); - int r = sqlite3_step(stmt); + int r; + { + SteppingStatementGuard stepping(db, stmt); + r = sqlite3_step(stmt); + } if (r == SQLITE_DONE) return scope.Escape(Undefined(isolate)); if (r != SQLITE_ROW) { THROW_ERR_SQLITE_ERROR(isolate, db); @@ -3127,6 +3182,7 @@ void StatementSync::All(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE( env, stmt->IsFinalized(), "statement has been finalized"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, stmt->db_.get()); Isolate* isolate = env->isolate(); int r = stmt->ResetStatement(); CHECK_ERROR_OR_THROW(isolate, stmt->db_.get(), r, SQLITE_OK, void()); @@ -3154,6 +3210,7 @@ void StatementSync::Iterate(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE( env, stmt->IsFinalized(), "statement has been finalized"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, stmt->db_.get()); int r = stmt->ResetStatement(); CHECK_ERROR_OR_THROW(env->isolate(), stmt->db_.get(), r, SQLITE_OK, void()); @@ -3177,6 +3234,7 @@ void StatementSync::Get(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE( env, stmt->IsFinalized(), "statement has been finalized"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, stmt->db_.get()); int r = stmt->ResetStatement(); CHECK_ERROR_OR_THROW(env->isolate(), stmt->db_.get(), r, SQLITE_OK, void()); @@ -3201,6 +3259,7 @@ void StatementSync::Run(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE( env, stmt->IsFinalized(), "statement has been finalized"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, stmt->db_.get()); int r = stmt->ResetStatement(); CHECK_ERROR_OR_THROW(env->isolate(), stmt->db_.get(), r, SQLITE_OK, void()); @@ -3483,6 +3542,7 @@ void SQLTagStore::Run(const FunctionCallbackInfo& args) { THROW_AND_RETURN_ON_BAD_STATE( env, !session->database_->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, session->database_.get()); BaseObjectPtr stmt = PrepareStatement(args); @@ -3509,6 +3569,7 @@ void SQLTagStore::Iterate(const FunctionCallbackInfo& args) { THROW_AND_RETURN_ON_BAD_STATE( env, !session->database_->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, session->database_.get()); BaseObjectPtr stmt = PrepareStatement(args); @@ -3537,6 +3598,7 @@ void SQLTagStore::Get(const FunctionCallbackInfo& args) { THROW_AND_RETURN_ON_BAD_STATE( env, !session->database_->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, session->database_.get()); BaseObjectPtr stmt = PrepareStatement(args); @@ -3566,6 +3628,7 @@ void SQLTagStore::All(const FunctionCallbackInfo& args) { THROW_AND_RETURN_ON_BAD_STATE( env, !session->database_->IsOpen(), "database is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, session->database_.get()); BaseObjectPtr stmt = PrepareStatement(args); @@ -3592,6 +3655,10 @@ void SQLTagStore::All(const FunctionCallbackInfo& args) { void SQLTagStore::Clear(const FunctionCallbackInfo& args) { SQLTagStore* store; ASSIGN_OR_RETURN_UNWRAP(&store, args.This()); + Environment* env = Environment::GetCurrent(args); + if (store->database_) { + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, store->database_.get()); + } store->sql_tags_.Clear(); } @@ -3785,6 +3852,7 @@ void StatementSyncIterator::Next(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE( env, iter->stmt_->IsFinalized(), "statement has been finalized"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, iter->stmt_->db_.get()); Isolate* isolate = env->isolate(); auto iter_template = getLazyIterTemplate(env); @@ -3807,7 +3875,12 @@ void StatementSyncIterator::Next(const FunctionCallbackInfo& args) { iter->statement_reset_generation_ != iter->stmt_->reset_generation_, "iterator was invalidated"); - int r = sqlite3_step(iter->stmt_->statement_); + int r; + { + SteppingStatementGuard stepping(iter->stmt_->db_.get(), + iter->stmt_->statement_); + r = sqlite3_step(iter->stmt_->statement_); + } if (r != SQLITE_ROW) { CHECK_ERROR_OR_THROW( env->isolate(), iter->stmt_->db_.get(), r, SQLITE_DONE, void()); @@ -3862,6 +3935,7 @@ void StatementSyncIterator::Return(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE( env, iter->stmt_->IsFinalized(), "statement has been finalized"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, iter->stmt_->db_.get()); Isolate* isolate = env->isolate(); sqlite3_reset(iter->stmt_->statement_); @@ -3940,6 +4014,7 @@ void Session::Changeset(const FunctionCallbackInfo& args) { env, !session->database_->IsOpen(), "database is not open"); THROW_AND_RETURN_ON_BAD_STATE( env, session->session_ == nullptr, "session is not open"); + THROW_AND_RETURN_IF_IN_AUTHORIZER(env, session->database_.get()); int nChangeset; void* pChangeset; diff --git a/src/node_sqlite.h b/src/node_sqlite.h index b4446e5db859..2a87f073b4b4 100644 --- a/src/node_sqlite.h +++ b/src/node_sqlite.h @@ -9,6 +9,7 @@ #include "sqlite3.h" #include "util.h" +#include #include #include #include @@ -233,6 +234,26 @@ class DatabaseSync : public BaseObject { void DecrementCallbackDepth() { --callback_depth_; } bool IsInCallback() const { return callback_depth_ > 0; } + // SQLite forbids an authorizer callback from doing anything that modifies + // the database connection that invoked it, which includes preparing and + // stepping statements. See https://www.sqlite.org/c3ref/set_authorizer.html. + void IncrementAuthorizerDepth() { ++authorizer_depth_; } + 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. + void PushSteppingStatement(sqlite3_stmt* stmt) { + stepping_statements_.push_back(stmt); + } + void PopSteppingStatement() { stepping_statements_.pop_back(); } + bool IsSteppingStatement(sqlite3_stmt* stmt) const { + return std::find(stepping_statements_.begin(), + stepping_statements_.end(), + stmt) != stepping_statements_.end(); + } + SET_MEMORY_INFO_NAME(DatabaseSync) SET_SELF_SIZE(DatabaseSync) @@ -247,6 +268,8 @@ class DatabaseSync : public BaseObject { sqlite3* connection_; bool ignore_next_sqlite_error_; int callback_depth_ = 0; + int authorizer_depth_ = 0; + std::vector stepping_statements_; std::set backups_; std::unordered_set sessions_; @@ -426,6 +449,32 @@ class CallbackDepthGuard { DatabaseSync* db_; }; +class SteppingStatementGuard { + public: + SteppingStatementGuard(DatabaseSync* db, sqlite3_stmt* stmt) : db_(db) { + db_->PushSteppingStatement(stmt); + } + ~SteppingStatementGuard() { db_->PopSteppingStatement(); } + SteppingStatementGuard(const SteppingStatementGuard&) = delete; + SteppingStatementGuard& operator=(const SteppingStatementGuard&) = delete; + + private: + DatabaseSync* db_; +}; + +class AuthorizerDepthGuard { + public: + explicit AuthorizerDepthGuard(DatabaseSync* db) : db_(db) { + db_->IncrementAuthorizerDepth(); + } + ~AuthorizerDepthGuard() { db_->DecrementAuthorizerDepth(); } + AuthorizerDepthGuard(const AuthorizerDepthGuard&) = delete; + AuthorizerDepthGuard& operator=(const AuthorizerDepthGuard&) = delete; + + private: + DatabaseSync* db_; +}; + class UserDefinedFunction { public: UserDefinedFunction(Environment* env, diff --git a/test/parallel/test-sqlite-authz.js b/test/parallel/test-sqlite-authz.js index 69c075a57e2e..cab8e5921a59 100644 --- a/test/parallel/test-sqlite-authz.js +++ b/test/parallel/test-sqlite-authz.js @@ -1,7 +1,7 @@ 'use strict'; -const { skipIfSQLiteMissing } = require('../common'); -skipIfSQLiteMissing(); +const common = require('../common'); +common.skipIfSQLiteMissing(); const assert = require('node:assert'); const { DatabaseSync, constants } = require('node:sqlite'); @@ -288,3 +288,238 @@ suite('DatabaseSync.prototype.setAuthorizer()', () => { }); }); }); + +// SQLite forbids an authorizer callback from modifying the connection that +// invoked it, which includes preparing and stepping statements. +// See https://www.sqlite.org/c3ref/set_authorizer.html. +suite('authorizer callback reentrancy', () => { + const expectedError = 'ERR_INVALID_STATE: database cannot be accessed ' + + 'from an authorizer callback'; + const finalizeError = 'ERR_INVALID_STATE: statement cannot be finalized ' + + 'while it is being executed'; + + // Calls each of `cases` from inside an authorizer callback, and returns a + // `name -> outcome` map of what each one threw. + const runInAuthorizer = (db, cases) => { + const outcomes = {}; + for (const [name, fn] of Object.entries(cases)) { + let ran = false; + db.setAuthorizer(() => { + if (!ran) { + ran = true; + try { + fn(); + outcomes[name] = 'did not throw'; + } catch (err) { + outcomes[name] = `${err.code}: ${err.message}`; + } + } + return constants.SQLITE_OK; + }); + db.exec('SELECT 1'); + db.setAuthorizer(null); + if (!ran) { + outcomes[name] = 'authorizer callback did not run'; + } + } + return outcomes; + }; + + // Builds the expected `name -> outcome` map for the given case names. + const allRejected = (cases) => Object.fromEntries( + Object.keys(cases).map((name) => [name, expectedError]), + ); + + it('rejects database methods', () => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (x INTEGER)'); + const cases = { + prepare: () => db.prepare('SELECT 1'), + exec: () => db.exec('SELECT 1'), + setAuthorizer: () => db.setAuthorizer(null), + deserialize: () => db.deserialize(db.serialize()), + createSession: () => db.createSession(), + applyChangeset: () => db.applyChangeset(new Uint8Array([1])), + createTagStore: () => db.createTagStore(), + serialize: () => db.serialize(), + function: () => db.function('noop', () => 1), + aggregate: () => db.aggregate('agg', { start: 0, step: (acc) => acc }), + enableLoadExtension: () => db.enableLoadExtension(false), + limits: () => { db.limits.length = 100; }, + }; + + assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases)); + }); + + it('rejects close(), which the callback depth guard already covers', () => { + const db = new DatabaseSync(':memory:'); + const cases = { close: () => db.close() }; + + assert.deepStrictEqual(runInAuthorizer(db, cases), { + close: 'ERR_INVALID_STATE: database cannot be closed while in a callback', + }); + }); + + it('rejects statement methods', () => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (x INTEGER)'); + db.exec('INSERT INTO t VALUES (1)'); + const stmt = db.prepare('SELECT x FROM t'); + const cases = { + run: () => stmt.run(), + get: () => stmt.get(), + all: () => stmt.all(), + iterate: () => stmt.iterate(), + }; + + 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', () => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (x INTEGER)'); + db.exec('INSERT INTO t VALUES (1)'); + const closeStmt = db.prepare('SELECT x FROM t'); + const disposeStmt = db.prepare('SELECT x FROM t'); + const cases = { + close: () => closeStmt.close(), + dispose: () => disposeStmt[Symbol.dispose](), + }; + + assert.deepStrictEqual(runInAuthorizer(db, cases), { + close: 'did not throw', + dispose: 'did not throw', + }); + }); + + // 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. + it('allows disposing an already-finalized statement', () => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (x INTEGER)'); + const stmt = db.prepare('SELECT x FROM t'); + stmt.close(); + const cases = { dispose: () => stmt[Symbol.dispose]() }; + + assert.deepStrictEqual(runInAuthorizer(db, cases), { + dispose: 'did not throw', + }); + }); + + it('rejects session changeset methods', () => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (x INTEGER PRIMARY KEY, y TEXT)'); + const session = db.createSession({ table: 't' }); + db.exec("INSERT INTO t VALUES (1, 'a')"); + const cases = { + changeset: () => session.changeset(), + patchset: () => session.patchset(), + }; + + assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases)); + }); + + // A statement being re-prepared inside sqlite3_step() is the case that + // actually crashes, because that statement's VM is mid-execution. + it('rejects finalizing the statement being stepped', () => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (x INTEGER)'); + db.exec('INSERT INTO t VALUES (1)'); + const stmt = db.prepare('SELECT x FROM t'); + stmt.get(); + db.exec('ALTER TABLE t ADD COLUMN y INTEGER'); + + let outcome = 'authorizer callback did not run'; + let ran = false; + db.setAuthorizer(() => { + if (!ran) { + ran = true; + try { + stmt.close(); + outcome = 'did not throw'; + } catch (err) { + outcome = `${err.code}: ${err.message}`; + } + } + return constants.SQLITE_OK; + }); + + stmt.get(); + + assert.strictEqual(outcome, finalizeError); + }); + + it('rejects iterator methods', () => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (x INTEGER)'); + db.exec('INSERT INTO t VALUES (1)'); + const iter = db.prepare('SELECT x FROM t').iterate(); + const cases = { + next: () => iter.next(), + return: () => iter.return(), + }; + + assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases)); + iter.return(); + }); + + it('rejects tag store methods', () => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (x INTEGER)'); + const sql = db.createTagStore(10); + const cases = { + run: () => sql.run`SELECT 1`, + get: () => sql.get`SELECT 1`, + all: () => sql.all`SELECT 1`, + iterate: () => sql.iterate`SELECT 1`, + clear: () => sql.clear(), + }; + + assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases)); + }); + + // A statement may be re-prepared during sqlite3_step() after a schema + // change, which invokes the authorizer without an explicit prepare() call. + it('rejects reentry when the authorizer runs during a re-prepare', () => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (x INTEGER)'); + db.exec('INSERT INTO t VALUES (1)'); + const stmt = db.prepare('SELECT x FROM t'); + stmt.get(); + db.exec('ALTER TABLE t ADD COLUMN y INTEGER'); + + let outcome = 'authorizer callback did not run'; + let ran = false; + db.setAuthorizer(() => { + if (!ran) { + ran = true; + try { + db.prepare('SELECT 1'); + outcome = 'did not throw'; + } catch (err) { + outcome = `${err.code}: ${err.message}`; + } + } + return constants.SQLITE_OK; + }); + + stmt.get(); + + assert.strictEqual(outcome, expectedError); + }); + + it('allows access again after the authorizer returns', () => { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (x INTEGER)'); + const cases = { prepare: () => db.prepare('SELECT 1') }; + + assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases)); + + db.setAuthorizer(() => constants.SQLITE_OK); + assert.deepStrictEqual(db.prepare('SELECT 1 AS v').get(), { __proto__: null, v: 1 }); + }); +}); diff --git a/test/parallel/test-sqlite-udf-close.js b/test/parallel/test-sqlite-udf-close.js index 86794029b457..bdb5a42cf7bd 100644 --- a/test/parallel/test-sqlite-udf-close.js +++ b/test/parallel/test-sqlite-udf-close.js @@ -36,4 +36,73 @@ for (const method of ['all', 'get', 'run', 'iterate']) { assert.strictEqual(db.isOpen, true); db.close(); }); + + // Finalizing the statement being stepped frees the virtual machine that + // sqlite3_step() is still running, so this must throw rather than crash. + test(`statement.close() from a UDF during statement.${method}()`, () => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE TABLE data (value INTEGER); + INSERT INTO data VALUES (1), (2), (3); + `); + + let statement; + db.function('close_stmt', (value) => { + statement.close(); + return value; + }); + + statement = db.prepare('SELECT close_stmt(value) FROM data'); + assert.throws(() => { + if (method === 'iterate') { + for (const row of statement.iterate()) { + assert.ok(row); + } + } else { + statement[method](); + } + }, { + code: 'ERR_INVALID_STATE', + message: 'statement cannot be finalized while it is being executed', + }); + + db.close(); + }); + + // A UDF may prepare and finalize its own helper statements. Only the + // statement being stepped is off limits. + test(`UDF finalizes its own statement during statement.${method}()`, () => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE TABLE data (value INTEGER); + INSERT INTO data VALUES (1), (2), (3); + CREATE TABLE lookup (key INTEGER, label TEXT); + INSERT INTO lookup VALUES (1, 'one'), (2, 'two'), (3, 'three'); + `); + + db.function('lookup_label', (value) => { + const helper = db.prepare('SELECT label FROM lookup WHERE key = ?'); + const label = helper.get(value).label; + helper.close(); + return label; + }); + + const statement = db.prepare('SELECT lookup_label(value) AS l FROM data'); + if (method === 'iterate') { + const labels = []; + for (const row of statement.iterate()) { + labels.push(row.l); + } + assert.deepStrictEqual(labels, ['one', 'two', 'three']); + } else if (method === 'all') { + assert.deepStrictEqual(statement.all().map((r) => r.l), + ['one', 'two', 'three']); + } else if (method === 'get') { + assert.strictEqual(statement.get().l, 'one'); + } else { + statement.run(); + } + + db.close(); + }); }