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
This commit is contained in:
2026-09-02 22:34:40 +02:00
parent 5c9f8bb321
commit 20e3d6aec4
3 changed files with 26 additions and 5 deletions
+20 -3
View File
@@ -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<LoadedCatalog> {
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)
@@ -24,7 +24,9 @@ pub struct CatalogGame {
/// duplicate game IDs.
pub async fn load_catalog_games(path: &Path) -> eyre::Result<BTreeMap<String, CatalogGame>> {
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)
+3 -1
View File
@@ -23,7 +23,9 @@ pub struct EtiGame {
/// # Errors
pub async fn get_games(db: &Path) -> eyre::Result<Vec<EtiGame>> {
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>(