sqlite: fix crash on db.close() from inside a user function - #63183

Closed
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv
Closed

sqlite: fix crash on db.close() from inside a user function#63183
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv

Conversation

@mceachen

Copy link
Copy Markdown
Contributor

Calling db.close() from inside a user-defined function callback while sqlite3_step is on the call stack caused two distinct crashes:

  1. DatabaseSync::Close ran sqlite3_finalize on the statement whose sqlite3_step frame was still active, freeing the VM that step was executing. The outer step then operated on freed memory.

  2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64 after step returned. The reentrant close zeroed connection_, so the deref crashed.

Add a MarkStepping() RAII guard wrapped around every sqlite3_step caller. If Finalize() is called while stepping_, defer it; the guard's destructor runs the deferred finalize after step returns. Add a connection-null check in StatementExecutionHelper::Run before the connection-dependent reads, throwing ERR_INVALID_STATE.

Fixes: #63180

(full disclosure: produced with assistance from claude and codex)

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. sqlite Issues and PRs related to the SQLite subsystem. labels May 8, 2026
@codecov

codecovBot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.91837% with 2 lines in your changes missing coverage. Please review.
βœ… Project coverage is 90.03%. Comparing base (bbf51ad) to head (c02e2c0).

Files with missing linesPatch %Lines
src/node_sqlite.cc95.00%1 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #63183 +/- ##
==========================================
- Coverage 90.03% 90.03% -0.01% 
==========================================
Files 713 713 Lines 224950 224986 +36 Branches 42532 42550 +18 ==========================================
+ Hits 202542 202572 +30 - Misses 14175 14183 +8 + Partials 8233 8231 -2 
Files with missing linesCoverage Ξ”
src/node_sqlite.h83.09% <100.00%> (+2.45%)⬆️
src/node_sqlite.cc80.62% <95.00%> (+0.08%)⬆️

... and 26 files with indirect coverage changes

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • πŸ“¦ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Renegade334

Copy link
Copy Markdown
Member

This definitely shouldn't segfault, but it's worth saying that "don't close the db handle during a user function callback" is expressly part of the sqlite3_create_function API contract, as is finalizing/resetting the statement, which I think we are also able to do:

letstmt;db.function('x',()=>stmt.get());stmt=db.prepare('SELECT x()');stmt.get();

Rather than performing forbidden operations and then mitigating against the consequences, it would probably be better to prevent those operations from occurring in the first place. Could we instead set some sort of state while a user callback function is being called, that causes attempts to close/finalize to be rejected with an exception?

Calling db.close() from inside a user-defined function callback while
sqlite3_step is on the call stack caused two distinct crashes:
1. DatabaseSync::Close ran sqlite3_finalize on the statement whose
sqlite3_step frame was still active, freeing the VM that step was
executing. The outer step then operated on freed memory.
2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced
db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64
after step returned. The reentrant close zeroed connection_, so
the deref crashed.
Add a MarkStepping() RAII guard wrapped around every sqlite3_step
caller. If Finalize() is called while stepping_, defer it; the
guard's destructor runs the deferred finalize after step returns.
Add a connection-null check in StatementExecutionHelper::Run before
the connection-dependent reads, throwing ERR_INVALID_STATE.
Fixes: nodejs#63180
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from 8adcb3b to c6ce251CompareMay 9, 2026 03:24
Per SQLite's sqlite3_create_function() contract, closing the database
or finalizing/resetting a statement is forbidden while a user-supplied
callback is on the stack. The previous fix detected the close case and
deferred the finalize after sqlite3_step returned. Per reviewer
feedback, prevent the forbidden operations up front instead: track a
user-callback depth on DatabaseSync (via an RAII guard wrapped around
every JS call from xFunc, the aggregate xStep/xValue/xFinal/xInverse
and start callbacks, and the authorizer callback), and have db.close(),
the statement execution methods, the iterator's next/return, and the
SQL tag store's run/get/all/iterate/clear throw ERR_INVALID_STATE when
called from inside such a callback.
Removes the now-unreachable deferred-finalize machinery (stepping_,
finalize_pending_, MarkStepping) and the connection-null check in
StatementExecutionHelper::Run. Adds tests for scalar, aggregate, and
authorizer callbacks, and for reentrant iter.next().
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from c6ce251 to 20d7ffaCompareMay 9, 2026 03:28
@mceachen

Copy link
Copy Markdown
ContributorAuthor

Thanks @Renegade334 -- agreed, switched to prevention.

DatabaseSync now tracks a user-callback depth via an RAII guard wrapped around every SQLite-invoked JS call (scalar xFunc, aggregate xStep/xValue/xFinal/xInverse/start, authorizer). While depth > 0, db.close(), the statement and tag-store execution methods, the iterator's next/return, tagStore.clear(), and db.deserialize() throw ERR_INVALID_STATE. Forbidden state is now unreachable, so the deferred-finalize machinery and the connection == nullptr check are gone.

New tests: aggregate step/result/start, authorizer, and reentrant iter.next() (your stmt.get()-from-callback example).

I just rebased to main (hence the force-push). Happy to squash before landing.

@mceachen

mceachen commented May 9, 2026

Copy link
Copy Markdown
ContributorAuthor

Ugh, Derp, all nested statement execution from SQL functions is now rejected. I've added a regression test and will push a new diff soon.

Edit: the third commit is ready for review.

The previous commit's db-wide IsInUserFunctionCallback guard was too
broad: it rejected legitimate cross-statement use from inside a user
function (the common "lookup" pattern), e.g.
const lookup = db.prepare('SELECT v FROM lookup WHERE id = ?');
db.function('lookup_v', (id) => lookup.get(id).v);
SQLite only forbids reentry into the *currently running* statement
(recursive sqlite3_step, sqlite3_reset, or sqlite3_finalize), not
operations on other statements on the same connection.
Track per-statement stepping_ on StatementSync, set via a MarkStepping
RAII guard wrapped around each sqlite3_step caller. JS methods that
would step, reset, or finalize a statement (StatementSync run/get/all/
iterate, iterator next/return, SQLTagStore run/get/all/iterate) check
that flag on the specific statement instead of the db-wide depth.
Keep the db-wide IsInUserFunctionCallback check only where the
operation is broadly invasive: db.close, db.deserialize, and SQL tag
store .clear, all of which finalize tracked statements (potentially
the running one).
Drop the authorizer scope: SQLite's authorizer rules are stricter
than user-defined functions (prepare/exec are forbidden too) and
warrant a separate change rather than partial coverage here.
Add tests for the legitimate cross-statement and cross-tag-store
patterns (now passing), and for db[Symbol.dispose]() being a
documented no-op when invoked from a callback.
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen

Copy link
Copy Markdown
ContributorAuthor

@Renegade334 is there anything I can do or change to help this PR?
@geeksilva97 did you happen to see this?

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's also a filter callback for applying a changeset.

Comment threadsrc/node_sqlite.cc
THROW_AND_RETURN_ON_BAD_STATE(
env,
db->IsInUserFunctionCallback(),
"database cannot be closed inside a user-defined function callback");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard doesn't cover the authorizer callback, and that leaves the original use-after-free reachable.

The authorizer runs during statement preparation, and preparation can happen inside sqlite3_step: on SQLITE_SCHEMA the public sqlite3_step wrapper calls sqlite3Reprepare in a loop (deps/sqlite/sqlite3.c:94612-94614), which reaches sqlite3AuthCheck. So:

db.setAuthorizer(()=>{db.close();return0;});db.function('f',()=>{db.exec('CREATE TABLE z (a)');return1;});db.prepare('SELECT f(v) FROM t').all();// multi-row

Row 1's f() does DDL, which expires all prepared statements. On row 2 sqlite3_step returns SQLITE_SCHEMA, reprepares in place, and invokes the authorizer β€” where IsInUserFunctionCallback() is false, so db.close() goes through. FinalizeStatements() then finalizes the very VM whose sqlite3_step frame is live (crash 1 from the description), and zeroes connection_. Through StatementSync::Run the follow-on sqlite3_last_insert_rowid(db->Connection()) derefs null (crash 2) β€” commit 2 dropped the connection-null check commit 1 added.

The commit message defers the authorizer to a separate change, but this isn't a coverage nicety: it's the same crash, still reachable from pure JS, with the interim mitigations removed. Note main already wraps AuthorizerCallback (src/node_sqlite.cc:2552) β€” see my other comment.

Comment threadsrc/node_sqlite.h
// detected separately via the per-statement
// StatementSync::IsStepping() flag, which leaves cross-statement use
// (the "lookup" pattern) unaffected.
inline auto EnterUserFunctionCallback() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is based on bbf51ad24cb (2026-05-08) and adds a second mechanism alongside one main already has, with narrower coverage.

main carries IsInCallback() / callback_depth_ / class CallbackDepthGuard (src/node_sqlite.h:232-234,249,409-416) applied at five sites β€” including AuthorizerCallback (node_sqlite.cc:2552) and the sqlite3changeset_apply call (:2409) β€” plus post-callback IsOpen() checks in xFunc/xStepBase/xValueBase/GetAggregate.

Concretely, the renamed message here fails an existing test: test/parallel/test-sqlite-udf-close.js:33 on main asserts the exact string 'database cannot be closed while in a callback' for all of all/get/run/iterate.

Worth rebasing and building on the existing guard rather than in parallel with it. Two things not to lose in the process: main's authorizer and changeset coverage, and its post-callback IsOpen() checks. That should also make the unrelated sqlite3_reset β†’ ResetStatement() changes in SQLTagStore disappear, since those already match main.

@mceachen

Copy link
Copy Markdown
ContributorAuthor

Confirmed that #64743 addressed this.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

node:sqlite segfaults when db.close() is called from a user-defined function callback during query execution

5 participants

@mceachen@nodejs-github-bot@Renegade334@TrevorBurnham@louwers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

sqlite: fix crash on db.close() from inside a user function - #63183

