Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 38 additions & 31 deletions ext/node/ops/sqlite/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,16 @@ fn set_db_config(
}
}

/// Opens a SQLite database connection with appropriate permission checks.
///
/// Performs file-system permission checks via `state`, configures ATTACH
/// restrictions when the caller lacks full permissions for the path, and
/// enables or disables extension loading based on `allow_extension`.
///
/// When `allow_extension` is `true`, only the C API for extension loading is
/// enabled (the SQL `load_extension()` function remains disabled). No FFI
/// permission check is performed here; the check is deferred to `load_extension`
/// where the specific extension path can be validated against scoped permissions.
fn open_db(
state: &mut OpState,
readonly: bool,
Expand All @@ -462,15 +472,13 @@ fn open_db(
conn.set_limit(Limit::SQLITE_LIMIT_ATTACHED, 0)?;
}

if allow_extension {
perms.check_ffi_all()?;
} else {
assert!(set_db_config(
&conn,
SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION,
false
));
}
// Enable or disable C API extension loading (SQL function always disabled)
// Permission check deferred to loadExtension() where the specific path is validated
assert!(set_db_config(
&conn,
SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION,
allow_extension
));

return Ok(conn);
}
Expand Down Expand Up @@ -500,30 +508,26 @@ fn open_db(
conn.set_limit(Limit::SQLITE_LIMIT_ATTACHED, 0)?;
}

if allow_extension {
perms.check_ffi_all()?;
} else {
assert!(set_db_config(
&conn,
SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION,
false
));
}
// Enable or disable C API extension loading (SQL function always disabled)
// Permission check deferred to loadExtension() where the specific path is validated
assert!(set_db_config(
&conn,
SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION,
allow_extension
));

return Ok(conn);
}

let conn = rusqlite::Connection::open(location)?;

if allow_extension {
perms.check_ffi_all()?;
} else {
assert!(set_db_config(
&conn,
SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION,
false
));
}
// Enable or disable C API extension loading (SQL function always disabled)
// Permission check deferred to loadExtension() where the specific path is validated
assert!(set_db_config(
&conn,
SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION,
allow_extension
));

if disable_attach {
conn.set_limit(Limit::SQLITE_LIMIT_ATTACHED, 0)?;
Expand Down Expand Up @@ -1084,10 +1088,11 @@ impl DatabaseSync {
}
}

// Loads a SQLite extension.
// Loads a SQLite extension from the specified path.
//
// This is a wrapper around `sqlite3_load_extension`. It requires FFI permission
// to be granted and allowExtension must be set to true when opening the database.
// This is a wrapper around `sqlite3_load_extension`. It requires:
// - `allowExtension: true` when opening the database (which requires partial FFI permission)
// - FFI permission covering the extension path (e.g., `--allow-ffi=/path/to/extension.so`)
fn load_extension(
&self,
state: &mut OpState,
Expand All @@ -1106,7 +1111,9 @@ impl DatabaseSync {
));
}

state.borrow::<PermissionsContainer>().check_ffi_all()?;
state
.borrow_mut::<PermissionsContainer>()
.check_ffi_partial_with_path(Cow::Borrowed(Path::new(path)))?;

// SAFETY: lifetime of the connection is guaranteed by reference counting.
let raw_handle = unsafe { db.handle() };
Expand Down
165 changes: 160 additions & 5 deletions tests/sqlite_extension_test/sqlite_extension_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ const extensionPath = (() => {
return path.join(targetDir, `${libPrefix}test_sqlite_extension.${libSuffix}`);
})();

const extensionExists = (() => {
try {
Deno.statSync(extensionPath);
return true;
} catch {
return false;
}
})();

