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)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-13 23:07:59 -03:00
parent 2c62333b0b
commit fa30740aa9
3 changed files with 84 additions and 9 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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 });
});