Skip to content

Commit 01c1300

Browse files
TrevorBurnhamtrivikr
authored andcommitted
sqlite: reject closing a session from a callback
SQLite runs "PRAGMA table_xinfo" from inside its pre-update hook, while it is still walking the connection's session list. Deleting a session from a callback that the PRAGMA triggers frees memory that walk is still using. Both an authorizer callback and a 'sqlite.db.query' subscriber reach that window, and either one segfaults. Reject session.close() and Symbol.dispose when the connection is inside any callback. An already-closed session stays a no-op so that disposal remains idempotent. Fixes: #65428 Signed-off-by: Trevor Burnham <trevorburnham@gmail.com> PR-URL: #65454 Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
1 parent f7e2c14 commit 01c1300

3 files changed

Lines changed: 174 additions & 4 deletions

File tree

doc/api/sqlite.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,8 +1014,11 @@ wrapper around [`sqlite3session_patchset()`][].
10141014
### `session.close()`
10151015

10161016
Closes the session. An exception is thrown if the database or the session is not open,
1017-
or if the session is currently generating a changeset or patchset. This method is a
1018-
wrapper around [`sqlite3session_delete()`][].
1017+
or if the session is currently generating a changeset or patchset. An
1018+
[`ERR_INVALID_STATE`][] error is thrown if the method is called from a callback that
1019+
SQLite invoked, such as an authorizer callback, a user-defined function, or a
1020+
[`'sqlite.db.query'`][] subscriber, because SQLite may still be using the session.
1021+
This method is a wrapper around [`sqlite3session_delete()`][].
10191022

10201023
### `session[Symbol.dispose]()`
10211024

@@ -1025,7 +1028,8 @@ added: v24.9.0
10251028

10261029
Closes the session. If the session is already closed, then this is a no-op. An
10271030
[`ERR_INVALID_STATE`][] error is thrown if the session is currently generating
1028-
a changeset or patchset, under the same conditions as [`session.close()`][].
1031+
a changeset or patchset, or if the method is called from a callback that SQLite
1032+
invoked, under the same conditions as [`session.close()`][].
10291033

10301034
## Class: `StatementSync`
10311035

src/node_sqlite.cc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,16 @@ inline MaybeLocal<Value> IntegerToValue(Isolate* isolate,
169169
sqlite3_stmt_busy((stmt)->statement_.get()), \
170170
"database cannot be accessed from an authorizer callback")
171171

172+
// SQLite's session module reaches back into JavaScript from inside the
173+
// pre-update hook, while it is still walking the connection's session list and
174+
// reading the table it found there. Deleting a session frees memory that walk
175+
// is still using, so no callback may close one.
176+
#define THROW_AND_RETURN_IF_SESSION_IN_CALLBACK(env, session) \
177+
THROW_AND_RETURN_ON_BAD_STATE( \
178+
(env), \
179+
(session)->database_->IsInCallback(), \
180+
"session cannot be closed while in a callback")
181+
172182
// A statement's virtual machine cannot be reentered while sqlite3_step() is
173183
// running it. Finalizing it frees the VM outright, and re-running it resets the
174184
// VM mid-execution; both are use-after-free rather than merely a contract
@@ -4358,6 +4368,9 @@ void Session::Close(const FunctionCallbackInfo<Value>& args) {
43584368
env, session->session_ == nullptr, "session is not open");
43594369
THROW_AND_RETURN_ON_BAD_STATE(
43604370
env, session->is_generating_changeset_, "session is currently in use");
4371+
// Checked last: changeset generation runs the authorizer, so both conditions
4372+
// hold in that case and the more specific message above has to win.
4373+
THROW_AND_RETURN_IF_SESSION_IN_CALLBACK(env, session);
43614374

43624375
session->Delete();
43634376
}
@@ -4371,6 +4384,7 @@ void Session::Dispose(const FunctionCallbackInfo<Value>& args) {
43714384
}
43724385
THROW_AND_RETURN_ON_BAD_STATE(
43734386
env, session->is_generating_changeset_, "session is currently in use");
4387+
THROW_AND_RETURN_IF_SESSION_IN_CALLBACK(env, session);
43744388

43754389
session->Delete();
43764390
}

test/parallel/test-sqlite-session.js

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ const {
66
DatabaseSync,
77
constants,
88
} = require('node:sqlite');
9-
const { test, suite } = require('node:test');
9+
const { it, test, suite } = require('node:test');
10+
const dc = require('node:diagnostics_channel');
1011
const { nextDb } = require('../sqlite/next-db.js');
1112
const { Worker } = require('worker_threads');
1213
const { once } = require('events');
@@ -652,6 +653,157 @@ test('session[Symbol.dispose]() - after closing database is a no-op', () => {
652653
session[Symbol.dispose]();
653654
});
654655

