From fa30740aa972eb25ed908b9a0171eafc905bff6e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 13 Jul 2026 23:07:59 -0300 Subject: [PATCH] fix(cli): verify better-sqlite3 native binary is actually loadable (port from 9router#2493) isBetterSqliteBinaryValid() only checked the .node file's magic bytes (ELF/Mach-O/PE header), never whether the binary was built for the ABI (NODE_MODULE_VERSION) of the Node runtime that loads it. A stale or foreign-ABI binary passed the check and then segfaulted the process on the first database call instead of triggering a rebuild via npmInstallRuntime(). The fix adds a real load probe (require() in a throwaway subprocess) after the magic-byte check, so an incompatible binary is now correctly reported as invalid and the runtime self-heal reinstalls it. Reported-by: Manikandan (@mrprohack) (https://github.com/decolua/9router/issues/2493) --- CHANGELOG.md | 2 ++ bin/cli/runtime/nativeDeps.mjs | 41 +++++++++++++++++++++++++--- tests/unit/cli-runtime.test.ts | 50 ++++++++++++++++++++++++++++++---- 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab0f12866..a1b154147e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ ## [3.8.49] — TBD +- **fix(cli):** the runtime self-heal now verifies a cached `better-sqlite3` native binary actually loads for the running Node before trusting it — the old check only inspected the file's magic bytes (ELF/Mach-O/PE header), so a binary built for a different Node ABI passed validation and segfaulted the process on first use instead of triggering a rebuild. (thanks @mrprohack) + --- ## [3.8.48] — 2026-07-13 diff --git a/bin/cli/runtime/nativeDeps.mjs b/bin/cli/runtime/nativeDeps.mjs index 2a0787bd04..1dc442270e 100644 --- a/bin/cli/runtime/nativeDeps.mjs +++ b/bin/cli/runtime/nativeDeps.mjs @@ -52,6 +52,31 @@ export function hasModule(name) { return existsSync(join(runtimeModules(), name, "package.json")); } +/** + * Probe whether a native addon (.node) file can actually be dlopen'd by the Node runtime that + * is going to load it. Runs in a throwaway subprocess so a real ABI mismatch (which can segfault + * the process instead of throwing) never takes down the caller — only the probe subprocess. + */ +function probeNativeBinaryLoadable(binary) { + try { + const res = spawnSync( + process.execPath, + [ + "-e", + "try { require(process.argv[1]); process.exit(0); } catch (e) { process.exit(1); }", + binary, + ], + { timeout: 10_000, stdio: "ignore" } + ); + // status === 0 means require() (and therefore dlopen) succeeded. Anything else — a thrown + // ERR_DLOPEN_FAILED/NODE_MODULE_VERSION mismatch (status 1) or a crash (status null with a + // signal, e.g. SIGSEGV) — means the binary is not safe to load. + return res.status === 0; + } catch { + return false; + } +} + export function isBetterSqliteBinaryValid() { const binary = join( runtimeModules(), @@ -68,10 +93,18 @@ export function isBetterSqliteBinaryValid() { closeSync(fd); const magic = buf.toString("hex"); const os = platform(); - if (os === "linux") return magic.startsWith("7f454c46"); // ELF - if (os === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O - if (os === "win32") return magic.startsWith("4d5a"); // PE/MZ - return true; + let formatOk; + if (os === "linux") formatOk = magic.startsWith("7f454c46"); // ELF + else if (os === "darwin") + formatOk = magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O + else if (os === "win32") formatOk = magic.startsWith("4d5a"); // PE/MZ + else formatOk = true; + if (!formatOk) return false; + // File-format magic bytes alone do not guarantee the binary was built for the Node ABI + // (NODE_MODULE_VERSION) that will load it — a stale/foreign-ABI binary passes the header + // check and then crashes (segfault) on load instead of triggering a rebuild. Actually + // attempt to load it, isolated in a subprocess. + return probeNativeBinaryLoadable(binary); } catch { return false; } diff --git a/tests/unit/cli-runtime.test.ts b/tests/unit/cli-runtime.test.ts index 46a3e3204c..293f6a3933 100644 --- a/tests/unit/cli-runtime.test.ts +++ b/tests/unit/cli-runtime.test.ts @@ -71,20 +71,60 @@ test("buildEnvWithRuntime preserva NODE_PATH existente", async () => { assert.ok(env.NODE_PATH.includes("/existing/path"), "NODE_PATH original deve ser preservado"); }); -test("isBetterSqliteBinaryValid detecta ELF magic bytes (Linux)", async () => { +test("isBetterSqliteBinaryValid rejeita binário com magic bytes válidos mas ABI incompatível (regressão #2493)", async () => { + // Regression for upstream 9router#2493: a binary that only "looks" native (correct ELF/Mach-O/PE + // header) but was built for a different Node ABI (NODE_MODULE_VERSION) must NOT be reported as + // valid — loading it crashes the process (segfault) instead of triggering a rebuild. const { getRuntimeNodeModules, isBetterSqliteBinaryValid } = await import("../../bin/cli/runtime/nativeDeps.mjs"); const nm = getRuntimeNodeModules(); const buildDir = join(nm, "better-sqlite3", "build", "Release"); mkdirSync(buildDir, { recursive: true }); const binary = join(buildDir, "better_sqlite3.node"); - const buf = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00, 0x00, 0x00]); + const { platform } = await import("node:os"); + const os = platform(); + // Correct file-format magic bytes for the current OS, but not a real, loadable native addon — + // this is exactly what the old magic-bytes-only check let through. + const magicByPlatform = { + linux: [0x7f, 0x45, 0x4c, 0x46], + darwin: [0xcf, 0xfa, 0xed, 0xfe], + win32: [0x4d, 0x5a], + }; + const magic = magicByPlatform[os] ?? magicByPlatform.linux; + const buf = Buffer.concat([Buffer.from(magic), Buffer.alloc(64, 0)]); writeFileSync(binary, buf); const result = isBetterSqliteBinaryValid(); - const { platform } = await import("node:os"); - if (platform() === "linux") { - assert.equal(result, true, "ELF magic bytes devem ser válidos no Linux"); + assert.equal( + result, + false, + "binário com header válido mas ABI/conteúdo incompatível deve ser inválido" + ); + rmSync(join(nm, "better-sqlite3"), { recursive: true, force: true }); +}); + +test("isBetterSqliteBinaryValid aceita um binário nativo real e carregável", async () => { + const { getRuntimeNodeModules, isBetterSqliteBinaryValid } = + await import("../../bin/cli/runtime/nativeDeps.mjs"); + const { existsSync, copyFileSync } = await import("node:fs"); + const realBinary = join( + process.cwd(), + "node_modules", + "better-sqlite3", + "build", + "Release", + "better_sqlite3.node" + ); + if (!existsSync(realBinary)) { + // Ambient runtime without a compiled better-sqlite3 binary — nothing to assert here. + return; } + const nm = getRuntimeNodeModules(); + const buildDir = join(nm, "better-sqlite3", "build", "Release"); + mkdirSync(buildDir, { recursive: true }); + const binary = join(buildDir, "better_sqlite3.node"); + copyFileSync(realBinary, binary); + const result = isBetterSqliteBinaryValid(); + assert.equal(result, true, "um binário real, compatível com o Node atual, deve ser válido"); rmSync(join(nm, "better-sqlite3"), { recursive: true, force: true }); });