Closed
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv
Closed

sqlite: fix crash on db.close() from inside a user function#63183
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv

Conversation

@mceachen

Copy link
Copy Markdown
Contributor

Calling db.close() from inside a user-defined function callback while sqlite3_step is on the call stack caused two distinct crashes:

  1. DatabaseSync::Close ran sqlite3_finalize on the statement whose sqlite3_step frame was still active, freeing the VM that step was executing. The outer step then operated on freed memory.

  2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64 after step returned. The reentrant close zeroed connection_, so the deref crashed.

Add a MarkStepping() RAII guard wrapped around every sqlite3_step caller. If Finalize() is called while stepping_, defer it; the guard's destructor runs the deferred finalize after step returns. Add a connection-null check in StatementExecutionHelper::Run before the connection-dependent reads, throwing ERR_INVALID_STATE.

Fixes: #63180

(full disclosure: produced with assistance from claude and codex)

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. sqlite Issues and PRs related to the SQLite subsystem. labels May 8, 2026
@codecov

codecovBot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.91837% with 2 lines in your changes missing coverage. Please review.
βœ… Project coverage is 90.03%. Comparing base (bbf51ad) to head (c02e2c0).

Files with missing linesPatch %Lines
src/node_sqlite.cc95.00%1 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #63183 +/- ##
==========================================
- Coverage 90.03% 90.03% -0.01% 
==========================================
Files 713 713 Lines 224950 224986 +36 Branches 42532 42550 +18 ==========================================
+ Hits 202542 202572 +30 - Misses 14175 14183 +8 + Partials 8233 8231 -2 
Files with missing linesCoverage Ξ”
src/node_sqlite.h83.09% <100.00%> (+2.45%)⬆️
src/node_sqlite.cc80.62% <95.00%> (+0.08%)⬆️

... and 26 files with indirect coverage changes

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • πŸ“¦ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Renegade334

Copy link
Copy Markdown
Member

This definitely shouldn't segfault, but it's worth saying that "don't close the db handle during a user function callback" is expressly part of the sqlite3_create_function API contract, as is finalizing/resetting the statement, which I think we are also able to do:

letstmt;db.function('x',()=>stmt.get());stmt=db.prepare('SELECT x()');stmt.get();

Rather than performing forbidden operations and then mitigating against the consequences, it would probably be better to prevent those operations from occurring in the first place. Could we instead set some sort of state while a user callback function is being called, that causes attempts to close/finalize to be rejected with an exception?

Calling db.close() from inside a user-defined function callback while
sqlite3_step is on the call stack caused two distinct crashes:
1. DatabaseSync::Close ran sqlite3_finalize on the statement whose
sqlite3_step frame was still active, freeing the VM that step was
executing. The outer step then operated on freed memory.
2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced
db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64
after step returned. The reentrant close zeroed connection_, so
the deref crashed.
Add a MarkStepping() RAII guard wrapped around every sqlite3_step
caller. If Finalize() is called while stepping_, defer it; the
guard's destructor runs the deferred finalize after step returns.
Add a connection-null check in StatementExecutionHelper::Run before
the connection-dependent reads, throwing ERR_INVALID_STATE.
Fixes: nodejs#63180
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from 8adcb3b to c6ce251CompareMay 9, 2026 03:24
Per SQLite's sqlite3_create_function() contract, closing the database
or finalizing/resetting a statement is forbidden while a user-supplied
callback is on the stack. The previous fix detected the close case and
deferred the finalize after sqlite3_step returned. Per reviewer
feedback, prevent the forbidden operations up front instead: track a
user-callback depth on DatabaseSync (via an RAII guard wrapped around
every JS call from xFunc, the aggregate xStep/xValue/xFinal/xInverse
and start callbacks, and the authorizer callback), and have db.close(),
the statement execution methods, the iterator's next/return, and the
SQL tag store's run/get/all/iterate/clear throw ERR_INVALID_STATE when
called from inside such a callback.
Removes the now-unreachable deferred-finalize machinery (stepping_,
finalize_pending_, MarkStepping) and the connection-null check in
StatementExecutionHelper::Run. Adds tests for scalar, aggregate, and
authorizer callbacks, and for reentrant iter.next().
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from c6ce251 to 20d7ffaCompareMay 9, 2026 03:28
@mceachen

Copy link
Copy Markdown
ContributorAuthor

Thanks @Renegade334 -- agreed, switched to prevention.

DatabaseSync now tracks a user-callback depth via an RAII guard wrapped around every SQLite-invoked JS call (scalar xFunc, aggregate xStep/xValue/xFinal/xInverse/start, authorizer). While depth > 0, db.close(), the statement and tag-store execution methods, the iterator's next/return, tagStore.clear(), and db.deserialize() throw ERR_INVALID_STATE. Forbidden state is now unreachable, so the deferred-finalize machinery and the connection == nullptr check are gone.

New tests: aggregate step/result/start, authorizer, and reentrant iter.next() (your stmt.get()-from-callback example).

I just rebased to main (hence the force-push). Happy to squash before landing.

@mceachen

mceachen commented May 9, 2026

Copy link
Copy Markdown
ContributorAuthor

Ugh, Derp, all nested statement execution from SQL functions is now rejected. I've added a regression test and will push a new diff soon.

Edit: the third commit is ready for review.

The previous commit's db-wide IsInUserFunctionCallback guard was too
broad: it rejected legitimate cross-statement use from inside a user
function (the common "lookup" pattern), e.g.
const lookup = db.prepare('SELECT v FROM lookup WHERE id = ?');
db.function('lookup_v', (id) => lookup.get(id).v);
SQLite only forbids reentry into the *currently running* statement
(recursive sqlite3_step, sqlite3_reset, or sqlite3_finalize), not
operations on other statements on the same connection.
Track per-statement stepping_ on StatementSync, set via a MarkStepping
RAII guard wrapped around each sqlite3_step caller. JS methods that
would step, reset, or finalize a statement (StatementSync run/get/all/
iterate, iterator next/return, SQLTagStore run/get/all/iterate) check
that flag on the specific statement instead of the db-wide depth.
Keep the db-wide IsInUserFunctionCallback check only where the
operation is broadly invasive: db.close, db.deserialize, and SQL tag
store .clear, all of which finalize tracked statements (potentially
the running one).
Drop the authorizer scope: SQLite's authorizer rules are stricter
than user-defined functions (prepare/exec are forbidden too) and
warrant a separate change rather than partial coverage here.
Add tests for the legitimate cross-statement and cross-tag-store
patterns (now passing), and for db[Symbol.dispose]() being a
documented no-op when invoked from a callback.
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen

Copy link
Copy Markdown
ContributorAuthor

@Renegade334 is there anything I can do or change to help this PR?
@geeksilva97 did you happen to see this?

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's also a filter callback for applying a changeset.

Comment threadsrc/node_sqlite.cc
THROW_AND_RETURN_ON_BAD_STATE(
env,
db->IsInUserFunctionCallback(),
"database cannot be closed inside a user-defined function callback");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard doesn't cover the authorizer callback, and that leaves the original use-after-free reachable.

The authorizer runs during statement preparation, and preparation can happen inside sqlite3_step: on SQLITE_SCHEMA the public sqlite3_step wrapper calls sqlite3Reprepare in a loop (deps/sqlite/sqlite3.c:94612-94614), which reaches sqlite3AuthCheck. So:

db.setAuthorizer(()=>{db.close();return0;});db.function('f',()=>{db.exec('CREATE TABLE z (a)');return1;});db.prepare('SELECT f(v) FROM t').all();// multi-row

Row 1's f() does DDL, which expires all prepared statements. On row 2 sqlite3_step returns SQLITE_SCHEMA, reprepares in place, and invokes the authorizer β€” where IsInUserFunctionCallback() is false, so db.close() goes through. FinalizeStatements() then finalizes the very VM whose sqlite3_step frame is live (crash 1 from the description), and zeroes connection_. Through StatementSync::Run the follow-on sqlite3_last_insert_rowid(db->Connection()) derefs null (crash 2) β€” commit 2 dropped the connection-null check commit 1 added.

The commit message defers the authorizer to a separate change, but this isn't a coverage nicety: it's the same crash, still reachable from pure JS, with the interim mitigations removed. Note main already wraps AuthorizerCallback (src/node_sqlite.cc:2552) β€” see my other comment.

Comment threadsrc/node_sqlite.h
// detected separately via the per-statement
// StatementSync::IsStepping() flag, which leaves cross-statement use
// (the "lookup" pattern) unaffected.
inline auto EnterUserFunctionCallback() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is based on bbf51ad24cb (2026-05-08) and adds a second mechanism alongside one main already has, with narrower coverage.

main carries IsInCallback() / callback_depth_ / class CallbackDepthGuard (src/node_sqlite.h:232-234,249,409-416) applied at five sites β€” including AuthorizerCallback (node_sqlite.cc:2552) and the sqlite3changeset_apply call (:2409) β€” plus post-callback IsOpen() checks in xFunc/xStepBase/xValueBase/GetAggregate.

Concretely, the renamed message here fails an existing test: test/parallel/test-sqlite-udf-close.js:33 on main asserts the exact string 'database cannot be closed while in a callback' for all of all/get/run/iterate.

Worth rebasing and building on the existing guard rather than in parallel with it. Two things not to lose in the process: main's authorizer and changeset coverage, and its post-callback IsOpen() checks. That should also make the unrelated sqlite3_reset β†’ ResetStatement() changes in SQLTagStore disappear, since those already match main.

@mceachen

Copy link
Copy Markdown
ContributorAuthor

Confirmed that #64743 addressed this.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

node:sqlite segfaults when db.close() is called from a user-defined function callback during query execution

5 participants

@mceachen@nodejs-github-bot@Renegade334@TrevorBurnham@louwers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

sqlite: fix crash on db.close() from inside a user function - #63183

Closed
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv
Closed

