Skip to content

Commit c41be50

Browse files
committed
fix(server): restrict SQLite database file permissions to 0o600
1 parent 0797fef commit c41be50

3 files changed

Lines changed: 84 additions & 2 deletions

File tree

architecture/gateway.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ This keeps the gateway data model portable across storage backends and leaves
104104
room for future stores that can provide the same object, label, version, and
105105
scope semantics.
106106

107+
The SQLite adapter tightens the on-disk database file to mode `0o600` on every
108+
connect so that provider API keys, SSH session tokens, and sandbox metadata are
109+
not readable by other local users on shared hosts.
110+
107111
Persisted state includes sandboxes, providers, SSH sessions, policy revisions,
108112
settings, inference configuration, and deployment records.
109113

crates/openshell-server/src/persistence/sqlite.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
use super::{
5-
DraftChunkRecord, ObjectRecord, PersistenceResult, PolicyRecord, current_time_ms, map_db_error,
6-
map_migrate_error,
5+
DraftChunkRecord, ObjectRecord, PersistenceError, PersistenceResult, PolicyRecord,
6+
current_time_ms, map_db_error, map_migrate_error,
77
};
88
use crate::policy_store::{
99
draft_chunk_payload_from_record, draft_chunk_record_from_parts, policy_payload_from_record,
1010
policy_record_from_parts,
1111
};
1212
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
1313
use sqlx::{Row, SqlitePool};
14+
use std::path::Path;
1415
use std::str::FromStr;
1516

1617
static SQLITE_MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations/sqlite");
@@ -40,11 +41,20 @@ impl SqliteStore {
4041
pool_options = pool_options.idle_timeout(None).max_lifetime(None);
4142
}
4243

44+
// Capture the on-disk path before `connect_with` consumes the options
45+
// so we can restrict the permissions after the database is connected.
46+
let db_path = (!is_in_memory).then(|| options.get_filename().to_path_buf());
47+
4348
let pool = pool_options
4449
.connect_with(options)
4550
.await
4651
.map_err(|e| map_db_error(&e))?;
4752

53+
// Tighten the permissions of the database file to owner-only access (0o600).
54+
if let Some(path) = db_path {
55+
restrict_db_file_permissions(&path)?;
56+
}
57+
4858
Ok(Self { pool })
4959
}
5060

@@ -616,6 +626,26 @@ fn draft_chunk_dedup_key(chunk: &DraftChunkRecord) -> String {
616626
format!("{}|{}|{}", chunk.host, chunk.port, chunk.binary)
617627
}
618628

629+
/// Restrict an on-disk `SQLite` database file to owner-only read/write (0o600).
630+
fn restrict_db_file_permissions(path: &Path) -> PersistenceResult<()> {
631+
#[cfg(unix)]
632+
{
633+
use std::os::unix::fs::PermissionsExt;
634+
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|err| {
635+
PersistenceError::Database(format!(
636+
"failed to restrict permissions on {}: {err}",
637+
path.display()
638+
))
639+
})?;
640+
}
641+
#[cfg(not(unix))]
642+
{
643+
let _ = path;
644+
warn!("restrict_db_file_permissions is a no-op on non-Unix platforms");
645+
}
646+
Ok(())
647+
}
648+
619649
fn row_to_object_record(row: sqlx::sqlite::SqliteRow) -> ObjectRecord {
620650
ObjectRecord {
621651
object_type: row.get("object_type"),

crates/openshell-server/src/persistence/tests.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,54 @@ async fn sqlite_connect_runs_embedded_migrations() {
3434
assert!(records.is_empty());
3535
}
3636

37+
#[cfg(unix)]
38+
#[tokio::test]
39+
async fn sqlite_connect_restricts_db_file_permissions() {
40+
use std::os::unix::fs::PermissionsExt;
41+
42+
let tmp = tempfile::tempdir().expect("tempdir");
43+
let db_path = tmp.path().join("openshell.db");
44+
let url = format!("sqlite:{}?mode=rwc", db_path.display());
45+
46+
let _store = Store::connect(&url).await.expect("connect to sqlite");
47+
48+
let mode = std::fs::metadata(&db_path)
49+
.expect("db file exists")
50+
.permissions()
51+
.mode()
52+
& 0o777;
53+
assert_eq!(mode, 0o600, "expected 0600, got {mode:04o}");
54+
}
55+
56+
#[cfg(unix)]
57+
#[tokio::test]
58+
async fn sqlite_connect_tightens_existing_db_file_permissions() {
59+
use std::os::unix::fs::PermissionsExt;
60+
61+
let tmp = tempfile::tempdir().expect("tempdir");
62+
let db_path = tmp.path().join("openshell.db");
63+
let url = format!("sqlite:{}?mode=rwc", db_path.display());
64+
65+
// First connect creates the file; close the pool by dropping the store.
66+
{
67+
let _store = Store::connect(&url).await.expect("initial connect");
68+
}
69+
70+
// Simulate a pre-existing database left with permissive permissions
71+
// (e.g., from an older gateway version that lacked this hardening).
72+
std::fs::set_permissions(&db_path, std::fs::Permissions::from_mode(0o644))
73+
.expect("loosen permissions");
74+
75+
let _store = Store::connect(&url).await.expect("reconnect to sqlite");
76+
77+
let mode = std::fs::metadata(&db_path)
78+
.expect("db file exists")
79+
.permissions()
80+
.mode()
81+
& 0o777;
82+
assert_eq!(mode, 0o600, "expected 0600, got {mode:04o}");
83+
}
84+
3785
#[tokio::test]
3886
async fn sqlite_updates_timestamp() {
3987
let store = Store::connect("sqlite::memory:?cache=shared")

0 commit comments

Comments
 (0)