fix(db): restrict data/app.db to 0600 (#4420)

* fix(db): restrict data/app.db to 0600

app.db holds bearer-token hashes, bcrypt password hashes, and encrypted
provider keys but was created under the default umask (0644 -> world-readable),
unlike .app_key/vault/integrations which are already 0600 via safe_chmod.

init_db() now chmods the SQLite file to 0600 right after create_all (POSIX
only; no-op on Windows, skipped for Postgres / in-memory). Unconditional and
idempotent, so it also re-locks already-deployed 0644 installs on next
startup. The transient rollback journal inherits 0600 from the parent file at
creation - no sidecar handling needed; -wal/-shm don't exist until WAL is
enabled (#4409 C4) and inherit the same mode then.

Satisfies Rule B, unblocking #4413 and the vault/integration secret moves.
Mirrors src/secret_storage.py:43-45.

Verified: security + DB-permission suites pass; 6 pre-existing visual_report
failures (missing markdown/nh3 deps) are unrelated.

Closes #4407

* fix(db): harden SQLite path parsing and re-lock sidecars

Address review feedback on #4420.

P2: derive the file to chmod from engine.url (SQLAlchemy's parsed URL)
via _sqlite_db_path(), instead of DATABASE_URL.replace("sqlite:///", "").
A driver-qualified URL (sqlite+pysqlite://) or one carrying query args
(?cache=shared) previously slipped past the prefix check / string slice
and left the DB world-readable; the parsed path resolves correctly and
drops the query.

P3: re-lock stale -wal/-shm/-journal sidecars to 0o600 at startup. The
main file is chmod'd first, so any sidecar SQLite creates afterward
inherits 0o600, but a -wal/-shm left world-readable by an older 0o644
install (once WAL was enabled) could still expose DB pages. Absent
sidecars are the normal case, not an error.

Tests: unit-test _sqlite_db_path across driver/query/memory/postgres URL
forms, and a subprocess test asserting stale 0o644 -wal/-shm are
re-locked on startup.

* fix(db): handle sqlite file URI app db permissions

* fix(db): close remaining SQLite permission bypasses

---------

Co-authored-by: Ethan <23321960+0xLeathery@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
This commit is contained in:
Ethan
2026-07-12 05:15:49 +10:00
committed by GitHub
parent f13e54de6e
commit df2fad2881
2 changed files with 380 additions and 5 deletions
+112 -5
View File
@@ -3,13 +3,16 @@ import logging
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from urllib.parse import unquote, urlparse
from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text
from sqlalchemy.engine import Engine
from sqlalchemy.engine import Engine, make_url
from sqlalchemy.types import TypeDecorator
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.orm import relationship, sessionmaker, backref
from src.runtime_paths import get_app_root
from core.platform_compat import safe_chmod, IS_WINDOWS
logger = logging.getLogger(__name__)
@@ -42,12 +45,28 @@ def _default_database_url() -> str:
def _normalize_sqlite_url(url: str) -> str:
if not url.startswith("sqlite:///"):
"""Resolve relative ordinary SQLite paths without rewriting URI filenames."""
try:
parsed = make_url(url)
except Exception:
return url
db_path = url.replace("sqlite:///", "", 1)
if db_path == ":memory:" or os.path.isabs(db_path):
if parsed.get_backend_name() != "sqlite":
return url
return f"sqlite:///{(Path(get_app_root()) / db_path).resolve().as_posix()}"
db_path = parsed.database
if (
not db_path
or db_path == ":memory:"
or str(db_path).lower().startswith("file:")
or os.path.isabs(str(db_path))
):
return url
absolute_path = (Path(get_app_root()) / str(db_path)).resolve().as_posix()
return parsed.set(database=absolute_path).render_as_string(
hide_password=False
)
# Get database URL from environment, default to SQLite in DATA_DIR
@@ -59,6 +78,59 @@ engine = create_engine(
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
)
# Sidecar files SQLite can create next to the main DB. -journal is the default
# rollback journal; -wal/-shm appear once WAL is enabled. Each can hold copies of
# secret-bearing pages, so they get the same 0o600 lockdown as the DB itself.
_SQLITE_SIDECARS = ("-journal", "-wal", "-shm")
def _sqlite_db_path(url) -> Optional[str]:
"""Return the filesystem path for a file-backed SQLite URL.
SQLite query parameters such as ``mode=memory`` only affect filename
semantics when SQLAlchemy enables URI handling with ``uri=true``. Ordinary
file URLs must therefore remain file-backed even when they contain a query
parameter named ``mode``.
For SQLite ``file:`` URIs, an empty authority or ``localhost`` identifies a
local path. Other authorities are retained as UNC-style paths.
"""
if url.get_backend_name() != "sqlite":
return None
db_path = url.database
if not db_path or db_path == ":memory:":
return None
db_path = str(db_path)
query = {
str(key).lower(): str(value).strip().lower()
for key, value in dict(getattr(url, "query", {}) or {}).items()
}
uri_enabled = query.get("uri") in {"1", "true", "yes", "on"}
is_file_uri = db_path.lower().startswith("file:")
if not uri_enabled or not is_file_uri:
return db_path
if (
db_path.lower().startswith("file::memory:")
or query.get("mode") == "memory"
):
return None
parsed = urlparse(db_path)
fs_path = parsed.path or ""
if not fs_path or fs_path == ":memory:":
return None
authority = parsed.netloc
if authority and authority.lower() != "localhost":
fs_path = f"//{authority}{fs_path}"
return unquote(fs_path)
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@@ -1819,6 +1891,41 @@ def init_db():
"""
_migrate_model_endpoints()
Base.metadata.create_all(bind=engine)
# Lock the DB file (and any SQLite sidecars) to 0o600 — it holds bearer-token
# + bcrypt hashes and encrypted provider keys. POSIX only; safe_chmod no-ops
# on Windows (ACL-restricted profile dir) and the path helper returns None for
# Postgres / in-memory. Must stay AFTER create_all: the file is born here at
# the umask default, and nothing below resets the mode. The path comes from
# engine.url (SQLAlchemy's parsed URL), so a driver-qualified or query-tagged
# DATABASE_URL still resolves to the real file instead of slipping through.
db_path = _sqlite_db_path(engine.url)
if db_path is not None:
# Fail closed-loud on the main file: this is the only access control on
# it, so if the chmod genuinely fails (read-only FS, foreign owner) an
# operator should hear about it. safe_chmod also returns False as a
# Windows no-op, so guard on IS_WINDOWS to avoid a spurious warning there.
if not safe_chmod(db_path, 0o600) and not IS_WINDOWS:
logger.warning(
"Could not restrict %s to 0o600; it holds secrets and may be "
"world-readable. Check filesystem permissions and ownership.",
db_path,
)
# Re-lock any sidecars present at startup. New ones inherit the main
# file's mode (now 0o600, since we set it first), and they're usually
# absent here, but a stale -wal/-shm/-journal left by an older 0o644
# install could still expose secret pages. Absent sidecars are the
# normal case, not an error — only a failed chmod warrants a warning.
for suffix in _SQLITE_SIDECARS:
sidecar = db_path + suffix
if (
os.path.exists(sidecar)
and not safe_chmod(sidecar, 0o600)
and not IS_WINDOWS
):
logger.warning(
"Could not restrict %s to 0o600; it may expose DB pages.",
sidecar,
)
_migrate_add_hidden_models_column()
_migrate_add_cached_models_column()
_migrate_add_pinned_models_column()