Deno.test({
name: "[node/sqlite] DatabaseSync loadExtension",
permissions: { read: true, write: true, ffi: true },
Expand All @@ -49,7 +58,7 @@ Deno.test({
db.loadExtension(extensionPath);

const stmt = db.prepare("SELECT test_func('Hello, World!') AS result");
const { result } = stmt.get();
const { result } = stmt.get()!;
assertEquals(result, "Hello, World!");

db.close();
Expand All @@ -60,12 +69,19 @@ Deno.test({
name: "[node/sqlite] DatabaseSync loadExtension with FFI permission denied",
permissions: { read: true, write: true, ffi: false },
fn() {
// Creating a database with allowExtension: true should succeed
// (permission check deferred to loadExtension)
const db = new DatabaseSync(":memory:", {
allowExtension: true,
readOnly: false,
});

// The error should occur when actually trying to load an extension
assertThrows(() => {
new DatabaseSync(":memory:", {
allowExtension: true,
readOnly: false,
});
db.loadExtension("/some/extension/path");
}, Deno.errors.NotCapable);

db.close();
},
});

Expand All @@ -86,3 +102,142 @@ Deno.test({
db.close();
},
});

// Tests for scoped FFI permissions (--allow-ffi=/path/to/extension)
// These require subprocess spawning since Deno.test permissions don't support scoped FFI

Deno.test({
name: "[node/sqlite] DatabaseSync with scoped FFI permission succeeds",
ignore: !extensionExists,
permissions: { read: true, run: true },
async fn() {
const code = `
import { DatabaseSync } from "node:sqlite";
const extensionPath = Deno.args[0];
const db = new DatabaseSync(":memory:", { allowExtension: true });
db.loadExtension(extensionPath);
const stmt = db.prepare("SELECT test_func('test') AS result");
const { result } = stmt.get()!;
if (result !== "test") throw new Error("Unexpected result: " + result);
db.close();
console.log("OK");
`;

const command = new Deno.Command(Deno.execPath(), {
args: [
"run",
`--allow-read=${extensionPath}`,
`--allow-ffi=${extensionPath}`,
"--no-lock",
"-",
extensionPath,
],
stdin: "piped",
stdout: "piped",
stderr: "piped",
});

const child = command.spawn();
const writer = child.stdin.getWriter();
await writer.write(new TextEncoder().encode(code));
await writer.close();

const { code: exitCode, stdout, stderr } = await child.output();
const stdoutText = new TextDecoder().decode(stdout);
const stderrText = new TextDecoder().decode(stderr);

assertEquals(exitCode, 0, `Expected success but got: ${stderrText}`);
assertEquals(stdoutText.trim(), "OK");
},
});

Deno.test({
name:
"[node/sqlite] DatabaseSync loadExtension fails for path outside scoped FFI",
ignore: !extensionExists,
permissions: { read: true, run: true },
async fn() {
// Grant FFI only for a different path, not the actual extension
const wrongPath = "/some/other/path";

const code = `
import { DatabaseSync } from "node:sqlite";
const extensionPath = Deno.args[0];
const db = new DatabaseSync(":memory:", { allowExtension: true });
try {
db.loadExtension(extensionPath);
console.log("UNEXPECTED_SUCCESS");
} catch (e) {
if (e instanceof Deno.errors.NotCapable) {
console.log("EXPECTED_PERMISSION_ERROR");
} else {
console.log("UNEXPECTED_ERROR: " + e.constructor.name + ": " + e.message);
}
}
db.close();
`;

const command = new Deno.Command(Deno.execPath(), {
args: [
"run",
`--allow-read=${extensionPath}`,
`--allow-ffi=${wrongPath}`,
"--no-lock",
"-",
extensionPath,
],
stdin: "piped",
stdout: "piped",
stderr: "piped",
});

const child = command.spawn();
const writer = child.stdin.getWriter();
await writer.write(new TextEncoder().encode(code));
await writer.close();

const { stdout } = await child.output();
const stdoutText = new TextDecoder().decode(stdout);

assertEquals(
stdoutText.trim(),
"EXPECTED_PERMISSION_ERROR",
`Expected NotCapable error but got: ${stdoutText}`,
);
},
});

Deno.test({
name:
"[node/sqlite] SQL load_extension() is disabled even with allowExtension: true",
ignore: !extensionExists,
permissions: { read: true, write: true, ffi: true },
fn() {
// Even with allowExtension: true and full FFI permissions,
// the SQL function load_extension() should be disabled.
// Only the C API loadExtension() method should work.
const db = new DatabaseSync(":memory:", {
allowExtension: true,
readOnly: false,
});

// Attempting to load extension via SQL should fail with "not authorized",
// even though the same extension loads successfully via C API
const loadExtStmt = db.prepare("SELECT load_extension($path)");
assertThrows(
() => {
loadExtStmt.get({ $path: extensionPath });
},
Error,
"not authorized",
);

// Verify the C API still works with the same extension
db.loadExtension(extensionPath);
const stmt = db.prepare("SELECT test_func('works') AS result");
const { result } = stmt.get()!;
assertEquals(result, "works");

db.close();
},
});
1 change: 1 addition & 0 deletions tests/sqlite_extension_test/tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ fn sqlite_extension_test() {
.arg("--allow-read")
.arg("--allow-write")
.arg("--allow-ffi")
.arg("--allow-run")
.arg("--config")
.arg(deno_config_path())
.arg("--no-check")
Expand Down
Loading