Files
node/test/parallel/test-sqlite-udf-close.js
T
Trevor Burnham c4b755ee00 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 the 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 it is on the stack. Covering every authorizer
invocation, including the re-prepare that SQLite can run during
sqlite3_step(), exposed a second and distinct hazard: reentering a
statement that is currently being stepped is a use-after-free rather
than a contract violation, since finalizing it frees the virtual machine
under sqlite3_step() and re-running it resets that machine
mid-execution. Any callback SQLite invokes during execution can reach
it, so a user-defined function is enough. Track the statements currently
being stepped and reject reentry into only those, which leaves a
user-defined function free to prepare, run, and finalize its own helper
statements.

Signed-off-by: Trevor Burnham <trevorburnham@gmail.com>
Fixes: https://github.com/nodejs/node/issues/63207
Assisted-by: claude:opus-5
PR-URL: https://github.com/nodejs/node/pull/65156
Reviewed-By: Xuguang Mei <meixuguang@gmail.com>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
2026-08-14 21:43:35 +00:00

243 lines
7.5 KiB
JavaScript

'use strict';
const { skipIfSQLiteMissing } = require('../common');
skipIfSQLiteMissing();
const assert = require('node:assert');
const { test } = require('node:test');
const { DatabaseSync } = require('node:sqlite');
for (const method of ['all', 'get', 'run', 'iterate']) {
test(`database.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);
`);
db.function('close_db', (value) => {
db.close();
return value;
});
const statement = db.prepare('SELECT close_db(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: 'database cannot be closed while in a callback',
});
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 is already being executed',
});
db.close();
});
// Re-running the statement being stepped resets its virtual machine
// mid-execution, which is the same use-after-free as finalizing it.
for (const reentrant of ['run', 'get', 'all', 'iterate']) {
test(`statement.${reentrant}() from a UDF during ` +
`statement.${method}()`, () => {
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE data (value INTEGER, padding TEXT);
INSERT INTO data VALUES (1, '${'x'.repeat(400)}'),
(2, '${'y'.repeat(400)}'),
(3, '${'z'.repeat(400)}');
`);
let statement;
let thrown;
db.function('reenter', (value) => {
if (thrown === undefined) {
try {
statement[reentrant]();
thrown = null;
} catch (err) {
thrown = err;
}
}
return value;
});
statement = db.prepare('SELECT reenter(value), padding FROM data');
if (method === 'iterate') {
for (const row of statement.iterate()) {
assert.ok(row);
}
} else {
statement[method]();
}
assert.ok(thrown, `${reentrant}() was not rejected`);
assert.strictEqual(thrown.code, 'ERR_INVALID_STATE');
assert.strictEqual(thrown.message, 'statement is already being executed');
db.close();
});
}
// Tag store methods resolve to a cached statement, which may be the one
// currently being stepped. Each reentrant method has its own guard, so all
// four are exercised.
for (const reentrant of ['run', 'get', 'all', 'iterate']) {
test(`tag store ${reentrant} reentry during statement.${method}()`, () => {
const db = new DatabaseSync(':memory:');
const sql = db.createTagStore(10);
db.exec(`
CREATE TABLE data (value INTEGER, padding TEXT);
INSERT INTO data VALUES (1, '${'x'.repeat(400)}'),
(2, '${'y'.repeat(400)}');
`);
let thrown;
db.function('reenter_tag', (value) => {
if (thrown === undefined) {
try {
// The identical tagged literal resolves to the same cached
// statement that is mid-execution.
// All four reject at call time, iterate() included, so the
// result is never consumed.
// eslint-disable-next-line no-unused-expressions
sql[reentrant]`SELECT reenter_tag(value), padding FROM data`;
thrown = null;
} catch (err) {
thrown = err;
}
}
return value;
});
if (method === 'iterate') {
for (const row of sql.iterate`SELECT reenter_tag(value), padding FROM data`) {
assert.ok(row);
}
} else {
// eslint-disable-next-line no-unused-expressions
sql[method]`SELECT reenter_tag(value), padding FROM data`;
}
assert.ok(thrown, `tag store ${reentrant} reentry was not rejected`);
assert.strictEqual(thrown.code, 'ERR_INVALID_STATE');
assert.strictEqual(thrown.message,
'statement is already 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();
});
}
// iterator.return() resets the statement it is iterating, and next() steps it
// again, so both reach the virtual machine that is mid-execution.
for (const op of ['next', 'return']) {
test(`iterator.${op}() from a UDF during iteration`, () => {
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE data (value INTEGER, padding TEXT);
INSERT INTO data VALUES (1, '${'x'.repeat(400)}'),
(2, '${'y'.repeat(400)}'),
(3, '${'z'.repeat(400)}');
`);
let iterator;
let thrown;
db.function('reenter_iter', (value) => {
if (thrown === undefined && iterator !== undefined) {
try {
iterator[op]();
thrown = null;
} catch (err) {
thrown = err;
}
}
return value;
});
const statement = db.prepare(
'SELECT reenter_iter(value) AS v, padding FROM data');
iterator = statement.iterate();
for (const row of iterator) {
assert.ok(row);
}
assert.ok(thrown, `iterator.${op}() was not rejected`);
assert.strictEqual(thrown.code, 'ERR_INVALID_STATE');
assert.strictEqual(thrown.message, 'statement is already being executed');
db.close();
});
}