Skip to content

sqlite: expandedSQL, sourceSQL, column names, and sqlite.db.query tracing abort the process on text longer than String::kMaxLength #66487

Description

@mceachen

Version

v26.10.0 (all repros below). The expandedSQL blob repro also aborts on v24.21.0 and v22.23.2.

Platform

Linux hostname 7.0.0-34-generic #34-Ubuntu SMP PREEMPT_DYNAMIC Wed Sep  2 14:29:37 UTC 2026 x86_64 GNU/Linux

Subsystem

sqlite

What steps will reproduce the bug?

statement.expandedSQL:

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

const db = new DatabaseSync(':memory:');
const stmt = db.prepare('SELECT ?');
stmt.run(Buffer.alloc((constants.MAX_STRING_LENGTH >>> 1) + 1));
stmt.expandedSQL; // aborts

sqlite.db.query diagnostics channel (v26.8.0 and later):

const { DatabaseSync } = require('node:sqlite');
const dc = require('node:diagnostics_channel');
const { constants } = require('node:buffer');

dc.subscribe('sqlite.db.query', () => {});
const db = new DatabaseSync(':memory:');
db.prepare('SELECT length(?)').get(
  Buffer.alloc((constants.MAX_STRING_LENGTH >>> 1) + 1)); // aborts

statement.sourceSQL:

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

const n = Math.ceil((constants.MAX_STRING_LENGTH + 1) / 3);
const db = new DatabaseSync(':memory:');
const stmt = db.prepare(`SELECT '${'€'.repeat(n)}'`);
stmt.sourceSQL; // aborts

In the first two, the blob is 268,435,445 bytes. sqlite3_expanded_sql() renders a bound blob as x'<hex>', so the expanded SQL is 536,870,900 bytes, which is more than String::kMaxLength (536,870,888 on 64-bit).

In the third, the SQL string has 178,956,972 characters, which is under String::kMaxLength, but its UTF-8 encoding is 536,870,898 bytes, which is over it.

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

Every run. The condition is SQL text whose UTF-8 encoding is longer than String::kMaxLength bytes:

  • expandedSQL and the trace callback: a bound parameter makes the expanded SQL that long. A blob does it at about 256 MiB. On v26.10.0, a text parameter of multi-byte characters also does it: stmt.run('€'.repeat(178956963)) followed by stmt.expandedSQL aborts.
  • sourceSQL: the SQL passed to prepare() is that long in UTF-8.

The two blob repros take about 8 s and reach about 830 MB peak RSS. The sourceSQL repro takes 7.4 s and reaches 3.0 GB. All were measured with /usr/bin/time.

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