656+
// SQLite runs "PRAGMA table_xinfo" from inside its pre-update hook, while it is
657+
// still walking the connection's session list. Deleting a session from a
658+
// callback that PRAGMA triggers frees memory the walk is still using, so the
659+
// close has to be rejected instead.
660+
suite('session.close() - from a callback', () => {
661+
const expectedError =
662+
'ERR_INVALID_STATE: session cannot be closed while in a callback';
663+
664+
for (const method of ['close', 'dispose']) {
665+
const closeSession = (session) => {
666+
if (method === 'close') {
667+
session.close();
668+
} else {
669+
session[Symbol.dispose]();
670+
}
671+
};
672+
673+
it(`rejects ${method} from an authorizer callback`, (t) => {
674+
const database = new DatabaseSync(':memory:');
675+
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
676+
const session = database.createSession();
677+
let outcome = 'callback did not run';
678+
679+
database.setAuthorizer((actionCode, param1) => {
680+
if (actionCode === constants.SQLITE_PRAGMA && param1 === 'table_xinfo') {
681+
try {
682+
closeSession(session);
683+
outcome = 'did not throw';
684+
} catch (err) {
685+
outcome = `${err.code}: ${err.message}`;
686+
}
687+
}
688+
return constants.SQLITE_OK;
689+
});
690+
691+
database.exec('INSERT INTO data VALUES (1)');
692+
t.assert.strictEqual(outcome, expectedError);
693+
694+
// The session survived and kept recording the insert.
695+
database.setAuthorizer(null);
696+
t.assert.notStrictEqual(session.changeset().length, 0);
697+
session.close();
698+
});
699+
700+
it(`rejects ${method} from a 'sqlite.db.query' subscriber`, (t) => {
701+
const database = new DatabaseSync(':memory:');
702+
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
703+
const session = database.createSession();
704+
let outcome = 'callback did not run';
705+
706+
const handler = ({ sql }) => {
707+
if (sql.includes('table_xinfo')) {
708+
try {
709+
closeSession(session);
710+
outcome = 'did not throw';
711+
} catch (err) {
712+
outcome = `${err.code}: ${err.message}`;
713+
}
714+
}
715+
};
716+
dc.subscribe('sqlite.db.query', handler);
717+
t.after(() => dc.unsubscribe('sqlite.db.query', handler));
718+
719+
database.exec('INSERT INTO data VALUES (1)');
720+
t.assert.strictEqual(outcome, expectedError);
721+
722+
dc.unsubscribe('sqlite.db.query', handler);
723+
t.assert.notStrictEqual(session.changeset().length, 0);
724+
session.close();
725+
});
726+
727+
// Deliberately broader than the crash: the pre-update hook is not on the
728+
// stack here, so this close is safe today. Node cannot tell whether SQLite
729+
// is inside that hook, so every callback is rejected. This pins the
730+
// trade-off rather than leaving it to be discovered as a regression.
731+
it(`rejects ${method} from a user-defined function`, (t) => {
732+
const database = new DatabaseSync(':memory:');
733+
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
734+
const session = database.createSession();
735+
let outcome = 'callback did not run';
736+
737+
database.function('f', (x) => {
738+
try {
739+
closeSession(session);
740+
outcome = 'did not throw';
741+
} catch (err) {
742+
outcome = `${err.code}: ${err.message}`;
743+
}
744+
return x;
745+
});
746+
747+
database.exec('SELECT f(1)');
748+
t.assert.strictEqual(outcome, expectedError);
749+
750+
// Still closable once the callback is off the stack.
751+
session.close();
752+
t.assert.throws(() => session.close(), { message: 'session is not open' });
753+
});
754+
}
755+
756+
// Rejecting disposal has a cost: a `using` declaration inside a callback
757+
// demotes the block's own error to SuppressedError. Accepted for symmetry
758+
// with StatementSync's disposal, which throws for a busy statement the same
759+
// way. Pinned here so the trade-off is visible rather than surprising.
760+
it('demotes a callback error when disposal is rejected', (t) => {
761+
const database = new DatabaseSync(':memory:');
762+
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
763+
let caught;
764+
765+
database.function('f', (x) => {
766+
try {
767+
using session = database.createSession();
768+
t.assert.ok(session);
769+
throw new Error('callback error');
770+
} catch (err) {
771+
caught = err;
772+
}
773+
return x;
774+
});
775+
776+
database.exec('SELECT f(1)');
777+
t.assert.ok(caught instanceof SuppressedError);
778+
t.assert.strictEqual(caught.suppressed.message, 'callback error');
779+
t.assert.strictEqual(
780+
caught.error.message,
781+
'session cannot be closed while in a callback',
782+
);
783+
});
784+
785+
it('leaves an already closed session disposable from a callback', (t) => {
786+
const database = new DatabaseSync(':memory:');
787+
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
788+
const session = database.createSession();
789+
session.close();
790+
let outcome = 'callback did not run';
791+
792+
database.setAuthorizer(() => {
793+
try {
794+
session[Symbol.dispose]();
795+
outcome = 'no-op';
796+
} catch (err) {
797+
outcome = `${err.code}: ${err.message}`;
798+
}
799+
return constants.SQLITE_OK;
800+
});
801+
802+
database.exec('INSERT INTO data VALUES (1)');
803+
t.assert.strictEqual(outcome, 'no-op');
804+
});
805+
});
806+
655807
test('session - keeps its database alive after the db handle is dropped', async (t) => {
656808
const { gcUntil, onGC } = require('../common/gc');
657809

0 commit comments

Comments
 (0)