sqlite: fix crash on db.close() from inside a user function#63183
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv

Conversation

@mceachen

Copy link
Copy Markdown
Contributor

Calling db.close() from inside a user-defined function callback while sqlite3_step is on the call stack caused two distinct crashes:

  1. DatabaseSync::Close ran sqlite3_finalize on the statement whose sqlite3_step frame was still active, freeing the VM that step was executing. The outer step then operated on freed memory.

  2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64 after step returned. The reentrant close zeroed connection_, so the deref crashed.

Add a MarkStepping() RAII guard wrapped around every sqlite3_step caller. If Finalize() is called while stepping_, defer it; the guard's destructor runs the deferred finalize after step returns. Add a connection-null check in StatementExecutionHelper::Run before the connection-dependent reads, throwing ERR_INVALID_STATE.

Fixes: #63180

(full disclosure: produced with assistance from claude and codex)

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. sqlite Issues and PRs related to the SQLite subsystem. labels May 8, 2026
@codecov

codecovBot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.91837% with 2 lines in your changes missing coverage. Please review.
βœ… Project coverage is 90.03%. Comparing base (bbf51ad) to head (c02e2c0).

Files with missing linesPatch %Lines
src/node_sqlite.cc95.00%1 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #63183 +/- ##
==========================================
- Coverage 90.03% 90.03% -0.01% 
==========================================
Files 713 713 Lines 224950 224986 +36 Branches 42532 42550 +18 ==========================================
+ Hits 202542 202572 +30 - Misses 14175 14183 +8 + Partials 8233 8231 -2 
Files with missing linesCoverage Ξ”
src/node_sqlite.h83.09% <100.00%> (+2.45%)⬆️
src/node_sqlite.cc80.62% <95.00%> (+0.08%)⬆️

... and 26 files with indirect coverage changes

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • πŸ“¦ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Renegade334

Copy link
Copy Markdown
Member

This definitely shouldn't segfault, but it's worth saying that "don't close the db handle during a user function callback" is expressly part of the sqlite3_create_function API contract, as is finalizing/resetting the statement, which I think we are also able to do:

letstmt;db.function('x',()=>stmt.get());stmt=db.prepare('SELECT x()');stmt.get();

Rather than performing forbidden operations and then mitigating against the consequences, it would probably be better to prevent those operations from occurring in the first place. Could we instead set some sort of state while a user callback function is being called, that causes attempts to close/finalize to be rejected with an exception?

Calling db.close() from inside a user-defined function callback while
sqlite3_step is on the call stack caused two distinct crashes:
1. DatabaseSync::Close ran sqlite3_finalize on the statement whose
sqlite3_step frame was still active, freeing the VM that step was
executing. The outer step then operated on freed memory.
2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced
db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64
after step returned. The reentrant close zeroed connection_, so
the deref crashed.
Add a MarkStepping() RAII guard wrapped around every sqlite3_step
caller. If Finalize() is called while stepping_, defer it; the
guard's destructor runs the deferred finalize after step returns.
Add a connection-null check in StatementExecutionHelper::Run before
the connection-dependent reads, throwing ERR_INVALID_STATE.
Fixes: nodejs#63180
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from 8adcb3b to c6ce251CompareMay 9, 2026 03:24
Per SQLite's sqlite3_create_function() contract, closing the database
or finalizing/resetting a statement is forbidden while a user-supplied
callback is on the stack. The previous fix detected the close case and
deferred the finalize after sqlite3_step returned. Per reviewer
feedback, prevent the forbidden operations up front instead: track a
user-callback depth on DatabaseSync (via an RAII guard wrapped around
every JS call from xFunc, the aggregate xStep/xValue/xFinal/xInverse
and start callbacks, and the authorizer callback), and have db.close(),
the statement execution methods, the iterator's next/return, and the
SQL tag store's run/get/all/iterate/clear throw ERR_INVALID_STATE when
called from inside such a callback.
Removes the now-unreachable deferred-finalize machinery (stepping_,
finalize_pending_, MarkStepping) and the connection-null check in
StatementExecutionHelper::Run. Adds tests for scalar, aggregate, and
authorizer callbacks, and for reentrant iter.next().
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from c6ce251 to 20d7ffaCompareMay 9, 2026 03:28
@mceachen

Copy link
Copy Markdown
ContributorAuthor

Thanks @Renegade334 -- agreed, switched to prevention.

DatabaseSync now tracks a user-callback depth via an RAII guard wrapped around every SQLite-invoked JS call (scalar xFunc, aggregate xStep/xValue/xFinal/xInverse/start, authorizer). While depth > 0, db.close(), the statement and tag-store execution methods, the iterator's next/return, tagStore.clear(), and db.deserialize() throw ERR_INVALID_STATE. Forbidden state is now unreachable, so the deferred-finalize machinery and the connection == nullptr check are gone.

New tests: aggregate step/result/start, authorizer, and reentrant iter.next() (your stmt.get()-from-callback example).

I just rebased to main (hence the force-push). Happy to squash before landing.

@mceachen

mceachen commented May 9, 2026

Copy link
Copy Markdown
ContributorAuthor

Ugh, Derp, all nested statement execution from SQL functions is now rejected. I've added a regression test and will push a new diff soon.

Edit: the third commit is ready for review.

The previous commit's db-wide IsInUserFunctionCallback guard was too
broad: it rejected legitimate cross-statement use from inside a user
function (the common "lookup" pattern), e.g.
const lookup = db.prepare('SELECT v FROM lookup WHERE id = ?');
db.function('lookup_v', (id) => lookup.get(id).v);
SQLite only forbids reentry into the *currently running* statement
(recursive sqlite3_step, sqlite3_reset, or sqlite3_finalize), not
operations on other statements on the same connection.
Track per-statement stepping_ on StatementSync, set via a MarkStepping
RAII guard wrapped around each sqlite3_step caller. JS methods that
would step, reset, or finalize a statement (StatementSync run/get/all/
iterate, iterator next/return, SQLTagStore run/get/all/iterate) check
that flag on the specific statement instead of the db-wide depth.
Keep the db-wide IsInUserFunctionCallback check only where the
operation is broadly invasive: db.close, db.deserialize, and SQL tag
store .clear, all of which finalize tracked statements (potentially
the running one).
Drop the authorizer scope: SQLite's authorizer rules are stricter
than user-defined functions (prepare/exec are forbidden too) and
warrant a separate change rather than partial coverage here.
Add tests for the legitimate cross-statement and cross-tag-store
patterns (now passing), and for db[Symbol.dispose]() being a
documented no-op when invoked from a callback.
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen

Copy link
Copy Markdown
ContributorAuthor

@Renegade334 is there anything I can do or change to help this PR?
@geeksilva97 did you happen to see this?

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's also a filter callback for applying a changeset.

Comment threadsrc/node_sqlite.cc
THROW_AND_RETURN_ON_BAD_STATE(
env,
db->IsInUserFunctionCallback(),
"database cannot be closed inside a user-defined function callback");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard doesn't cover the authorizer callback, and that leaves the original use-after-free reachable.

The authorizer runs during statement preparation, and preparation can happen inside sqlite3_step: on SQLITE_SCHEMA the public sqlite3_step wrapper calls sqlite3Reprepare in a loop (deps/sqlite/sqlite3.c:94612-94614), which reaches sqlite3AuthCheck. So:

db.setAuthorizer(()=>{db.close();return0;});db.function('f',()=>{db.exec('CREATE TABLE z (a)');return1;});db.prepare('SELECT f(v) FROM t').all();// multi-row

Row 1's f() does DDL, which expires all prepared statements. On row 2 sqlite3_step returns SQLITE_SCHEMA, reprepares in place, and invokes the authorizer β€” where IsInUserFunctionCallback() is false, so db.close() goes through. FinalizeStatements() then finalizes the very VM whose sqlite3_step frame is live (crash 1 from the description), and zeroes connection_. Through StatementSync::Run the follow-on sqlite3_last_insert_rowid(db->Connection()) derefs null (crash 2) β€” commit 2 dropped the connection-null check commit 1 added.

The commit message defers the authorizer to a separate change, but this isn't a coverage nicety: it's the same crash, still reachable from pure JS, with the interim mitigations removed. Note main already wraps AuthorizerCallback (src/node_sqlite.cc:2552) β€” see my other comment.

Comment threadsrc/node_sqlite.h
// detected separately via the per-statement
// StatementSync::IsStepping() flag, which leaves cross-statement use
// (the "lookup" pattern) unaffected.
inline auto EnterUserFunctionCallback() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is based on bbf51ad24cb (2026-05-08) and adds a second mechanism alongside one main already has, with narrower coverage.

main carries IsInCallback() / callback_depth_ / class CallbackDepthGuard (src/node_sqlite.h:232-234,249,409-416) applied at five sites β€” including AuthorizerCallback (node_sqlite.cc:2552) and the sqlite3changeset_apply call (:2409) β€” plus post-callback IsOpen() checks in xFunc/xStepBase/xValueBase/GetAggregate.

Concretely, the renamed message here fails an existing test: test/parallel/test-sqlite-udf-close.js:33 on main asserts the exact string 'database cannot be closed while in a callback' for all of all/get/run/iterate.

Worth rebasing and building on the existing guard rather than in parallel with it. Two things not to lose in the process: main's authorizer and changeset coverage, and its post-callback IsOpen() checks. That should also make the unrelated sqlite3_reset β†’ ResetStatement() changes in SQLTagStore disappear, since those already match main.

@mceachen

Copy link
Copy Markdown
ContributorAuthor

Confirmed that #64743 addressed this.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

node:sqlite segfaults when db.close() is called from a user-defined function callback during query execution

5 participants

@mceachen@nodejs-github-bot@Renegade334@TrevorBurnham@louwers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

sqlite: fix crash on db.close() from inside a user function - #63183

Closed
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv
Closed

sqlite: fix crash on db.close() from inside a user function#63183
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv

Conversation

@mceachen

Copy link
Copy Markdown
Contributor

Calling db.close() from inside a user-defined function callback while sqlite3_step is on the call stack caused two distinct crashes:

  1. DatabaseSync::Close ran sqlite3_finalize on the statement whose sqlite3_step frame was still active, freeing the VM that step was executing. The outer step then operated on freed memory.

  2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64 after step returned. The reentrant close zeroed connection_, so the deref crashed.

Add a MarkStepping() RAII guard wrapped around every sqlite3_step caller. If Finalize() is called while stepping_, defer it; the guard's destructor runs the deferred finalize after step returns. Add a connection-null check in StatementExecutionHelper::Run before the connection-dependent reads, throwing ERR_INVALID_STATE.

Fixes: #63180

(full disclosure: produced with assistance from claude and codex)

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. sqlite Issues and PRs related to the SQLite subsystem. labels May 8, 2026
@codecov

codecovBot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.91837% with 2 lines in your changes missing coverage. Please review.
βœ… Project coverage is 90.03%. Comparing base (bbf51ad) to head (c02e2c0).

Files with missing linesPatch %Lines
src/node_sqlite.cc95.00%1 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #63183 +/- ##
==========================================
- Coverage 90.03% 90.03% -0.01% 
==========================================
Files 713 713 Lines 224950 224986 +36 Branches 42532 42550 +18 ==========================================
+ Hits 202542 202572 +30 - Misses 14175 14183 +8 + Partials 8233 8231 -2 
Files with missing linesCoverage Ξ”
src/node_sqlite.h83.09% <100.00%> (+2.45%)⬆️
src/node_sqlite.cc80.62% <95.00%> (+0.08%)⬆️

... and 26 files with indirect coverage changes

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • πŸ“¦ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Renegade334

Copy link
Copy Markdown
Member

This definitely shouldn't segfault, but it's worth saying that "don't close the db handle during a user function callback" is expressly part of the sqlite3_create_function API contract, as is finalizing/resetting the statement, which I think we are also able to do:

letstmt;db.function('x',()=>stmt.get());stmt=db.prepare('SELECT x()');stmt.get();

Rather than performing forbidden operations and then mitigating against the consequences, it would probably be better to prevent those operations from occurring in the first place. Could we instead set some sort of state while a user callback function is being called, that causes attempts to close/finalize to be rejected with an exception?

Calling db.close() from inside a user-defined function callback while
sqlite3_step is on the call stack caused two distinct crashes:
1. DatabaseSync::Close ran sqlite3_finalize on the statement whose
sqlite3_step frame was still active, freeing the VM that step was
executing. The outer step then operated on freed memory.
2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced
db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64
after step returned. The reentrant close zeroed connection_, so
the deref crashed.
Add a MarkStepping() RAII guard wrapped around every sqlite3_step
caller. If Finalize() is called while stepping_, defer it; the
guard's destructor runs the deferred finalize after step returns.
Add a connection-null check in StatementExecutionHelper::Run before
the connection-dependent reads, throwing ERR_INVALID_STATE.
Fixes: nodejs#63180
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from 8adcb3b to c6ce251CompareMay 9, 2026 03:24
Per SQLite's sqlite3_create_function() contract, closing the database
or finalizing/resetting a statement is forbidden while a user-supplied
callback is on the stack. The previous fix detected the close case and
deferred the finalize after sqlite3_step returned. Per reviewer
feedback, prevent the forbidden operations up front instead: track a
user-callback depth on DatabaseSync (via an RAII guard wrapped around
every JS call from xFunc, the aggregate xStep/xValue/xFinal/xInverse
and start callbacks, and the authorizer callback), and have db.close(),
the statement execution methods, the iterator's next/return, and the
SQL tag store's run/get/all/iterate/clear throw ERR_INVALID_STATE when
called from inside such a callback.
Removes the now-unreachable deferred-finalize machinery (stepping_,
finalize_pending_, MarkStepping) and the connection-null check in
StatementExecutionHelper::Run. Adds tests for scalar, aggregate, and
authorizer callbacks, and for reentrant iter.next().
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from c6ce251 to 20d7ffaCompareMay 9, 2026 03:28
@mceachen

Copy link
Copy Markdown
ContributorAuthor

Thanks @Renegade334 -- agreed, switched to prevention.

DatabaseSync now tracks a user-callback depth via an RAII guard wrapped around every SQLite-invoked JS call (scalar xFunc, aggregate xStep/xValue/xFinal/xInverse/start, authorizer). While depth > 0, db.close(), the statement and tag-store execution methods, the iterator's next/return, tagStore.clear(), and db.deserialize() throw ERR_INVALID_STATE. Forbidden state is now unreachable, so the deferred-finalize machinery and the connection == nullptr check are gone.

New tests: aggregate step/result/start, authorizer, and reentrant iter.next() (your stmt.get()-from-callback example).

I just rebased to main (hence the force-push). Happy to squash before landing.

@mceachen

mceachen commented May 9, 2026

Copy link
Copy Markdown
ContributorAuthor

Ugh, Derp, all nested statement execution from SQL functions is now rejected. I've added a regression test and will push a new diff soon.

Edit: the third commit is ready for review.

The previous commit's db-wide IsInUserFunctionCallback guard was too
broad: it rejected legitimate cross-statement use from inside a user
function (the common "lookup" pattern), e.g.
const lookup = db.prepare('SELECT v FROM lookup WHERE id = ?');
db.function('lookup_v', (id) => lookup.get(id).v);
SQLite only forbids reentry into the *currently running* statement
(recursive sqlite3_step, sqlite3_reset, or sqlite3_finalize), not
operations on other statements on the same connection.
Track per-statement stepping_ on StatementSync, set via a MarkStepping
RAII guard wrapped around each sqlite3_step caller. JS methods that
would step, reset, or finalize a statement (StatementSync run/get/all/
iterate, iterator next/return, SQLTagStore run/get/all/iterate) check
that flag on the specific statement instead of the db-wide depth.
Keep the db-wide IsInUserFunctionCallback check only where the
operation is broadly invasive: db.close, db.deserialize, and SQL tag
store .clear, all of which finalize tracked statements (potentially
the running one).
Drop the authorizer scope: SQLite's authorizer rules are stricter
than user-defined functions (prepare/exec are forbidden too) and
warrant a separate change rather than partial coverage here.
Add tests for the legitimate cross-statement and cross-tag-store
patterns (now passing), and for db[Symbol.dispose]() being a
documented no-op when invoked from a callback.
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen

Copy link
Copy Markdown
ContributorAuthor

@Renegade334 is there anything I can do or change to help this PR?
@geeksilva97 did you happen to see this?

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's also a filter callback for applying a changeset.

Comment threadsrc/node_sqlite.cc
THROW_AND_RETURN_ON_BAD_STATE(
env,
db->IsInUserFunctionCallback(),
"database cannot be closed inside a user-defined function callback");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard doesn't cover the authorizer callback, and that leaves the original use-after-free reachable.

The authorizer runs during statement preparation, and preparation can happen inside sqlite3_step: on SQLITE_SCHEMA the public sqlite3_step wrapper calls sqlite3Reprepare in a loop (deps/sqlite/sqlite3.c:94612-94614), which reaches sqlite3AuthCheck. So:

db.setAuthorizer(()=>{db.close();return0;});db.function('f',()=>{db.exec('CREATE TABLE z (a)');return1;});db.prepare('SELECT f(v) FROM t').all();// multi-row

Row 1's f() does DDL, which expires all prepared statements. On row 2 sqlite3_step returns SQLITE_SCHEMA, reprepares in place, and invokes the authorizer β€” where IsInUserFunctionCallback() is false, so db.close() goes through. FinalizeStatements() then finalizes the very VM whose sqlite3_step frame is live (crash 1 from the description), and zeroes connection_. Through StatementSync::Run the follow-on sqlite3_last_insert_rowid(db->Connection()) derefs null (crash 2) β€” commit 2 dropped the connection-null check commit 1 added.

The commit message defers the authorizer to a separate change, but this isn't a coverage nicety: it's the same crash, still reachable from pure JS, with the interim mitigations removed. Note main already wraps AuthorizerCallback (src/node_sqlite.cc:2552) β€” see my other comment.

Comment threadsrc/node_sqlite.h
// detected separately via the per-statement
// StatementSync::IsStepping() flag, which leaves cross-statement use
// (the "lookup" pattern) unaffected.
inline auto EnterUserFunctionCallback() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is based on bbf51ad24cb (2026-05-08) and adds a second mechanism alongside one main already has, with narrower coverage.

main carries IsInCallback() / callback_depth_ / class CallbackDepthGuard (src/node_sqlite.h:232-234,249,409-416) applied at five sites β€” including AuthorizerCallback (node_sqlite.cc:2552) and the sqlite3changeset_apply call (:2409) β€” plus post-callback IsOpen() checks in xFunc/xStepBase/xValueBase/GetAggregate.

Concretely, the renamed message here fails an existing test: test/parallel/test-sqlite-udf-close.js:33 on main asserts the exact string 'database cannot be closed while in a callback' for all of all/get/run/iterate.

Worth rebasing and building on the existing guard rather than in parallel with it. Two things not to lose in the process: main's authorizer and changeset coverage, and its post-callback IsOpen() checks. That should also make the unrelated sqlite3_reset β†’ ResetStatement() changes in SQLTagStore disappear, since those already match main.

@mceachen

Copy link
Copy Markdown
ContributorAuthor

Confirmed that #64743 addressed this.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

node:sqlite segfaults when db.close() is called from a user-defined function callback during query execution

5 participants

@mceachen@nodejs-github-bot@Renegade334@TrevorBurnham@louwers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

sqlite: fix crash on db.close() from inside a user function - #63183

Closed
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv
Closed

sqlite: fix crash on db.close() from inside a user function#63183
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv

Conversation

@mceachen