The process should not abort.

  • statement.expandedSQL should throw ERR_STRING_TOO_LONG. Since c5b7a06 (sqlite: throw on oversized string values #66209), column values and user-defined function arguments longer than String::kMaxLength throw ERR_STRING_TOO_LONG, and buffer.toString() throws the same code for the same limit.
  • A statement traced through sqlite.db.query should complete as it does without a subscriber. If the SQL text cannot be represented as a string, the event can be skipped.
  • statement.sourceSQL should return the string or throw ERR_STRING_TOO_LONG. On v24.21.0 the sourceSQL repro and the text-parameter expandedSQL variant both return the string.

What do you see instead?

expandedSQL on v26.10.0 (exit code 133, SIGTRAP):

#
# Fatal error in , line 0
# Check failed: String::kMaxLength >= len.
#
#
#
#FailureMessage Object: 0x7ffdf8743130
----- Native stack trace -----
 1: 0xaa1c41  [node]
 2: 0x28ff6b1 V8_Fatal(char const*, ...) [node]
 3: 0xcf20be  [node]
 4: 0xbb1ff5 node::sqlite::StatementSync::ExpandedSQLGetter(v8::FunctionCallbackInfo<v8::Value> const&) [node]
 5: 0x73297fdcfae9
Trace/breakpoint trap (core dumped)

sqlite.db.query on v26.10.0 (exit code 133):

#
# Fatal error in , line 0
# Check failed: String::kMaxLength >= len.
#
#
#
#FailureMessage Object: 0x7ffd1b3ad090
----- Native stack trace -----
 1: 0xaa1c41  [node]
 2: 0x28ff6b1 V8_Fatal(char const*, ...) [node]
 3: 0xcf20be  [node]
 4: 0xba679c node::sqlite::DatabaseSync::TraceCallback(unsigned int, void*, void*, void*) [node]
 5: 0x22f1a46  [node]
 6: 0x22eda9d  [node]
 7: 0xbb07ac node::sqlite::StatementExecutionHelper::Get(node::Environment*, node::sqlite::StatementSync*) [node]
 8: 0xbb1368 node::sqlite::StatementSync::Get(v8::FunctionCallbackInfo<v8::Value> const&) [node]
 9: 0x746b6f58fae9
Trace/breakpoint trap (core dumped)

sourceSQL on v26.10.0 (exit code 133):

#
# Fatal error in , line 0
# Check failed: String::kMaxLength >= len.
#
#
#
#FailureMessage Object: 0x7ffe08b2f620
----- Native stack trace -----
 1: 0xaa1c41  [node]
 2: 0x28ff6b1 V8_Fatal(char const*, ...) [node]
 3: 0xcf20be  [node]
 4: 0xbb1ea6 node::sqlite::StatementSync::SourceSQLGetter(v8::FunctionCallbackInfo<v8::Value> const&) [node]
Trace/breakpoint trap (core dumped)

On v24.21.0 and v22.23.2 the blob expandedSQL repro fails a different check, Check failed: (location_) != nullptr., with the same ExpandedSQLGetter frame. The reason is below.

Additional information

This was discovered and repro'ed by claude opus 5.5.

These call sites pass SQLite's text to String::NewFromUtf8() without a length:

Each one then checks ToLocal(), but that code is not reached. In V8's NEW_STRING macro (deps/v8/src/api/api.cc#L7524-L7543), the check that returns an empty handle for length > String::kMaxLength runs only when length > 0. With the default length of -1, V8 measures the string itself:

  • V8 in v26.x and main (14.6): StringLength() runs CHECK_GE(String::kMaxLength, len) on the strlen() byte count (api.cc#L7470-L7475) and aborts. This CHECK is present from v26.0.0. v25.9.0 (V8 14.1) checks only against kMaxInt.
  • V8 in v24.x (13.6) and v22.x (12.4): StringLength() checks only against kMaxInt. Factory::NewStringFromUtf8() then sizes the string by its UTF-16 length. If that is over String::kMaxLength, NewRawStringWithMap() throws InvalidStringLength, returns an empty MaybeHandle, and the .ToHandleChecked() in NEW_STRING (v24.21.0 api.cc#L7624) aborts. If it is under, as in the sourceSQL repro, the string is created.

So the blob expandedSQL repro aborts on v22.23.2, v24.21.0, and v26.10.0. The sourceSQL repro and the text-parameter expandedSQL variant abort on v26.10.0 and return the string on v24.21.0.

With an explicit length greater than String::kMaxLength, NEW_STRING returns an empty MaybeLocal without throwing and without aborting.

TraceCallback uses the source SQL fallback when sqlite3_expanded_sql() returns NULL. That happens when the expansion would exceed SQLITE_LIMIT_LENGTH (1,000,000,000 bytes by default). This aborts v26.10.0 in TraceCallback (14.5 s, 2.7 GB peak RSS):

const { DatabaseSync } = require('node:sqlite');
const dc = require('node:diagnostics_channel');
const { constants } = require('node:buffer');

dc.subscribe('sqlite.db.query', () => {});
const n = Math.ceil((constants.MAX_STRING_LENGTH + 1) / 3);
const db = new DatabaseSync(':memory:');
const stmt = db.prepare(
  `SELECT length('${'€'.repeat(n)}') AS a, length(?) AS b`);
stmt.get(Buffer.alloc(240_000_000)); // aborts

Column names take the same path. Statement::ColumnNameToName (src/node_sqlite.cc#L4007) creates row object keys with NewFromUtf8(..., NewStringType::kInternalized) and no length, and SQLite names an unaliased result column after its expression text. This aborts v26.10.0 in StatementSync::GetCachedColumnNames (8.4 s, 3.5 GB peak RSS). On v24.21.0 it returns a row whose key has 178,956,973 characters:

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

const n = Math.ceil((constants.MAX_STRING_LENGTH + 1) / 3);
const db = new DatabaseSync(':memory:');
db.prepare(`SELECT length('${'€'.repeat(n)}')`).get(); // aborts

These calls also pass no length. I have not tested whether SQLite can produce text that long for them:

c5b7a06 added the ERR_STRING_TOO_LONG check in Utf8StringMaybeOneByte() (src/node_sqlite.cc#L74-L93) for column values and function arguments. It did not change these call sites.

Suggested fix (not built or tested against Node.js core):

  • ExpandedSQLGetter and SourceSQLGetter: pass the length, and throw ERR_STRING_TOO_LONG when V8 returns an empty handle. Utf8StringMaybeOneByte(env->isolate(), std::string_view(...)) already does both.
  • ColumnNameToName: same, but it needs kInternalized, so it would pass the length to NewFromUtf8() directly and throw ERR_STRING_TOO_LONG itself. GetCachedColumnNames() already returns false when it gets an empty handle.
  • TraceCallback, both calls: pass strlen() as the length. The existing return 0 on an empty handle then skips publishing and the traced statement completes, so subscribing to the channel does not change whether a statement succeeds. SQLite calls this callback from inside sqlite3_step(), sqlite3_reset(), or sqlite3_finalize(). Both strings are capped at 1,000,000,000 bytes by default (SQLITE_LIMIT_LENGTH and SQLITE_LIMIT_SQL_LENGTH), so the length fits in an int.

V8 compares an explicit length against String::kMaxLength in bytes, not UTF-16 code units. With this fix, the sourceSQL repro, the text-parameter expandedSQL variant, and the column-name repro would throw ERR_STRING_TOO_LONG, where v24.21.0 returns the string or row. Utf8StringMaybeOneByte() already rejects column values by byte length the same way. Returning those strings would require computing the UTF-16 length and transcoding before creating the string.

@photostructure/sqlite, a Node-API port of node:sqlite, hit the same abort through napi_create_string_utf8(..., NAPI_AUTO_LENGTH, ...), which takes the same length = -1 path. Its fix, photostructure/node-sqlite@095efcd, takes the approach above for expandedSQL and the trace callback: pass strlen(), throw ERR_STRING_TOO_LONG from expandedSQL, and skip the trace event.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    sqliteIssues and PRs related to the SQLite subsystem.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions