From bab5f76cc003a39ec2f16536f6138b400ce79c98 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:37:22 +0000 Subject: [PATCH 1/4] bun:sqlite: reject paths containing null bytes new Database(path) and db.loadExtension(path) passed the path to sqlite via a C string, so an embedded null byte silently truncated it: the file sqlite actually opened or created was the prefix before the null byte, while db.filename echoed the full JS string. Any suffix or tenant check done on the JS string did not describe the file really opened. The same truncation applied to the Bun.SQL sqlite adapter, which wraps bun:sqlite. node:sqlite in the same binary already rejects such paths. Throw a TypeError before calling into sqlite, matching the existing guard in NodeSqlite.cpp. Also branch the 10k-insert statement finalization stress test on isDebug: debug+ASAN builds exceed the default test timeout at 10k awaited inserts. --- src/jsc/bindings/sqlite/JSSQLStatement.cpp | 12 +++++++ test/js/bun/sqlite/sqlite.test.js | 38 ++++++++++++++++++++++ test/js/sql/sqlite-sql.test.ts | 23 +++++++++++-- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 09cc3b05683e..2eec56ef380f 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -1413,6 +1413,11 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementLoadExtensionFunction, (JSC::JSGlobalObje auto extensionString = extension.toWTFString(lexicalGlobalObject); RETURN_IF_EXCEPTION(scope, {}); + if (extensionString.find('\0') != WTF::notFound) [[unlikely]] { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "The extension path must not contain null bytes"_s)); + return {}; + } + sqlite3* db = versionDB->handle(); if (!db) [[unlikely]] { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Can't do this on a closed database"_s)); @@ -1777,6 +1782,13 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementOpenStatementFunction, (JSC::JSGlobalObje String path = pathValue.toWTFString(lexicalGlobalObject); RETURN_IF_EXCEPTION(topExceptionScope, JSValue::encode(jsUndefined())); (void)topExceptionScope.tryClearException(); + if (path.find('\0') != WTF::notFound) [[unlikely]] { + // utf8().data() below is a C string, so an embedded NUL would + // silently truncate the path sqlite opens while db.filename keeps + // the full string. + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "The database path must not contain null bytes"_s)); + return {}; + } int openFlags = DEFAULT_SQLITE_FLAGS; if (callFrame->argumentCount() > 1) { JSValue flags = callFrame->argument(1); diff --git a/test/js/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index dd67228c37f1..3e063bcc4987 100644 --- a/test/js/bun/sqlite/sqlite.test.js +++ b/test/js/bun/sqlite/sqlite.test.js @@ -2575,3 +2575,41 @@ it("exec/run with an embedded NUL byte in the SQL string does not hang", async ( exitCode: 0, }); }); + +describe("paths with embedded null bytes", () => { + it("new Database() rejects them instead of truncating at the null byte", () => { + using dir = tempDir("sqlite-nul-path", {}); + expect(() => new Database(path.join(String(dir), "db1\0zz.sqlite"))).toThrow( + "The database path must not contain null bytes", + ); + // Without the guard, sqlite silently created the truncated path "db1". + expect(readdirSync(String(dir))).toEqual([]); + }); + + it("cannot open a different file than the one the JS string names", () => { + using dir = tempDir("sqlite-nul-suffix", {}); + const real = path.join(String(dir), "real.sqlite"); + { + const db = new Database(real); + db.run("CREATE TABLE s (v TEXT)"); + db.run("INSERT INTO s VALUES ('secret')"); + db.close(); + } + // Without the guard, this opened real.sqlite even though the JS string + // ends with ".tmp". + expect(() => new Database(real + "\0.tmp", { readonly: true })).toThrow( + "The database path must not contain null bytes", + ); + }); + + it("loadExtension() rejects them", () => { + const db = new Database(":memory:"); + try { + expect(() => db.loadExtension("does-not-exist\0.so")).toThrow( + "The extension path must not contain null bytes", + ); + } finally { + db.close(); + } + }); +}); diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 1a34b8264d61..6e622da72024 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1,6 +1,6 @@ import { randomUUIDv7, SQL } from "bun"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; -import { tempDir } from "harness"; +import { isDebug, tempDir } from "harness"; import { existsSync } from "node:fs"; import { rm, stat } from "node:fs/promises"; import { join } from "node:path"; @@ -2014,7 +2014,9 @@ describe("Memory and resource management", () => { await sql`CREATE TABLE stmt_test (id INTEGER PRIMARY KEY, value TEXT)`; - const iterations = 10000; + // Debug+ASAN builds run 10-100x slower; 10k awaited inserts blow the + // default test timeout there. + const iterations = isDebug ? 1000 : 10000; for (let i = 0; i < iterations; i++) { await sql`INSERT INTO stmt_test (id, value) VALUES (${i}, ${"test" + i})`; @@ -5403,3 +5405,20 @@ describe("Unicode & Encoding Fuzzing Tests", () => { } }); }); + +describe("filenames with embedded null bytes", () => { + test("are rejected instead of truncated at the null byte", async () => { + using dir = tempDir("sql-nul-path", {}); + await using sql = new SQL({ adapter: "sqlite", filename: join(String(dir), "db3\0zz.sqlite") }); + let error: Error | null = null; + try { + await sql`select 1`; + } catch (e) { + error = e as Error; + } + expect(error).not.toBeNull(); + expect(error!.message).toContain("The database path must not contain null bytes"); + // Without the guard, sqlite silently created the truncated path "db3". + expect(existsSync(join(String(dir), "db3"))).toBe(false); + }); +}); From 8703cf4a578ff26b6422efb988ae24f073ab6ce0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:39:48 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- test/js/bun/sqlite/sqlite.test.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/js/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index 3e063bcc4987..3b6487e80aaa 100644 --- a/test/js/bun/sqlite/sqlite.test.js +++ b/test/js/bun/sqlite/sqlite.test.js @@ -2605,9 +2605,7 @@ describe("paths with embedded null bytes", () => { it("loadExtension() rejects them", () => { const db = new Database(":memory:"); try { - expect(() => db.loadExtension("does-not-exist\0.so")).toThrow( - "The extension path must not contain null bytes", - ); + expect(() => db.loadExtension("does-not-exist\0.so")).toThrow("The extension path must not contain null bytes"); } finally { db.close(); } From 569ebaff5bb3a1c3a1faa987b5f1c8ec0682f231 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:41:01 +0000 Subject: [PATCH 3/4] remove redundant comment on null byte guard --- src/jsc/bindings/sqlite/JSSQLStatement.cpp | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 2eec56ef380f..f6ba5f22ca19 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -1783,9 +1783,6 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementOpenStatementFunction, (JSC::JSGlobalObje RETURN_IF_EXCEPTION(topExceptionScope, JSValue::encode(jsUndefined())); (void)topExceptionScope.tryClearException(); if (path.find('\0') != WTF::notFound) [[unlikely]] { - // utf8().data() below is a C string, so an embedded NUL would - // silently truncate the path sqlite opens while db.filename keeps - // the full string. throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "The database path must not contain null bytes"_s)); return {}; } From 5b0480c6ce4b76f1d2c5b1505a5a43b602b24b35 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:56:40 +0000 Subject: [PATCH 4/4] guard the remaining sqlite string-to-C-path sites loadExtension's entryPoint argument and Database.setCustomSQLite's library path go through the same C string conversion, so give them the same null byte rejection. Argument validation in loadExtension now runs before the db-state checks so the error is uniform across platforms, and setCustomSQLite validates on all platforms even where the path is otherwise ignored. --- src/jsc/bindings/sqlite/JSSQLStatement.cpp | 19 +++++++++++++++---- test/js/bun/sqlite/sqlite.test.js | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index f6ba5f22ca19..83739d384f05 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -1211,6 +1211,13 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementSetCustomSQLite, (JSC::JSGlobalObject * l return {}; } + String sqlitePath = sqliteStrValue.toWTFString(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + if (sqlitePath.find('\0') != WTF::notFound) [[unlikely]] { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "The SQLite library path must not contain null bytes"_s)); + return {}; + } + #if LAZY_LOAD_SQLITE if (sqlite3_handle) { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "SQLite already loaded\nThis function can only be called before SQLite has been loaded and exactly once. SQLite auto-loads when the first time you open a Database."_s)); @@ -1219,8 +1226,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementSetCustomSQLite, (JSC::JSGlobalObject * l // Use a static CString to keep the string alive for the lifetime of the process static CString sqlite3_lib_path_storage; - sqlite3_lib_path_storage = sqliteStrValue.toWTFString(lexicalGlobalObject).utf8(); - RETURN_IF_EXCEPTION(scope, {}); + sqlite3_lib_path_storage = sqlitePath.utf8(); sqlite3_lib_path = sqlite3_lib_path_storage.data(); if (lazyLoadSQLite() == -1) { @@ -1418,6 +1424,13 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementLoadExtensionFunction, (JSC::JSGlobalObje return {}; } + auto entryPointStr = callFrame->argumentCount() > 2 && callFrame->argument(2).isString() ? callFrame->argument(2).toWTFString(lexicalGlobalObject) : String(); + RETURN_IF_EXCEPTION(scope, {}); + if (entryPointStr.find('\0') != WTF::notFound) [[unlikely]] { + throwException(lexicalGlobalObject, scope, createTypeError(lexicalGlobalObject, "The extension entry point must not contain null bytes"_s)); + return {}; + } + sqlite3* db = versionDB->handle(); if (!db) [[unlikely]] { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Can't do this on a closed database"_s)); @@ -1429,8 +1442,6 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementLoadExtensionFunction, (JSC::JSGlobalObje return {}; } - auto entryPointStr = callFrame->argumentCount() > 2 && callFrame->argument(2).isString() ? callFrame->argument(2).toWTFString(lexicalGlobalObject) : String(); - RETURN_IF_EXCEPTION(scope, {}); auto entryPointUtf8 = entryPointStr.utf8(); const char* entryPoint = entryPointStr.length() == 0 ? NULL : entryPointUtf8.data(); auto extensionStringUtf8 = extensionString.utf8(); diff --git a/test/js/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index 3b6487e80aaa..ef701856bbdd 100644 --- a/test/js/bun/sqlite/sqlite.test.js +++ b/test/js/bun/sqlite/sqlite.test.js @@ -2610,4 +2610,21 @@ describe("paths with embedded null bytes", () => { db.close(); } }); + + it("loadExtension() rejects entry points containing them", () => { + const db = new Database(":memory:"); + try { + expect(() => db.loadExtension("does-not-exist.so", "entry\0point")).toThrow( + "The extension entry point must not contain null bytes", + ); + } finally { + db.close(); + } + }); + + it("setCustomSQLite() rejects them", () => { + expect(() => Database.setCustomSQLite("libsqlite3\0.dylib")).toThrow( + "The SQLite library path must not contain null bytes", + ); + }); });