From b7d480e9374c55fd3d73d73f8fdbbb9974c6fe5a Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:20:07 -0700 Subject: [PATCH] sqlite: prevent reentrant session.close() Reject session.close() while generating a changeset or patchset. Closing the session at that point frees it while SQLite is still using it, causing a use-after-free. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: codex:gpt-5.6-sol --- doc/api/sqlite.md | 3 ++- src/node_sqlite.cc | 6 ++++++ src/node_sqlite.h | 1 + test/parallel/test-sqlite-session.js | 22 ++++++++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/doc/api/sqlite.md b/doc/api/sqlite.md index 95d1143a2427..7de0f2d88cea 100644 --- a/doc/api/sqlite.md +++ b/doc/api/sqlite.md @@ -1013,7 +1013,8 @@ wrapper around [`sqlite3session_patchset()`][]. ### `session.close()` -Closes the session. An exception is thrown if the database or the session is not open. This method is a +Closes the session. An exception is thrown if the database or the session is not open, +or if the session is currently generating a changeset or patchset. This method is a wrapper around [`sqlite3session_delete()`][]. ### `session[Symbol.dispose]()` diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index d0da887062bd..cc8d52dcf619 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -4326,6 +4326,10 @@ void Session::Changeset(const FunctionCallbackInfo& args) { env, session->session_ == nullptr, "session is not open"); THROW_AND_RETURN_IF_IN_AUTHORIZER(env, session->database_.get()); + session->is_generating_changeset_ = true; + auto changeset_guard = + OnScopeLeave([&] { session->is_generating_changeset_ = false; }); + int nChangeset; void* pChangeset; int r = sqliteChangesetFunc(session->session_, &nChangeset, &pChangeset); @@ -4351,6 +4355,8 @@ void Session::Close(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_ON_BAD_STATE( + env, session->is_generating_changeset_, "session is currently in use"); session->Delete(); } diff --git a/src/node_sqlite.h b/src/node_sqlite.h index 475a759e75e8..557d907fd737 100644 --- a/src/node_sqlite.h +++ b/src/node_sqlite.h @@ -466,6 +466,7 @@ class Session : public BaseObject { void Delete(); sqlite3_session* session_; BaseObjectPtr database_; // The Parent Database + bool is_generating_changeset_ = false; friend class DatabaseSync; }; diff --git a/test/parallel/test-sqlite-session.js b/test/parallel/test-sqlite-session.js index 2a72740bf899..a8bbaa77d064 100644 --- a/test/parallel/test-sqlite-session.js +++ b/test/parallel/test-sqlite-session.js @@ -612,6 +612,28 @@ test('session.close() - closing twice', (t) => { }); }); +test('session.close() - while generating changes throws exception', (t) => { + for (const method of ['changeset', 'patchset']) { + const database = new DatabaseSync(':memory:'); + database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, value TEXT)'); + + const session = database.createSession({ table: 'data' }); + database.exec("INSERT INTO data VALUES (1, 'a'), (2, 'b'), (3, 'c')"); + database.setAuthorizer(() => { + session.close(); + return constants.SQLITE_OK; + }); + + t.assert.throws(() => session[method](), { + code: 'ERR_INVALID_STATE', + message: 'session is currently in use', + }); + + database.setAuthorizer(null); + t.assert.notStrictEqual(session[method]().length, 0); + } +}); + test('session - keeps its database alive after the db handle is dropped', async (t) => { const { gcUntil, onGC } = require('../common/gc');