From 20e3d6aec477a72a85ce097d1f25636684a5aa16 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Wed, 2 Sep 2026 22:34:40 +0200 Subject: [PATCH] fix(compat): open catalog databases with trusted_schema OFF Security audit finding SEC-DB-01. The three read-only opens of the catalog `game.db` (runtime bundle loader, legacy ETI reader and the catalog publisher) set only `read_only(true)`. SQLite still honours schema-embedded SQL in that mode: triggers, views, CHECK constraints and expression indexes may call functions with side effects or virtual tables unless `trusted_schema` is off. `harden_read_only_catalog_options` now applies `trusted_schema = OFF` and `cell_size_check = ON` to those connections. The database is a bundled application resource, not a remote input, so this is defense in depth against a corrupted or tampered bundle; it has no effect on the parameterised queries the code runs. Test plan: `just test` (the compat tests open real fixture databases through the hardened options). Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg --- crates/lanspread-compat/src/catalog_bundle.rs | 23 ++++++++++++++++--- .../src/catalog_publisher/catalog.rs | 4 +++- crates/lanspread-compat/src/eti.rs | 4 +++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/lanspread-compat/src/catalog_bundle.rs b/crates/lanspread-compat/src/catalog_bundle.rs index ad8ec08..87b2204 100644 --- a/crates/lanspread-compat/src/catalog_bundle.rs +++ b/crates/lanspread-compat/src/catalog_bundle.rs @@ -47,6 +47,21 @@ impl LoadedCatalog { } } +/// Applies defensive SQLite settings to a read-only catalog connection. +/// +/// `trusted_schema = OFF` stops SQL functions with side effects and virtual +/// tables from running out of triggers, views, CHECK constraints or indexes +/// stored inside the database file, and `cell_size_check = ON` makes the +/// b-tree layer validate cell sizes when reading pages. The catalog database +/// ships inside the application bundle, so this is defense in depth against a +/// corrupted or tampered resource rather than a remote input. +#[must_use] +pub fn harden_read_only_catalog_options(options: SqliteConnectOptions) -> SqliteConnectOptions { + options + .pragma("trusted_schema", "OFF") + .pragma("cell_size_check", "ON") +} + /// Loads the UI catalog and its exact content authority from one database /// snapshot. /// @@ -64,9 +79,11 @@ pub async fn load_catalog_bundle( manifests_root: &Path, ) -> eyre::Result { validate_regular_file(game_db_path, "catalog database")?; - let options = SqliteConnectOptions::new() - .filename(game_db_path) - .read_only(true); + let options = harden_read_only_catalog_options( + SqliteConnectOptions::new() + .filename(game_db_path) + .read_only(true), + ); let pool = SqlitePoolOptions::new() .max_connections(1) .connect_with(options) diff --git a/crates/lanspread-compat/src/catalog_publisher/catalog.rs b/crates/lanspread-compat/src/catalog_publisher/catalog.rs index ac78d83..49534e0 100644 --- a/crates/lanspread-compat/src/catalog_publisher/catalog.rs +++ b/crates/lanspread-compat/src/catalog_publisher/catalog.rs @@ -24,7 +24,9 @@ pub struct CatalogGame { /// duplicate game IDs. pub async fn load_catalog_games(path: &Path) -> eyre::Result> { validate_regular_file(path, "catalog database")?; - let options = SqliteConnectOptions::new().filename(path).read_only(true); + let options = crate::catalog_bundle::harden_read_only_catalog_options( + SqliteConnectOptions::new().filename(path).read_only(true), + ); let pool = SqlitePoolOptions::new() .max_connections(1) .connect_with(options) diff --git a/crates/lanspread-compat/src/eti.rs b/crates/lanspread-compat/src/eti.rs index f390c1e..ab9b392 100644 --- a/crates/lanspread-compat/src/eti.rs +++ b/crates/lanspread-compat/src/eti.rs @@ -23,7 +23,9 @@ pub struct EtiGame { /// # Errors pub async fn get_games(db: &Path) -> eyre::Result> { - let options = SqliteConnectOptions::new().filename(db).read_only(true); + let options = crate::catalog_bundle::harden_read_only_catalog_options( + SqliteConnectOptions::new().filename(db).read_only(true), + ); let pool = SqlitePoolOptions::new().connect_with(options).await?; let query_result = sqlx::query_as::<_, EtiGame>(