mirror of
https://github.com/nodejs/node.git
synced 2026-11-09 15:11:11 -05:00
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>
243 lines
7.5 KiB
JavaScript
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();
|
|
});
|
|
}
|