Copy link
Copy Markdown
Contributor

Calling db.close() from inside a user-defined function callback while sqlite3_step is on the call stack caused two distinct crashes:

  1. DatabaseSync::Close ran sqlite3_finalize on the statement whose sqlite3_step frame was still active, freeing the VM that step was executing. The outer step then operated on freed memory.

  2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64 after step returned. The reentrant close zeroed connection_, so the deref crashed.

Add a MarkStepping() RAII guard wrapped around every sqlite3_step caller. If Finalize() is called while stepping_, defer it; the guard's destructor runs the deferred finalize after step returns. Add a connection-null check in StatementExecutionHelper::Run before the connection-dependent reads, throwing ERR_INVALID_STATE.

Fixes: #63180

(full disclosure: produced with assistance from claude and codex)

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. sqlite Issues and PRs related to the SQLite subsystem. labels May 8, 2026
@codecov

codecovBot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.91837% with 2 lines in your changes missing coverage. Please review.
βœ… Project coverage is 90.03%. Comparing base (bbf51ad) to head (c02e2c0).

Files with missing linesPatch %Lines
src/node_sqlite.cc95.00%1 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #63183 +/- ##
==========================================
- Coverage 90.03% 90.03% -0.01% 
==========================================
Files 713 713 Lines 224950 224986 +36 Branches 42532 42550 +18 ==========================================
+ Hits 202542 202572 +30 - Misses 14175 14183 +8 + Partials 8233 8231 -2 
Files with missing linesCoverage Ξ”
src/node_sqlite.h83.09% <100.00%> (+2.45%)⬆️
src/node_sqlite.cc80.62% <95.00%> (+0.08%)⬆️

... and 26 files with indirect coverage changes

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • πŸ“¦ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Renegade334

Copy link
Copy Markdown
Member

This definitely shouldn't segfault, but it's worth saying that "don't close the db handle during a user function callback" is expressly part of the sqlite3_create_function API contract, as is finalizing/resetting the statement, which I think we are also able to do:

letstmt;db.function('x',()=>stmt.get());stmt=db.prepare('SELECT x()');stmt.get();

Rather than performing forbidden operations and then mitigating against the consequences, it would probably be better to prevent those operations from occurring in the first place. Could we instead set some sort of state while a user callback function is being called, that causes attempts to close/finalize to be rejected with an exception?

Calling db.close() from inside a user-defined function callback while
sqlite3_step is on the call stack caused two distinct crashes:
1. DatabaseSync::Close ran sqlite3_finalize on the statement whose
sqlite3_step frame was still active, freeing the VM that step was
executing. The outer step then operated on freed memory.
2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced
db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64
after step returned. The reentrant close zeroed connection_, so
the deref crashed.
Add a MarkStepping() RAII guard wrapped around every sqlite3_step
caller. If Finalize() is called while stepping_, defer it; the
guard's destructor runs the deferred finalize after step returns.
Add a connection-null check in StatementExecutionHelper::Run before
the connection-dependent reads, throwing ERR_INVALID_STATE.
Fixes: nodejs#63180
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from 8adcb3b to c6ce251CompareMay 9, 2026 03:24
Per SQLite's sqlite3_create_function() contract, closing the database
or finalizing/resetting a statement is forbidden while a user-supplied
callback is on the stack. The previous fix detected the close case and
deferred the finalize after sqlite3_step returned. Per reviewer
feedback, prevent the forbidden operations up front instead: track a
user-callback depth on DatabaseSync (via an RAII guard wrapped around
every JS call from xFunc, the aggregate xStep/xValue/xFinal/xInverse
and start callbacks, and the authorizer callback), and have db.close(),
the statement execution methods, the iterator's next/return, and the
SQL tag store's run/get/all/iterate/clear throw ERR_INVALID_STATE when
called from inside such a callback.
Removes the now-unreachable deferred-finalize machinery (stepping_,
finalize_pending_, MarkStepping) and the connection-null check in
StatementExecutionHelper::Run. Adds tests for scalar, aggregate, and
authorizer callbacks, and for reentrant iter.next().
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from c6ce251 to 20d7ffaCompareMay 9, 2026 03:28
@mceachen

Copy link
Copy Markdown
ContributorAuthor

Thanks @Renegade334 -- agreed, switched to prevention.

DatabaseSync now tracks a user-callback depth via an RAII guard wrapped around every SQLite-invoked JS call (scalar xFunc, aggregate xStep/xValue/xFinal/xInverse/start, authorizer). While depth > 0, db.close(), the statement and tag-store execution methods, the iterator's next/return, tagStore.clear(), and db.deserialize() throw ERR_INVALID_STATE. Forbidden state is now unreachable, so the deferred-finalize machinery and the connection == nullptr check are gone.

New tests: aggregate step/result/start, authorizer, and reentrant iter.next() (your stmt.get()-from-callback example).

I just rebased to main (hence the force-push). Happy to squash before landing.

@mceachen

mceachen commented May 9, 2026

Copy link
Copy Markdown
ContributorAuthor

Ugh, Derp, all nested statement execution from SQL functions is now rejected. I've added a regression test and will push a new diff soon.

Edit: the third commit is ready for review.

The previous commit's db-wide IsInUserFunctionCallback guard was too
broad: it rejected legitimate cross-statement use from inside a user
function (the common "lookup" pattern), e.g.
const lookup = db.prepare('SELECT v FROM lookup WHERE id = ?');
db.function('lookup_v', (id) => lookup.get(id).v);
SQLite only forbids reentry into the *currently running* statement
(recursive sqlite3_step, sqlite3_reset, or sqlite3_finalize), not
operations on other statements on the same connection.
Track per-statement stepping_ on StatementSync, set via a MarkStepping
RAII guard wrapped around each sqlite3_step caller. JS methods that
would step, reset, or finalize a statement (StatementSync run/get/all/
iterate, iterator next/return, SQLTagStore run/get/all/iterate) check
that flag on the specific statement instead of the db-wide depth.
Keep the db-wide IsInUserFunctionCallback check only where the
operation is broadly invasive: db.close, db.deserialize, and SQL tag
store .clear, all of which finalize tracked statements (potentially
the running one).
Drop the authorizer scope: SQLite's authorizer rules are stricter
than user-defined functions (prepare/exec are forbidden too) and
warrant a separate change rather than partial coverage here.
Add tests for the legitimate cross-statement and cross-tag-store
patterns (now passing), and for db[Symbol.dispose]() being a
documented no-op when invoked from a callback.
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen

Copy link
Copy Markdown
ContributorAuthor

@Renegade334 is there anything I can do or change to help this PR?
@geeksilva97 did you happen to see this?

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's also a filter callback for applying a changeset.

Comment threadsrc/node_sqlite.cc
THROW_AND_RETURN_ON_BAD_STATE(
env,
db->IsInUserFunctionCallback(),
"database cannot be closed inside a user-defined function callback");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard doesn't cover the authorizer callback, and that leaves the original use-after-free reachable.

The authorizer runs during statement preparation, and preparation can happen inside sqlite3_step: on SQLITE_SCHEMA the public sqlite3_step wrapper calls sqlite3Reprepare in a loop (deps/sqlite/sqlite3.c:94612-94614), which reaches sqlite3AuthCheck. So:

db.setAuthorizer(()=>{db.close();return0;});db.function('f',()=>{db.exec('CREATE TABLE z (a)');return1;});db.prepare('SELECT f(v) FROM t').all();// multi-row

Row 1's f() does DDL, which expires all prepared statements. On row 2 sqlite3_step returns SQLITE_SCHEMA, reprepares in place, and invokes the authorizer β€” where IsInUserFunctionCallback() is false, so db.close() goes through. FinalizeStatements() then finalizes the very VM whose sqlite3_step frame is live (crash 1 from the description), and zeroes connection_. Through StatementSync::Run the follow-on sqlite3_last_insert_rowid(db->Connection()) derefs null (crash 2) β€” commit 2 dropped the connection-null check commit 1 added.

The commit message defers the authorizer to a separate change, but this isn't a coverage nicety: it's the same crash, still reachable from pure JS, with the interim mitigations removed. Note main already wraps AuthorizerCallback (src/node_sqlite.cc:2552) β€” see my other comment.

Comment threadsrc/node_sqlite.h
// detected separately via the per-statement
// StatementSync::IsStepping() flag, which leaves cross-statement use
// (the "lookup" pattern) unaffected.
inline auto EnterUserFunctionCallback() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is based on bbf51ad24cb (2026-05-08) and adds a second mechanism alongside one main already has, with narrower coverage.

main carries IsInCallback() / callback_depth_ / class CallbackDepthGuard (src/node_sqlite.h:232-234,249,409-416) applied at five sites β€” including AuthorizerCallback (node_sqlite.cc:2552) and the sqlite3changeset_apply call (:2409) β€” plus post-callback IsOpen() checks in xFunc/xStepBase/xValueBase/GetAggregate.

Concretely, the renamed message here fails an existing test: test/parallel/test-sqlite-udf-close.js:33 on main asserts the exact string 'database cannot be closed while in a callback' for all of all/get/run/iterate.

Worth rebasing and building on the existing guard rather than in parallel with it. Two things not to lose in the process: main's authorizer and changeset coverage, and its post-callback IsOpen() checks. That should also make the unrelated sqlite3_reset β†’ ResetStatement() changes in SQLTagStore disappear, since those already match main.

@mceachen

Copy link
Copy Markdown
ContributorAuthor

Confirmed that #64743 addressed this.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

node:sqlite segfaults when db.close() is called from a user-defined function callback during query execution

5 participants

@mceachen@nodejs-github-bot@Renegade334@TrevorBurnham@louwers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

sqlite: fix crash on db.close() from inside a user function - #63183

Closed
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv
Closed

sqlite: fix crash on db.close() from inside a user function#63183
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv

Conversation

@mceachen

Copy link
Copy Markdown
Contributor

