Skip to content

sqlite: authorizer callback can modify invoking connection despite SQLite contract #63207

@mceachen

Description

@mceachen

Version

v25+

Platform

all platforms are impacted

Subsystem

sqlite

What steps will reproduce the bug?

Per the SQLite docs for sqlite3_set_authorizer():

The authorizer callback must not do anything that will modify the database connection that invoked the authorizer callback.

The same paragraph says sqlite3_prepare_v2() and sqlite3_step() both count as modifying the connection.

node:sqlite documents database.setAuthorizer() as a wrapper around sqlite3_set_authorizer(), but it currently lets an authorizer callback call APIs that map to those operations on the same DatabaseSync connection:

const { DatabaseSync, constants } = require('node:sqlite');

const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE t (x INTEGER)');

let calls = 0;
let nestedPrepareSucceeded = false;
let nestedStepSucceeded = false;

db.setAuthorizer(() => {
  if (++calls === 1) {
    const stmt = db.prepare('SELECT 1'); // sqlite3_prepare_v2()
    nestedPrepareSucceeded = true;

    stmt.get(); // sqlite3_step()
    nestedStepSucceeded = true;
  }

  return constants.SQLITE_OK;
});

db.prepare('SELECT x FROM t');

console.log({
  calls,
  nestedPrepareSucceeded,
  nestedStepSucceeded,
});

How often does it reproduce? Is there a required condition?

Every time

What is the expected behavior? Why is that the expected behavior?

The nested same-connection db.prepare() should throw ERR_INVALID_STATE while the authorizer callback is on the stack.

This is not limited to user-facing prepare(). The SQLite docs also note:

When sqlite3_prepare_v2() is used to prepare a statement, the statement might be re-prepared during sqlite3_step() due to a schema change.

So the guard should cover every authorizer callback invocation, regardless of whether it was triggered by an explicit prepare or by re-prepare during step.

What do you see instead?

{
  calls: 3,
  nestedPrepareSucceeded: true,
  nestedStepSucceeded: true
}

Additional information

APIs that should likely be rejected while a same-connection authorizer callback is on the stack include at least:

db.close()
db.exec()
db.prepare()
db.deserialize()
db.setAuthorizer()
stmt.run()
stmt.get()
stmt.all()
stmt.iterate()
iter.next()
iter.return()
sqlTagStore.run()
sqlTagStore.get()
sqlTagStore.all()
sqlTagStore.iterate()
sqlTagStore.clear()

There may be additional same-connection mutating APIs that should be included as well, such as APIs that alter connection configuration, registered functions, extensions, sessions, or limits.

This is related to the recent user-defined function callback reentry work, but authorizers need a separate guard. User-defined SQL functions are allowed to call other SQLite APIs as long as they do not close the connection or reset/finalize the running statement. Authorizer callbacks are stricter: SQLite says they must not do anything that modifies the invoking connection, and explicitly includes prepare and step in that definition.

Since node:sqlite is still Stability 1.2 / release candidate, I think this can probably be changed directly rather than going through a deprecation cycle. A doc note under database.setAuthorizer() would also be useful.

(disclaimer: found while working on #63180 and #63183 -- refined with the help of claude and codex)

Metadata

Metadata

Assignees

No one assigned

    Labels

    sqliteIssues and PRs related to the SQLite subsystem.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions