mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-15 12:58:04 +00:00
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:
+112
-5
@@ -3,13 +3,16 @@ import logging
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
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 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.types import TypeDecorator
|
||||||
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
||||||
from sqlalchemy.orm import relationship, sessionmaker, backref
|
from sqlalchemy.orm import relationship, sessionmaker, backref
|
||||||
|
|
||||||
from src.runtime_paths import get_app_root
|
from src.runtime_paths import get_app_root
|
||||||
|
from core.platform_compat import safe_chmod, IS_WINDOWS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,12 +45,28 @@ def _default_database_url() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_sqlite_url(url: str) -> 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
|
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 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
|
# 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 {}
|
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
|
# Create session factory
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
@@ -1819,6 +1891,41 @@ def init_db():
|
|||||||
"""
|
"""
|
||||||
_migrate_model_endpoints()
|
_migrate_model_endpoints()
|
||||||
Base.metadata.create_all(bind=engine)
|
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_hidden_models_column()
|
||||||
_migrate_add_cached_models_column()
|
_migrate_add_cached_models_column()
|
||||||
_migrate_add_pinned_models_column()
|
_migrate_add_pinned_models_column()
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform == "win32",
|
||||||
|
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
|
||||||
|
)
|
||||||
|
def test_app_db_created_with_0600(tmp_path):
|
||||||
|
"""app.db holds secrets — it must not be world-readable.
|
||||||
|
|
||||||
|
Note: under umask 077 a fresh sqlite file is born 0600 and this would pass
|
||||||
|
even without the chmod; dev/CI umask is 022, where the chmod is what makes
|
||||||
|
it pass. No umask machinery needed — just don't read a green here as proof
|
||||||
|
on a 077 box.
|
||||||
|
|
||||||
|
A subprocess (not in-process patching) is used deliberately: the engine
|
||||||
|
binds to DATABASE_URL at import time, so a fresh interpreter with its own
|
||||||
|
DATABASE_URL is the clean way to exercise init_db() against a real on-disk
|
||||||
|
file without rebinding the already-imported engine.
|
||||||
|
"""
|
||||||
|
db_file = tmp_path / "app.db"
|
||||||
|
env = {**os.environ, "DATABASE_URL": f"sqlite:///{db_file}"}
|
||||||
|
repo_root = Path(__file__).resolve().parents[1]
|
||||||
|
# Importing core.database runs init_db() against the temp file-backed DB.
|
||||||
|
# cwd=repo_root so `import core` resolves (the `-c` sys.path[0] is the CWD).
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-c", "import core.database"],
|
||||||
|
env=env,
|
||||||
|
cwd=repo_root,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
assert db_file.exists()
|
||||||
|
mode = db_file.stat().st_mode & 0o777
|
||||||
|
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
|
||||||
|
|
||||||
|
# Upgrade path: an already-deployed DB sitting at 0644 must be re-corrected
|
||||||
|
# on the next startup. The chmod is unconditional (not gated on create_all
|
||||||
|
# having created the file), so this is the common path for existing installs.
|
||||||
|
db_file.chmod(0o644)
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-c", "import core.database"],
|
||||||
|
env=env,
|
||||||
|
cwd=repo_root,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
assert db_file.stat().st_mode & 0o777 == 0o600, "existing 0644 DB not re-locked on startup"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_sqlite_url_preserves_sqlite_uri_filename():
|
||||||
|
"""URI filenames must reach SQLAlchemy unchanged for SQLite to parse."""
|
||||||
|
from core.database import _normalize_sqlite_url
|
||||||
|
|
||||||
|
url = "sqlite:///file:/tmp/app.db?mode=rwc&uri=true"
|
||||||
|
assert _normalize_sqlite_url(url) == url
|
||||||
|
|
||||||
|
|
||||||
|
def test_sqlite_db_path_handles_driver_and_query_forms():
|
||||||
|
"""The path fed to chmod must come from SQLAlchemy's parsed URL, not a naive
|
||||||
|
replace("sqlite:///"). A driver-qualified URL (sqlite+pysqlite://) or one
|
||||||
|
carrying query args (?cache=shared) would otherwise resolve to the wrong
|
||||||
|
path and leave the real file world-readable. Pure logic — runs everywhere.
|
||||||
|
"""
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
|
|
||||||
|
from core.database import _sqlite_db_path
|
||||||
|
|
||||||
|
# Plain forms (relative + absolute) resolve to the file path.
|
||||||
|
assert _sqlite_db_path(make_url("sqlite:///data/app.db")) == "data/app.db"
|
||||||
|
assert _sqlite_db_path(make_url("sqlite:////abs/app.db")) == "/abs/app.db"
|
||||||
|
# A driver qualifier must not defeat detection...
|
||||||
|
assert _sqlite_db_path(make_url("sqlite+pysqlite:///data/app.db")) == "data/app.db"
|
||||||
|
# ...and query args must be stripped from the path.
|
||||||
|
assert _sqlite_db_path(make_url("sqlite:///data/app.db?cache=shared")) == "data/app.db"
|
||||||
|
assert _sqlite_db_path(make_url("sqlite+pysqlite:////abs/app.db?mode=ro")) == "/abs/app.db"
|
||||||
|
# Nothing to lock for non-file-backed or non-sqlite databases.
|
||||||
|
assert _sqlite_db_path(make_url("sqlite:///:memory:")) is None
|
||||||
|
assert _sqlite_db_path(make_url("sqlite://")) is None
|
||||||
|
assert _sqlite_db_path(make_url("postgresql+psycopg2://u:p@h/db")) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform == "win32",
|
||||||
|
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
|
||||||
|
)
|
||||||
|
def test_app_db_sidecars_relocked(tmp_path):
|
||||||
|
"""Stale SQLite sidecars (-wal/-shm) left by an older 0o644 install hold
|
||||||
|
copies of DB pages, so startup must re-lock them too — not just app.db.
|
||||||
|
|
||||||
|
The default -journal is transient (SQLite deletes it after the create_all
|
||||||
|
commit), so it isn't asserted on here; -wal/-shm persist and are the real
|
||||||
|
exposure once WAL has ever been enabled.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
db_file = tmp_path / "app.db"
|
||||||
|
sqlite3.connect(db_file).close() # a real, pre-existing DB ...
|
||||||
|
db_file.chmod(0o644)
|
||||||
|
sidecars = [tmp_path / f"app.db{sfx}" for sfx in ("-wal", "-shm")]
|
||||||
|
for s in sidecars:
|
||||||
|
s.write_bytes(b"")
|
||||||
|
s.chmod(0o644)
|
||||||
|
|
||||||
|
env = {**os.environ, "DATABASE_URL": f"sqlite:///{db_file}"}
|
||||||
|
repo_root = Path(__file__).resolve().parents[1]
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-c", "import core.database"],
|
||||||
|
env=env,
|
||||||
|
cwd=repo_root,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert db_file.stat().st_mode & 0o777 == 0o600
|
||||||
|
for s in sidecars:
|
||||||
|
assert s.stat().st_mode & 0o777 == 0o600, f"{s.name} not re-locked on startup"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sqlite_db_path_handles_file_uri_forms(tmp_path):
|
||||||
|
"""SQLite URI filenames must chmod the real filesystem path, not the
|
||||||
|
literal file: URI string. Memory URI databases should still be skipped."""
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
|
|
||||||
|
from core.database import _sqlite_db_path
|
||||||
|
|
||||||
|
db_file = tmp_path / "uri-app.db"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
_sqlite_db_path(make_url(f"sqlite+pysqlite:///file:{db_file}?mode=rwc&uri=true"))
|
||||||
|
== str(db_file)
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
_sqlite_db_path(make_url(f"sqlite:///file:{db_file}?cache=shared&uri=true"))
|
||||||
|
== str(db_file)
|
||||||
|
)
|
||||||
|
|
||||||
|
localhost_db = tmp_path / "localhost-uri.db"
|
||||||
|
assert (
|
||||||
|
_sqlite_db_path(
|
||||||
|
make_url(
|
||||||
|
f"sqlite+pysqlite:///file://localhost{localhost_db}"
|
||||||
|
"?mode=rwc&uri=true"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
== str(localhost_db)
|
||||||
|
)
|
||||||
|
|
||||||
|
non_uri_mode_db = tmp_path / "mode-query-file.db"
|
||||||
|
assert (
|
||||||
|
_sqlite_db_path(
|
||||||
|
make_url(
|
||||||
|
f"sqlite+pysqlite:///{non_uri_mode_db}?mode=memory"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
== str(non_uri_mode_db)
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
_sqlite_db_path(make_url("sqlite+pysqlite:///file::memory:?cache=shared&uri=true"))
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
_sqlite_db_path(make_url("sqlite+pysqlite:///file:memdb1?mode=memory&cache=shared&uri=true"))
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform == "win32",
|
||||||
|
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
|
||||||
|
)
|
||||||
|
def test_app_db_file_uri_created_with_0600(tmp_path):
|
||||||
|
"""Import-time DB initialization must lock SQLite file: URI databases too."""
|
||||||
|
db_file = tmp_path / "uri-app.db"
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"DATABASE_URL": f"sqlite+pysqlite:///file:{db_file}?mode=rwc&uri=true",
|
||||||
|
}
|
||||||
|
repo_root = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-c", "import core.database"],
|
||||||
|
env=env,
|
||||||
|
cwd=repo_root,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert db_file.exists()
|
||||||
|
mode = db_file.stat().st_mode & 0o777
|
||||||
|
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform == "win32",
|
||||||
|
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
|
||||||
|
)
|
||||||
|
def test_app_db_localhost_file_uri_created_with_0600(tmp_path):
|
||||||
|
"""A file://localhost URI must chmod the local path SQLite opens."""
|
||||||
|
db_file = tmp_path / "localhost-uri.db"
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"DATABASE_URL": (
|
||||||
|
f"sqlite+pysqlite:///file://localhost{db_file}"
|
||||||
|
"?mode=rwc&uri=true"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
repo_root = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-c", "import core.database"],
|
||||||
|
env=env,
|
||||||
|
cwd=repo_root,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert db_file.exists()
|
||||||
|
mode = db_file.stat().st_mode & 0o777
|
||||||
|
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform == "win32",
|
||||||
|
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
|
||||||
|
)
|
||||||
|
def test_app_db_non_uri_mode_query_created_with_0600(tmp_path):
|
||||||
|
"""mode=memory without uri=true must not hide a real SQLite file."""
|
||||||
|
db_file = tmp_path / "mode-query-file.db"
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"DATABASE_URL": f"sqlite+pysqlite:///{db_file}?mode=memory",
|
||||||
|
}
|
||||||
|
repo_root = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-c", "import core.database"],
|
||||||
|
env=env,
|
||||||
|
cwd=repo_root,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert db_file.exists()
|
||||||
|
mode = db_file.stat().st_mode & 0o777
|
||||||
|
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform == "win32",
|
||||||
|
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
|
||||||
|
)
|
||||||
|
def test_app_db_plain_file_uri_created_with_0600(tmp_path):
|
||||||
|
"""The documented sqlite:///file: URI form must remain protected."""
|
||||||
|
db_file = tmp_path / "plain-uri-app.db"
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"DATABASE_URL": f"sqlite:///file:{db_file}?mode=rwc&uri=true",
|
||||||
|
}
|
||||||
|
repo_root = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-c", "import core.database"],
|
||||||
|
env=env,
|
||||||
|
cwd=repo_root,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert db_file.exists()
|
||||||
|
mode = db_file.stat().st_mode & 0o777
|
||||||
|
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
|
||||||
Reference in New Issue
Block a user