Calling db.close() from inside a user-defined function callback while sqlite3_step is on the call stack caused two distinct crashes:

  1. DatabaseSync::Close ran sqlite3_finalize on the statement whose sqlite3_step frame was still active, freeing the VM that step was executing. The outer step then operated on freed memory.

  2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64 after step returned. The reentrant close zeroed connection_, so the deref crashed.

Add a MarkStepping() RAII guard wrapped around every sqlite3_step caller. If Finalize() is called while stepping_, defer it; the guard's destructor runs the deferred finalize after step returns. Add a connection-null check in StatementExecutionHelper::Run before the connection-dependent reads, throwing ERR_INVALID_STATE.

Fixes: #63180

(full disclosure: produced with assistance from claude and codex)

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. sqlite Issues and PRs related to the SQLite subsystem. labels May 8, 2026
@codecov

codecovBot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.91837% with 2 lines in your changes missing coverage. Please review.
βœ… Project coverage is 90.03%. Comparing base (bbf51ad) to head (c02e2c0).

Files with missing linesPatch %Lines
src/node_sqlite.cc95.00%1 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #63183 +/- ##
==========================================
- Coverage 90.03% 90.03% -0.01% 
==========================================
Files 713 713 Lines 224950 224986 +36 Branches 42532 42550 +18 ==========================================
+ Hits 202542 202572 +30 - Misses 14175 14183 +8 + Partials 8233 8231 -2 
Files with missing linesCoverage Ξ”
src/node_sqlite.h83.09% <100.00%> (+2.45%)⬆️
src/node_sqlite.cc80.62% <95.00%> (+0.08%)⬆️

... and 26 files with indirect coverage changes

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • πŸ“¦ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Renegade334

Copy link
Copy Markdown
Member

This definitely shouldn't segfault, but it's worth saying that "don't close the db handle during a user function callback" is expressly part of the sqlite3_create_function API contract, as is finalizing/resetting the statement, which I think we are also able to do:

letstmt;db.function('x',()=>stmt.get());stmt=db.prepare('SELECT x()');stmt.get();

Rather than performing forbidden operations and then mitigating against the consequences, it would probably be better to prevent those operations from occurring in the first place. Could we instead set some sort of state while a user callback function is being called, that causes attempts to close/finalize to be rejected with an exception?

Calling db.close() from inside a user-defined function callback while
sqlite3_step is on the call stack caused two distinct crashes:
1. DatabaseSync::Close ran sqlite3_finalize on the statement whose
sqlite3_step frame was still active, freeing the VM that step was
executing. The outer step then operated on freed memory.
2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced
db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64
after step returned. The reentrant close zeroed connection_, so
the deref crashed.
Add a MarkStepping() RAII guard wrapped around every sqlite3_step
caller. If Finalize() is called while stepping_, defer it; the
guard's destructor runs the deferred finalize after step returns.
Add a connection-null check in StatementExecutionHelper::Run before
the connection-dependent reads, throwing ERR_INVALID_STATE.
Fixes: nodejs#63180
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from 8adcb3b to c6ce251CompareMay 9, 2026 03:24
Per SQLite's sqlite3_create_function() contract, closing the database
or finalizing/resetting a statement is forbidden while a user-supplied
callback is on the stack. The previous fix detected the close case and
deferred the finalize after sqlite3_step returned. Per reviewer
feedback, prevent the forbidden operations up front instead: track a
user-callback depth on DatabaseSync (via an RAII guard wrapped around
every JS call from xFunc, the aggregate xStep/xValue/xFinal/xInverse
and start callbacks, and the authorizer callback), and have db.close(),
the statement execution methods, the iterator's next/return, and the
SQL tag store's run/get/all/iterate/clear throw ERR_INVALID_STATE when
called from inside such a callback.
Removes the now-unreachable deferred-finalize machinery (stepping_,
finalize_pending_, MarkStepping) and the connection-null check in
StatementExecutionHelper::Run. Adds tests for scalar, aggregate, and
authorizer callbacks, and for reentrant iter.next().
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from c6ce251 to 20d7ffaCompareMay 9, 2026 03:28
@mceachen

Copy link
Copy Markdown
ContributorAuthor

Thanks @Renegade334 -- agreed, switched to prevention.

DatabaseSync now tracks a user-callback depth via an RAII guard wrapped around every SQLite-invoked JS call (scalar xFunc, aggregate xStep/xValue/xFinal/xInverse/start, authorizer). While depth > 0, db.close(), the statement and tag-store execution methods, the iterator's next/return, tagStore.clear(), and db.deserialize() throw ERR_INVALID_STATE. Forbidden state is now unreachable, so the deferred-finalize machinery and the connection == nullptr check are gone.

New tests: aggregate step/result/start, authorizer, and reentrant iter.next() (your stmt.get()-from-callback example).

I just rebased to main (hence the force-push). Happy to squash before landing.

@mceachen

mceachen commented May 9, 2026

Copy link
Copy Markdown
ContributorAuthor

Ugh, Derp, all nested statement execution from SQL functions is now rejected. I've added a regression test and will push a new diff soon.

Edit: the third commit is ready for review.

The previous commit's db-wide IsInUserFunctionCallback guard was too
broad: it rejected legitimate cross-statement use from inside a user
function (the common "lookup" pattern), e.g.
const lookup = db.prepare('SELECT v FROM lookup WHERE id = ?');
db.function('lookup_v', (id) => lookup.get(id).v);
SQLite only forbids reentry into the *currently running* statement
(recursive sqlite3_step, sqlite3_reset, or sqlite3_finalize), not
operations on other statements on the same connection.
Track per-statement stepping_ on StatementSync, set via a MarkStepping
RAII guard wrapped around each sqlite3_step caller. JS methods that
would step, reset, or finalize a statement (StatementSync run/get/all/
iterate, iterator next/return, SQLTagStore run/get/all/iterate) check
that flag on the specific statement instead of the db-wide depth.
Keep the db-wide IsInUserFunctionCallback check only where the
operation is broadly invasive: db.close, db.deserialize, and SQL tag
store .clear, all of which finalize tracked statements (potentially
the running one).
Drop the authorizer scope: SQLite's authorizer rules are stricter
than user-defined functions (prepare/exec are forbidden too) and
warrant a separate change rather than partial coverage here.
Add tests for the legitimate cross-statement and cross-tag-store
patterns (now passing), and for db[Symbol.dispose]() being a
documented no-op when invoked from a callback.
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen

Copy link
Copy Markdown
ContributorAuthor

@Renegade334 is there anything I can do or change to help this PR?
@geeksilva97 did you happen to see this?

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's also a filter callback for applying a changeset.

Comment threadsrc/node_sqlite.cc
THROW_AND_RETURN_ON_BAD_STATE(
env,
db->IsInUserFunctionCallback(),
"database cannot be closed inside a user-defined function callback");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard doesn't cover the authorizer callback, and that leaves the original use-after-free reachable.

The authorizer runs during statement preparation, and preparation can happen inside sqlite3_step: on SQLITE_SCHEMA the public sqlite3_step wrapper calls sqlite3Reprepare in a loop (deps/sqlite/sqlite3.c:94612-94614), which reaches sqlite3AuthCheck. So:

db.setAuthorizer(()=>{db.close();return0;});db.function('f',()=>{db.exec('CREATE TABLE z (a)');return1;});db.prepare('SELECT f(v) FROM t').all();// multi-row

Row 1's f() does DDL, which expires all prepared statements. On row 2 sqlite3_step returns SQLITE_SCHEMA, reprepares in place, and invokes the authorizer β€” where IsInUserFunctionCallback() is false, so db.close() goes through. FinalizeStatements() then finalizes the very VM whose sqlite3_step frame is live (crash 1 from the description), and zeroes connection_. Through StatementSync::Run the follow-on sqlite3_last_insert_rowid(db->Connection()) derefs null (crash 2) β€” commit 2 dropped the connection-null check commit 1 added.

The commit message defers the authorizer to a separate change, but this isn't a coverage nicety: it's the same crash, still reachable from pure JS, with the interim mitigations removed. Note main already wraps AuthorizerCallback (src/node_sqlite.cc:2552) β€” see my other comment.

Comment threadsrc/node_sqlite.h
// detected separately via the per-statement
// StatementSync::IsStepping() flag, which leaves cross-statement use
// (the "lookup" pattern) unaffected.
inline auto EnterUserFunctionCallback() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is based on bbf51ad24cb (2026-05-08) and adds a second mechanism alongside one main already has, with narrower coverage.

main carries IsInCallback() / callback_depth_ / class CallbackDepthGuard (src/node_sqlite.h:232-234,249,409-416) applied at five sites β€” including AuthorizerCallback (node_sqlite.cc:2552) and the sqlite3changeset_apply call (:2409) β€” plus post-callback IsOpen() checks in xFunc/xStepBase/xValueBase/GetAggregate.

Concretely, the renamed message here fails an existing test: test/parallel/test-sqlite-udf-close.js:33 on main asserts the exact string 'database cannot be closed while in a callback' for all of all/get/run/iterate.

Worth rebasing and building on the existing guard rather than in parallel with it. Two things not to lose in the process: main's authorizer and changeset coverage, and its post-callback IsOpen() checks. That should also make the unrelated sqlite3_reset β†’ ResetStatement() changes in SQLTagStore disappear, since those already match main.

@mceachen

Copy link
Copy Markdown
ContributorAuthor

Confirmed that #64743 addressed this.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

node:sqlite segfaults when db.close() is called from a user-defined function callback during query execution

5 participants

@mceachen@nodejs-github-bot@Renegade334@TrevorBurnham@louwers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

sqlite: fix crash on db.close() from inside a user function - #63183

Closed
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv
Closed

sqlite: fix crash on db.close() from inside a user function#63183
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv

Conversation

@mceachen

Copy link
Copy Markdown
Contributor

Calling db.close() from inside a user-defined function callback while sqlite3_step is on the call stack caused two distinct crashes:

  1. DatabaseSync::Close ran sqlite3_finalize on the statement whose sqlite3_step frame was still active, freeing the VM that step was executing. The outer step then operated on freed memory.

  2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64 after step returned. The reentrant close zeroed connection_, so the deref crashed.

Add a MarkStepping() RAII guard wrapped around every sqlite3_step caller. If Finalize() is called while stepping_, defer it; the guard's destructor runs the deferred finalize after step returns. Add a connection-null check in StatementExecutionHelper::Run before the connection-dependent reads, throwing ERR_INVALID_STATE.

Fixes: #63180

(full disclosure: produced with assistance from claude and codex)

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. sqlite Issues and PRs related to the SQLite subsystem. labels May 8, 2026
@codecov

codecovBot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.91837% with 2 lines in your changes missing coverage. Please review.
βœ… Project coverage is 90.03%. Comparing base (bbf51ad) to head (c02e2c0).

Files with missing linesPatch %Lines
src/node_sqlite.cc95.00%1 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #63183 +/- ##
==========================================
- Coverage 90.03% 90.03% -0.01% 
==========================================
Files 713 713 Lines 224950 224986 +36 Branches 42532 42550 +18 ==========================================
+ Hits 202542 202572 +30 - Misses 14175 14183 +8 + Partials 8233 8231 -2 
Files with missing linesCoverage Ξ”
src/node_sqlite.h83.09% <100.00%> (+2.45%)⬆️
src/node_sqlite.cc80.62% <95.00%> (+0.08%)⬆️

... and 26 files with indirect coverage changes

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • πŸ“¦ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Renegade334

Copy link
Copy Markdown
Member

This definitely shouldn't segfault, but it's worth saying that "don't close the db handle during a user function callback" is expressly part of the sqlite3_create_function API contract, as is finalizing/resetting the statement, which I think we are also able to do:

letstmt;db.function('x',()=>stmt.get());stmt=db.prepare('SELECT x()');stmt.get();

Rather than performing forbidden operations and then mitigating against the consequences, it would probably be better to prevent those operations from occurring in the first place. Could we instead set some sort of state while a user callback function is being called, that causes attempts to close/finalize to be rejected with an exception?

Calling db.close() from inside a user-defined function callback while
sqlite3_step is on the call stack caused two distinct crashes:
1. DatabaseSync::Close ran sqlite3_finalize on the statement whose
sqlite3_step frame was still active, freeing the VM that step was
executing. The outer step then operated on freed memory.
2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced
db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64
after step returned. The reentrant close zeroed connection_, so
the deref crashed.
Add a MarkStepping() RAII guard wrapped around every sqlite3_step
caller. If Finalize() is called while stepping_, defer it; the
guard's destructor runs the deferred finalize after step returns.
Add a connection-null check in StatementExecutionHelper::Run before
the connection-dependent reads, throwing ERR_INVALID_STATE.
Fixes: nodejs#63180
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from 8adcb3b to c6ce251CompareMay 9, 2026 03:24
Per SQLite's sqlite3_create_function() contract, closing the database
or finalizing/resetting a statement is forbidden while a user-supplied
callback is on the stack. The previous fix detected the close case and
deferred the finalize after sqlite3_step returned. Per reviewer
feedback, prevent the forbidden operations up front instead: track a
user-callback depth on DatabaseSync (via an RAII guard wrapped around
every JS call from xFunc, the aggregate xStep/xValue/xFinal/xInverse
and start callbacks, and the authorizer callback), and have db.close(),
the statement execution methods, the iterator's next/return, and the
SQL tag store's run/get/all/iterate/clear throw ERR_INVALID_STATE when
called from inside such a callback.
Removes the now-unreachable deferred-finalize machinery (stepping_,
finalize_pending_, MarkStepping) and the connection-null check in
StatementExecutionHelper::Run. Adds tests for scalar, aggregate, and
authorizer callbacks, and for reentrant iter.next().
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from c6ce251 to 20d7ffaCompareMay 9, 2026 03:28
@mceachen

Copy link
Copy Markdown
ContributorAuthor

Thanks @Renegade334 -- agreed, switched to prevention.

DatabaseSync now tracks a user-callback depth via an RAII guard wrapped around every SQLite-invoked JS call (scalar xFunc, aggregate xStep/xValue/xFinal/xInverse/start, authorizer). While depth > 0, db.close(), the statement and tag-store execution methods, the iterator's next/return, tagStore.clear(), and db.deserialize() throw ERR_INVALID_STATE. Forbidden state is now unreachable, so the deferred-finalize machinery and the connection == nullptr check are gone.

New tests: aggregate step/result/start, authorizer, and reentrant iter.next() (your stmt.get()-from-callback example).

I just rebased to main (hence the force-push). Happy to squash before landing.

@mceachen

mceachen commented May 9, 2026

Copy link
Copy Markdown
ContributorAuthor

Ugh, Derp, all nested statement execution from SQL functions is now rejected. I've added a regression test and will push a new diff soon.

Edit: the third commit is ready for review.

The previous commit's db-wide IsInUserFunctionCallback guard was too
broad: it rejected legitimate cross-statement use from inside a user
function (the common "lookup" pattern), e.g.
const lookup = db.prepare('SELECT v FROM lookup WHERE id = ?');
db.function('lookup_v', (id) => lookup.get(id).v);
SQLite only forbids reentry into the *currently running* statement
(recursive sqlite3_step, sqlite3_reset, or sqlite3_finalize), not
operations on other statements on the same connection.
Track per-statement stepping_ on StatementSync, set via a MarkStepping
RAII guard wrapped around each sqlite3_step caller. JS methods that
would step, reset, or finalize a statement (StatementSync run/get/all/
iterate, iterator next/return, SQLTagStore run/get/all/iterate) check
that flag on the specific statement instead of the db-wide depth.
Keep the db-wide IsInUserFunctionCallback check only where the
operation is broadly invasive: db.close, db.deserialize, and SQL tag
store .clear, all of which finalize tracked statements (potentially
the running one).
Drop the authorizer scope: SQLite's authorizer rules are stricter
than user-defined functions (prepare/exec are forbidden too) and
warrant a separate change rather than partial coverage here.
Add tests for the legitimate cross-statement and cross-tag-store
patterns (now passing), and for db[Symbol.dispose]() being a
documented no-op when invoked from a callback.
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen

Copy link
Copy Markdown
ContributorAuthor

@Renegade334 is there anything I can do or change to help this PR?
@geeksilva97 did you happen to see this?

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's also a filter callback for applying a changeset.

Comment threadsrc/node_sqlite.cc
THROW_AND_RETURN_ON_BAD_STATE(
env,
db->IsInUserFunctionCallback(),
"database cannot be closed inside a user-defined function callback");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard doesn't cover the authorizer callback, and that leaves the original use-after-free reachable.

The authorizer runs during statement preparation, and preparation can happen inside sqlite3_step: on SQLITE_SCHEMA the public sqlite3_step wrapper calls sqlite3Reprepare in a loop (deps/sqlite/sqlite3.c:94612-94614), which reaches sqlite3AuthCheck. So:

db.setAuthorizer(()=>{db.close();return0;});db.function('f',()=>{db.exec('CREATE TABLE z (a)');return1;});db.prepare('SELECT f(v) FROM t').all();// multi-row

Row 1's f() does DDL, which expires all prepared statements. On row 2 sqlite3_step returns SQLITE_SCHEMA, reprepares in place, and invokes the authorizer β€” where IsInUserFunctionCallback() is false, so db.close() goes through. FinalizeStatements() then finalizes the very VM whose sqlite3_step frame is live (crash 1 from the description), and zeroes connection_. Through StatementSync::Run the follow-on sqlite3_last_insert_rowid(db->Connection()) derefs null (crash 2) β€” commit 2 dropped the connection-null check commit 1 added.

The commit message defers the authorizer to a separate change, but this isn't a coverage nicety: it's the same crash, still reachable from pure JS, with the interim mitigations removed. Note main already wraps AuthorizerCallback (src/node_sqlite.cc:2552) β€” see my other comment.

Comment threadsrc/node_sqlite.h
// detected separately via the per-statement
// StatementSync::IsStepping() flag, which leaves cross-statement use
// (the "lookup" pattern) unaffected.
inline auto EnterUserFunctionCallback() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is based on bbf51ad24cb (2026-05-08) and adds a second mechanism alongside one main already has, with narrower coverage.

main carries IsInCallback() / callback_depth_ / class CallbackDepthGuard (src/node_sqlite.h:232-234,249,409-416) applied at five sites β€” including AuthorizerCallback (node_sqlite.cc:2552) and the sqlite3changeset_apply call (:2409) β€” plus post-callback IsOpen() checks in xFunc/xStepBase/xValueBase/GetAggregate.

Concretely, the renamed message here fails an existing test: test/parallel/test-sqlite-udf-close.js:33 on main asserts the exact string 'database cannot be closed while in a callback' for all of all/get/run/iterate.

Worth rebasing and building on the existing guard rather than in parallel with it. Two things not to lose in the process: main's authorizer and changeset coverage, and its post-callback IsOpen() checks. That should also make the unrelated sqlite3_reset β†’ ResetStatement() changes in SQLTagStore disappear, since those already match main.

@mceachen

Copy link
Copy Markdown
ContributorAuthor

Confirmed that #64743 addressed this.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

node:sqlite segfaults when db.close() is called from a user-defined function callback during query execution

5 participants

@mceachen@nodejs-github-bot@Renegade334@TrevorBurnham@louwers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

sqlite: fix crash on db.close() from inside a user function - #63183

Closed
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv
Closed

sqlite: fix crash on db.close() from inside a user function#63183
mceachen wants to merge 3 commits into
nodejs:mainfrom
mceachen:fix-sqlite-reentrant-close-segv

Conversation

@mceachen

Copy link
Copy Markdown
Contributor

Calling db.close() from inside a user-defined function callback while sqlite3_step is on the call stack caused two distinct crashes:

  1. DatabaseSync::Close ran sqlite3_finalize on the statement whose sqlite3_step frame was still active, freeing the VM that step was executing. The outer step then operated on freed memory.

  2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64 after step returned. The reentrant close zeroed connection_, so the deref crashed.

Add a MarkStepping() RAII guard wrapped around every sqlite3_step caller. If Finalize() is called while stepping_, defer it; the guard's destructor runs the deferred finalize after step returns. Add a connection-null check in StatementExecutionHelper::Run before the connection-dependent reads, throwing ERR_INVALID_STATE.

Fixes: #63180

(full disclosure: produced with assistance from claude and codex)

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. sqlite Issues and PRs related to the SQLite subsystem. labels May 8, 2026
@codecov

codecovBot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.91837% with 2 lines in your changes missing coverage. Please review.
βœ… Project coverage is 90.03%. Comparing base (bbf51ad) to head (c02e2c0).

Files with missing linesPatch %Lines
src/node_sqlite.cc95.00%1 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #63183 +/- ##
==========================================
- Coverage 90.03% 90.03% -0.01% 
==========================================
Files 713 713 Lines 224950 224986 +36 Branches 42532 42550 +18 ==========================================
+ Hits 202542 202572 +30 - Misses 14175 14183 +8 + Partials 8233 8231 -2 
Files with missing linesCoverage Ξ”
src/node_sqlite.h83.09% <100.00%> (+2.45%)⬆️
src/node_sqlite.cc80.62% <95.00%> (+0.08%)⬆️

... and 26 files with indirect coverage changes

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • πŸ“¦ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Renegade334

Copy link
Copy Markdown
Member

This definitely shouldn't segfault, but it's worth saying that "don't close the db handle during a user function callback" is expressly part of the sqlite3_create_function API contract, as is finalizing/resetting the statement, which I think we are also able to do:

letstmt;db.function('x',()=>stmt.get());stmt=db.prepare('SELECT x()');stmt.get();

Rather than performing forbidden operations and then mitigating against the consequences, it would probably be better to prevent those operations from occurring in the first place. Could we instead set some sort of state while a user callback function is being called, that causes attempts to close/finalize to be rejected with an exception?

Calling db.close() from inside a user-defined function callback while
sqlite3_step is on the call stack caused two distinct crashes:
1. DatabaseSync::Close ran sqlite3_finalize on the statement whose
sqlite3_step frame was still active, freeing the VM that step was
executing. The outer step then operated on freed memory.
2. Even if (1) is avoided, StatementExecutionHelper::Run dereferenced
db->Connection() via sqlite3_last_insert_rowid / sqlite3_changes64
after step returned. The reentrant close zeroed connection_, so
the deref crashed.
Add a MarkStepping() RAII guard wrapped around every sqlite3_step
caller. If Finalize() is called while stepping_, defer it; the
guard's destructor runs the deferred finalize after step returns.
Add a connection-null check in StatementExecutionHelper::Run before
the connection-dependent reads, throwing ERR_INVALID_STATE.
Fixes: nodejs#63180
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from 8adcb3b to c6ce251CompareMay 9, 2026 03:24
Per SQLite's sqlite3_create_function() contract, closing the database
or finalizing/resetting a statement is forbidden while a user-supplied
callback is on the stack. The previous fix detected the close case and
deferred the finalize after sqlite3_step returned. Per reviewer
feedback, prevent the forbidden operations up front instead: track a
user-callback depth on DatabaseSync (via an RAII guard wrapped around
every JS call from xFunc, the aggregate xStep/xValue/xFinal/xInverse
and start callbacks, and the authorizer callback), and have db.close(),
the statement execution methods, the iterator's next/return, and the
SQL tag store's run/get/all/iterate/clear throw ERR_INVALID_STATE when
called from inside such a callback.
Removes the now-unreachable deferred-finalize machinery (stepping_,
finalize_pending_, MarkStepping) and the connection-null check in
StatementExecutionHelper::Run. Adds tests for scalar, aggregate, and
authorizer callbacks, and for reentrant iter.next().
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen
mceachenforce-pushed the fix-sqlite-reentrant-close-segv branch from c6ce251 to 20d7ffaCompareMay 9, 2026 03:28
@mceachen

Copy link
Copy Markdown
ContributorAuthor

Thanks @Renegade334 -- agreed, switched to prevention.

DatabaseSync now tracks a user-callback depth via an RAII guard wrapped around every SQLite-invoked JS call (scalar xFunc, aggregate xStep/xValue/xFinal/xInverse/start, authorizer). While depth > 0, db.close(), the statement and tag-store execution methods, the iterator's next/return, tagStore.clear(), and db.deserialize() throw ERR_INVALID_STATE. Forbidden state is now unreachable, so the deferred-finalize machinery and the connection == nullptr check are gone.

New tests: aggregate step/result/start, authorizer, and reentrant iter.next() (your stmt.get()-from-callback example).

I just rebased to main (hence the force-push). Happy to squash before landing.

@mceachen

mceachen commented May 9, 2026

Copy link
Copy Markdown
ContributorAuthor

Ugh, Derp, all nested statement execution from SQL functions is now rejected. I've added a regression test and will push a new diff soon.

Edit: the third commit is ready for review.

The previous commit's db-wide IsInUserFunctionCallback guard was too
broad: it rejected legitimate cross-statement use from inside a user
function (the common "lookup" pattern), e.g.
const lookup = db.prepare('SELECT v FROM lookup WHERE id = ?');
db.function('lookup_v', (id) => lookup.get(id).v);
SQLite only forbids reentry into the *currently running* statement
(recursive sqlite3_step, sqlite3_reset, or sqlite3_finalize), not
operations on other statements on the same connection.
Track per-statement stepping_ on StatementSync, set via a MarkStepping
RAII guard wrapped around each sqlite3_step caller. JS methods that
would step, reset, or finalize a statement (StatementSync run/get/all/
iterate, iterator next/return, SQLTagStore run/get/all/iterate) check
that flag on the specific statement instead of the db-wide depth.
Keep the db-wide IsInUserFunctionCallback check only where the
operation is broadly invasive: db.close, db.deserialize, and SQL tag
store .clear, all of which finalize tracked statements (potentially
the running one).
Drop the authorizer scope: SQLite's authorizer rules are stricter
than user-defined functions (prepare/exec are forbidden too) and
warrant a separate change rather than partial coverage here.
Add tests for the legitimate cross-statement and cross-tag-store
patterns (now passing), and for db[Symbol.dispose]() being a
documented no-op when invoked from a callback.
Signed-off-by: Matthew McEachen <matthew@photostructure.com>
@mceachen

Copy link
Copy Markdown
ContributorAuthor

@Renegade334 is there anything I can do or change to help this PR?
@geeksilva97 did you happen to see this?

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's also a filter callback for applying a changeset.

Comment threadsrc/node_sqlite.cc
THROW_AND_RETURN_ON_BAD_STATE(
env,
db->IsInUserFunctionCallback(),
"database cannot be closed inside a user-defined function callback");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard doesn't cover the authorizer callback, and that leaves the original use-after-free reachable.

The authorizer runs during statement preparation, and preparation can happen inside sqlite3_step: on SQLITE_SCHEMA the public sqlite3_step wrapper calls sqlite3Reprepare in a loop (deps/sqlite/sqlite3.c:94612-94614), which reaches sqlite3AuthCheck. So:

db.setAuthorizer(()=>{db.close();return0;});db.function('f',()=>{db.exec('CREATE TABLE z (a)');return1;});db.prepare('SELECT f(v) FROM t').all();// multi-row

Row 1's f() does DDL, which expires all prepared statements. On row 2 sqlite3_step returns SQLITE_SCHEMA, reprepares in place, and invokes the authorizer β€” where IsInUserFunctionCallback() is false, so db.close() goes through. FinalizeStatements() then finalizes the very VM whose sqlite3_step frame is live (crash 1 from the description), and zeroes connection_. Through StatementSync::Run the follow-on sqlite3_last_insert_rowid(db->Connection()) derefs null (crash 2) β€” commit 2 dropped the connection-null check commit 1 added.

The commit message defers the authorizer to a separate change, but this isn't a coverage nicety: it's the same crash, still reachable from pure JS, with the interim mitigations removed. Note main already wraps AuthorizerCallback (src/node_sqlite.cc:2552) β€” see my other comment.

Comment threadsrc/node_sqlite.h
// detected separately via the per-statement
// StatementSync::IsStepping() flag, which leaves cross-statement use
// (the "lookup" pattern) unaffected.
inline auto EnterUserFunctionCallback() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is based on bbf51ad24cb (2026-05-08) and adds a second mechanism alongside one main already has, with narrower coverage.

main carries IsInCallback() / callback_depth_ / class CallbackDepthGuard (src/node_sqlite.h:232-234,249,409-416) applied at five sites β€” including AuthorizerCallback (node_sqlite.cc:2552) and the sqlite3changeset_apply call (:2409) β€” plus post-callback IsOpen() checks in xFunc/xStepBase/xValueBase/GetAggregate.

Concretely, the renamed message here fails an existing test: test/parallel/test-sqlite-udf-close.js:33 on main asserts the exact string 'database cannot be closed while in a callback' for all of all/get/run/iterate.

Worth rebasing and building on the existing guard rather than in parallel with it. Two things not to lose in the process: main's authorizer and changeset coverage, and its post-callback IsOpen() checks. That should also make the unrelated sqlite3_reset β†’ ResetStatement() changes in SQLTagStore disappear, since those already match main.

@mceachen

Copy link
Copy Markdown
ContributorAuthor

Confirmed that #64743 addressed this.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

node:sqlite segfaults when db.close() is called from a user-defined function callback during query execution

5 participants

@mceachen@nodejs-github-bot@Renegade334@TrevorBurnham@louwers