Files
node/test/parallel/test-sqlite-authz.js
Guilherme Araújo 5ebe0ebf65 sqlite: restore connection state on reopen
close() destroys the connection but keeps the DatabaseSync object, and
open() did not replay the state held on it.

An authorizer set with setAuthorizer() was not reinstalled, silently
dropping a deny-all policy. Limits written through db.limits.* reverted
to the constructor values. Extension loading was re-enabled from the
constructor ceiling rather than the current setting, so the connection
flag contradicted a previous enableLoadExtension(false), though
loadExtension() itself stayed blocked by its own check.

Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
Assisted-by: Claude Code
PR-URL: https://github.com/nodejs/node/pull/66042
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
2026-10-01 05:39:07 +00:00

683 lines
21 KiB
JavaScript

'use strict';
const common = require('../common');
common.skipIfSQLiteMissing();
const assert = require('node:assert');
const { Database, constants } = require('node:sqlite');
const { suite, it } = require('node:test');
suite('Database.prototype.setAuthorizer()', () => {
const createTestDatabase = () => {
const db = new Database(':memory:');
db.exec('CREATE TABLE users (id INTEGER, name TEXT)');
return db;
};
it('receives correct parameters for SELECT operations', (t) => {
const authorizer = t.mock.fn(() => constants.SQLITE_OK);
const db = createTestDatabase();
db.setAuthorizer(authorizer);
db.prepare('SELECT id FROM users').get();
assert.strictEqual(authorizer.mock.callCount(), 2);
const callArguments = authorizer.mock.calls.map((call) => call.arguments);
assert.deepStrictEqual(
callArguments,
[
[constants.SQLITE_SELECT, null, null, null, null],
[constants.SQLITE_READ, 'users', 'id', 'main', null],
]
);
});
it('receives correct parameters for INSERT operations', (t) => {
const authorizer = t.mock.fn(() => constants.SQLITE_OK);
const db = createTestDatabase();
db.setAuthorizer(authorizer);
db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run(1, 'node');
assert.strictEqual(authorizer.mock.callCount(), 1);
const callArguments = authorizer.mock.calls.map((call) => call.arguments);
assert.deepStrictEqual(
callArguments,
[[constants.SQLITE_INSERT, 'users', null, 'main', null]],
);
});
it('allows operations when authorizer returns SQLITE_OK', () => {
const db = new Database(':memory:');
db.setAuthorizer(() => constants.SQLITE_OK);
db.exec('CREATE TABLE users (id INTEGER, name TEXT)');
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
assert.strictEqual(tables[0].name, 'users');
});
it('blocks operations when authorizer returns SQLITE_DENY', () => {
const db = new Database(':memory:');
db.setAuthorizer(() => constants.SQLITE_DENY);
assert.throws(() => {
db.exec('SELECT 1');
}, {
code: 'ERR_SQLITE_ERROR',
message: /not authorized/
});
});
it('ignores SELECT operations when authorizer returns SQLITE_IGNORE', () => {
const db = createTestDatabase();
db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run(1, 'Alice');
db.setAuthorizer((actionCode) => {
if (actionCode === constants.SQLITE_SELECT) {
return constants.SQLITE_IGNORE;
}
return constants.SQLITE_OK;
});
// SELECT should be ignored and return no results
const result = db.prepare('SELECT * FROM users').all();
assert.deepStrictEqual(result, []);
});
it('ignores READ operations when authorizer returns SQLITE_IGNORE', () => {
const db = createTestDatabase();
db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run(1, 'Alice');
db.setAuthorizer((actionCode, arg1, arg2) => {
if (actionCode === constants.SQLITE_READ && arg1 === 'users' && arg2 === 'name') {
return constants.SQLITE_IGNORE;
}
return constants.SQLITE_OK;
});
// Reading the 'name' column should be ignored, returning NULL
const result = db.prepare('SELECT id, name FROM users WHERE id = 1').get();
assert.strictEqual(result.id, 1);
assert.strictEqual(result.name, null);
});
it('ignores INSERT operations when authorizer returns SQLITE_IGNORE', () => {
const db = createTestDatabase();
db.setAuthorizer((actionCode) => {
if (actionCode === constants.SQLITE_INSERT) {
return constants.SQLITE_IGNORE;
}
return constants.SQLITE_OK;
});
db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run(1, 'Alice');
// Verify no data was inserted
const count = db.prepare('SELECT COUNT(*) as count FROM users').get();
assert.strictEqual(count.count, 0);
});
it('ignores UPDATE operations when authorizer returns SQLITE_IGNORE', () => {
const db = createTestDatabase();
db.exec("INSERT INTO users (id, name) VALUES (1, 'Alice')");
db.setAuthorizer((actionCode) => {
if (actionCode === constants.SQLITE_UPDATE) {
return constants.SQLITE_IGNORE;
}
return constants.SQLITE_OK;
});
db.prepare('UPDATE users SET name = ? WHERE id = ?').run('Bob', 1);
// Verify data was not updated
const result = db.prepare('SELECT name FROM users WHERE id = 1').get();
assert.strictEqual(result.name, 'Alice');
});
it('ignores DELETE operations when authorizer returns SQLITE_IGNORE', () => {
const db = createTestDatabase();
db.exec("INSERT INTO users (id, name) VALUES (1, 'Alice')");
db.setAuthorizer(() => constants.SQLITE_IGNORE);
db.prepare('DELETE FROM users WHERE id = ?').run(1);
db.setAuthorizer(null);
// Verify data was not deleted
const count = db.prepare('SELECT COUNT(*) as count FROM users').get();
assert.strictEqual(count.count, 1);
});
it('rethrows error when authorizer throws error', () => {
const db = new Database(':memory:');
db.setAuthorizer(() => {
throw new Error('Unknown error');
});
assert.throws(() => {
db.exec('SELECT 1');
}, {
message: 'Unknown error'
});
});
it('throws error when authorizer returns nothing', () => {
const db = new Database(':memory:');
db.setAuthorizer(() => {
});
assert.throws(() => {
db.exec('SELECT 1');
}, {
message: 'Authorizer callback must return an integer authorization code'
});
});
it('throws error when authorizer returns NaN', () => {
const db = new Database(':memory:');
db.setAuthorizer(() => {
return '1';
});
assert.throws(() => {
db.exec('SELECT 1');
}, {
message: 'Authorizer callback must return an integer authorization code'
});
});
it('throws error when authorizer returns a invalid code', () => {
const db = new Database(':memory:');
db.setAuthorizer(() => {
return 3;
});
assert.throws(() => {
db.exec('SELECT 1');
}, {
message: 'Authorizer callback returned a invalid authorization code'
});
});
it('clears authorizer when set to null', (t) => {
const authorizer = t.mock.fn(() => constants.SQLITE_OK);
const db = new Database(':memory:');
const statement = db.prepare('SELECT 1');
// Set authorizer and verify it's called
db.setAuthorizer(authorizer);
statement.run();
assert.strictEqual(authorizer.mock.callCount(), 1);
// Clear authorizer and verify it's no longer called
db.setAuthorizer(null);
statement.run();
assert.strictEqual(authorizer.mock.callCount(), 1);
});
it('throws when callback is a string', () => {
const db = new Database(':memory:');
assert.throws(() => {
db.setAuthorizer('not a function');
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The "callback" argument must be a function/
});
});
it('throws when callback is a number', () => {
const db = new Database(':memory:');
assert.throws(() => {
db.setAuthorizer(1);
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The "callback" argument must be a function/
});
});
it('throws when callback is an object', () => {
const db = new Database(':memory:');
assert.throws(() => {
db.setAuthorizer({});
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The "callback" argument must be a function/
});
});
it('throws when callback is an array', () => {
const db = new Database(':memory:');
assert.throws(() => {
db.setAuthorizer([]);
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The "callback" argument must be a function/
});
});
it('throws when callback is undefined', () => {
const db = new Database(':memory:');
assert.throws(() => {
db.setAuthorizer();
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The "callback" argument must be a function/
});
});
it('throws if database is not open', () => {
const db = new Database(':memory:');
db.close();
assert.throws(() => {
db.setAuthorizer(() => constants.SQLITE_OK);
}, {
code: 'ERR_INVALID_STATE',
message: 'database is not open',
});
});
it('remains installed after close() and open()', (t) => {
const db = new Database(':memory:');
const authorizer = t.mock.fn(() => constants.SQLITE_DENY);
db.setAuthorizer(authorizer);
assert.throws(() => {
db.exec('CREATE TABLE x (a)');
}, { code: 'ERR_SQLITE_ERROR' });
const callsBefore = authorizer.mock.callCount();
assert.ok(callsBefore > 0);
db.close();
db.open();
assert.throws(() => {
db.exec('CREATE TABLE x (a)');
}, { code: 'ERR_SQLITE_ERROR' });
assert.ok(authorizer.mock.callCount() > callsBefore);
});
it('stays cleared after close() and open()', () => {
const db = new Database(':memory:');
db.setAuthorizer(() => constants.SQLITE_DENY);
db.setAuthorizer(null);
db.close();
db.open();
db.exec('CREATE TABLE x (a)');
});
});
// 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 steppingError =
'ERR_INVALID_STATE: statement is already 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 Database(':memory:');
db.exec('CREATE TABLE t (x INTEGER)');
const cases = {
prepare: () => db.prepare('SELECT 1'),
exec: () => db.exec('SELECT 1'),
setAuthorizer: () => db.setAuthorizer(null),
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),
enableDefensive: () => db.enableDefensive(true),
limits: () => { db.limits.length = 100; },
};
assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases));
});
// loadExtension() checks that extension loading is enabled before reaching
// the authorizer guard, so it needs a database opened with allowExtension.
it('rejects loadExtension', () => {
const db = new Database(':memory:', { allowExtension: true });
db.enableLoadExtension(true);
db.exec('CREATE TABLE t (x INTEGER)');
const cases = {
loadExtension: () => db.loadExtension('/nonexistent/extension'),
};
assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases));
});
// close() and deserialize() tear down the connection, so the pre-existing
// callback depth guard already rejects them with its own message.
it('rejects methods the callback depth guard already covers', () => {
const db = new Database(':memory:');
db.exec('CREATE TABLE t (x INTEGER)');
const snapshot = db.serialize();
const cases = {
close: () => db.close(),
deserialize: () => db.deserialize(snapshot),
};
assert.deepStrictEqual(runInAuthorizer(db, cases), {
close: 'ERR_INVALID_STATE: database cannot be closed while in a callback',
deserialize: 'ERR_INVALID_STATE: database cannot be deserialized ' +
'while in a callback',
});
});
it('rejects statement methods', () => {
const db = new Database(':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));
});
// An idle statement has no virtual-machine state or locks to release.
it('allows finalizing an idle statement', () => {
const db = new Database(':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',
});
});
// A paused iterator is busy and may hold locks between sqlite3_step() calls.
it('rejects finalizing another active statement', () => {
for (const method of ['close', 'dispose']) {
const db = new Database(':memory:');
db.exec('CREATE TABLE t (x INTEGER)');
db.exec('INSERT INTO t VALUES (1), (2), (3)');
const stmt = db.prepare('SELECT x FROM t');
const iter = stmt.iterate();
iter.next();
let outcome = 'authorizer callback did not run';
db.setAuthorizer((actionCode) => {
if (actionCode === constants.SQLITE_DROP_TABLE) {
try {
if (method === 'close') {
stmt.close();
} else {
stmt[Symbol.dispose]();
}
outcome = 'did not throw';
} catch (err) {
outcome = `${err.code}: ${err.message}`;
}
}
return constants.SQLITE_OK;
});
assert.throws(() => db.exec('DROP TABLE t'), {
code: 'ERR_SQLITE_ERROR',
message: 'database table is locked',
});
assert.strictEqual(outcome, expectedError);
db.setAuthorizer(null);
iter.return();
}
});
// 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 Database(':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 Database(':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 Database(':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, steppingError);
});
// Unlike an already-finalized statement, disposing the one being stepped
// would free the running virtual machine, so it throws.
it('rejects disposing the statement being stepped', () => {
const db = new Database(':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[Symbol.dispose]();
outcome = 'did not throw';
} catch (err) {
outcome = `${err.code}: ${err.message}`;
}
}
return constants.SQLITE_OK;
});
stmt.get();
assert.strictEqual(outcome, steppingError);
});
it('rejects iterator methods', () => {
const db = new Database(':memory:');
db.exec('CREATE TABLE t (x INTEGER)');
db.exec('INSERT INTO t VALUES (1), (2)');
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();
});
// A drained iterator holds no SQLite state, so next() and return() stay
// available and remain idempotent inside a callback.
it('allows iterator methods on a drained iterator', () => {
const db = new Database(':memory:');
db.exec('CREATE TABLE t (x INTEGER)');
db.exec('INSERT INTO t VALUES (1)');
const iter = db.prepare('SELECT x FROM t').iterate();
for (const row of iter) {
assert.ok(row);
}
const done = {};
const cases = {
next: () => { done.next = iter.next().done; },
return: () => { done.return = iter.return().done; },
};
assert.deepStrictEqual(runInAuthorizer(db, cases), {
next: 'did not throw',
return: 'did not throw',
});
assert.deepStrictEqual(done, { next: true, return: true });
});
it('rejects tag store methods', () => {
const db = new Database(':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`,
};
assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases));
});
// clear() only drops cached statements, so invalidating the cache after a
// schema change is allowed from the callback.
it('allows clearing a tag store', () => {
const db = new Database(':memory:');
db.exec('CREATE TABLE t (x INTEGER)');
db.exec('INSERT INTO t VALUES (1)');
const sql = db.createTagStore(10);
assert.strictEqual(sql.all`SELECT x FROM t`.length, 1);
assert.strictEqual(sql.size, 1);
const cases = { clear: () => sql.clear() };
assert.deepStrictEqual(runInAuthorizer(db, cases), {
clear: 'did not throw',
});
assert.strictEqual(sql.size, 0);
});
// 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 Database(':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 Database(':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 });
});
});