refactoring-backend #3
@@ -647,7 +647,7 @@ BanGUI maintains its **own SQLite database** (separate from the fail2ban databas
|
|||||||
| Table | Purpose |
|
| Table | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `settings` | Key-value store for application configuration (master password hash, fail2ban socket path, database path, timezone, session duration) |
|
| `settings` | Key-value store for application configuration (master password hash, fail2ban socket path, database path, timezone, session duration) |
|
||||||
| `sessions` | Active session tokens with expiry timestamps |
|
| `sessions` | Active session token hashes with expiry timestamps. Tokens are stored as one-way SHA256 hashes to prevent token hijacking if the database is exposed. |
|
||||||
| `geo_cache` | Resolved IP geolocation results (ip, country_code, country_name, asn, org, cached_at). Loaded into memory at startup via `load_cache_from_db()`; new entries are flushed back by the `geo_cache_flush` background task. |
|
| `geo_cache` | Resolved IP geolocation results (ip, country_code, country_name, asn, org, cached_at). Loaded into memory at startup via `load_cache_from_db()`; new entries are flushed back by the `geo_cache_flush` background task. |
|
||||||
| `blocklist_sources` | Registered blocklist URLs (id, name, url, enabled, created_at, updated_at) |
|
| `blocklist_sources` | Registered blocklist URLs (id, name, url, enabled, created_at, updated_at) |
|
||||||
| `import_logs` | Record of every blocklist import run (id, source_id, timestamp, ips_imported, ips_skipped, errors, status) |
|
| `import_logs` | Record of every blocklist import run (id, source_id, timestamp, ips_imported, ips_skipped, errors, status) |
|
||||||
@@ -665,7 +665,8 @@ BanGUI maintains its **own SQLite database** (separate from the fail2ban databas
|
|||||||
|
|
||||||
- **Single-user model** — one master password, no usernames.
|
- **Single-user model** — one master password, no usernames.
|
||||||
- Password is hashed with a strong algorithm (e.g., bcrypt or argon2) and stored in the application database during setup.
|
- Password is hashed with a strong algorithm (e.g., bcrypt or argon2) and stored in the application database during setup.
|
||||||
- Sessions are token-based, stored server-side in the `sessions` table, and delivered to the browser as HTTP-only secure cookies.
|
- Sessions are token-based, stored server-side in the `sessions` table as one-way SHA256 hashes, and delivered to the browser as HTTP-only secure cookies.
|
||||||
|
- **Session token hashing** — Session tokens are hashed before storage to prevent token hijacking if the database file is exposed. Only the hash (`token_hash`) is stored in the database; the raw token is never persisted. When validating a session, the incoming token is hashed before the database lookup. This ensures the database alone is not sufficient to usurp a session — an attacker would also need knowledge of the original token value.
|
||||||
- Session expiry is configurable (set during setup, stored in `settings`).
|
- Session expiry is configurable (set during setup, stored in `settings`).
|
||||||
- The frontend `AuthProvider` checks session validity on mount and redirects to `/login` if invalid.
|
- The frontend `AuthProvider` checks session validity on mount and redirects to `/login` if invalid.
|
||||||
- The backend `dependencies.py` provides an `authenticated` dependency that validates the session cookie on every protected endpoint.
|
- The backend `dependencies.py` provides an `authenticated` dependency that validates the session cookie on every protected endpoint.
|
||||||
|
|||||||
@@ -1001,7 +1001,88 @@ async def get_session_repo() -> SessionRepository:
|
|||||||
- Before each deployment, run `mypy --strict` to ensure all dependency providers return values compatible with their Protocol types.
|
- Before each deployment, run `mypy --strict` to ensure all dependency providers return values compatible with their Protocol types.
|
||||||
- The `cast()` calls in `dependencies.py` are a documented signal that structural compatibility is being verified externally, not via explicit class inheritance.
|
- The `cast()` calls in `dependencies.py` are a documented signal that structural compatibility is being verified externally, not via explicit class inheritance.
|
||||||
|
|
||||||
#### 13.7.2 Session Cache Pluggability — Process-Local vs. Shared Backends
|
#### 13.7.2 Session Token Hashing — One-Way Protection Against Database Exposure
|
||||||
|
|
||||||
|
Session tokens must be protected against database exposure. **Session tokens are stored as one-way SHA256 hashes in the database** to ensure that if the database file is compromised (volume mount misconfiguration, backup leak, etc.), the session tokens themselves cannot be directly used to hijack sessions.
|
||||||
|
|
||||||
|
**Implementation pattern:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import hashlib
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from app.models.auth import Session
|
||||||
|
|
||||||
|
def _hash_token(token: str) -> str:
|
||||||
|
"""Return the SHA256 hash of a session token."""
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
async def create_session(
|
||||||
|
db: "aiosqlite.Connection",
|
||||||
|
token: str,
|
||||||
|
created_at: str,
|
||||||
|
expires_at: str,
|
||||||
|
) -> Session:
|
||||||
|
"""Insert a new session row with the token hash."""
|
||||||
|
token_hash = _hash_token(token)
|
||||||
|
cursor = await db.execute(
|
||||||
|
"INSERT INTO sessions (token_hash, created_at, expires_at) VALUES (?, ?, ?)",
|
||||||
|
(token_hash, created_at, expires_at),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
# Return the Session with the ORIGINAL token (not the hash)
|
||||||
|
# so the service layer can sign and return it to the client.
|
||||||
|
return Session(
|
||||||
|
id=int(cursor.lastrowid) if cursor.lastrowid else 0,
|
||||||
|
token=token, # ← raw token, not the hash
|
||||||
|
created_at=created_at,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_session(
|
||||||
|
db: "aiosqlite.Connection",
|
||||||
|
token: str
|
||||||
|
) -> Session | None:
|
||||||
|
"""Look up a session by token hash."""
|
||||||
|
token_hash = _hash_token(token)
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT id, token_hash, created_at, expires_at FROM sessions WHERE token_hash = ?",
|
||||||
|
(token_hash,),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Return the Session with the INCOMING token (the one the client sent).
|
||||||
|
return Session(
|
||||||
|
id=int(row[0]),
|
||||||
|
token=token, # ← the raw token passed in
|
||||||
|
created_at=str(row[2]),
|
||||||
|
expires_at=str(row[3]),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
|
||||||
|
1. **Hash on write** — When inserting a session, hash the token before storage.
|
||||||
|
2. **Hash on read** — When validating a session, hash the incoming token before the database lookup.
|
||||||
|
3. **Never store raw tokens** — The `token_hash` column contains only hashes; raw tokens are never persisted.
|
||||||
|
4. **Return raw tokens to the service layer** — The `Session` model's `token` field contains the raw token (for signing and response), not the hash.
|
||||||
|
5. **Database schema** — Use `token_hash TEXT NOT NULL UNIQUE` instead of `token TEXT NOT NULL UNIQUE`, and create an index on `token_hash`.
|
||||||
|
6. **Migration strategy** — When upgrading from plaintext to hashed tokens, drop the old table and recreate it. This invalidates all existing sessions, which is acceptable because the database was exposed in plaintext.
|
||||||
|
|
||||||
|
**Why one-way hashing is safe:**
|
||||||
|
- If an attacker obtains a token hash from the database, they cannot reverse the SHA256 hash to recover the original token.
|
||||||
|
- The attacker cannot use the hash directly in a client request — they would need the original token to pass the hash check.
|
||||||
|
- This forces the attacker to either compromise the client (where they'd also get the raw token) or perform a brute-force attack against the hash space (infeasible for random 128-bit tokens).
|
||||||
|
|
||||||
|
**Never use symmetric encryption** — symmetric encryption stores a key in the database or environment, which merely shifts the exposure risk. A one-way hash is the correct choice for protecting tokens.
|
||||||
|
|
||||||
|
#### 13.7.3 Session Cache Pluggability — Process-Local vs. Shared Backends
|
||||||
|
|
||||||
Session validation is expensive (SQLite lookup + password verification). To improve performance, **validated session tokens are cached** using the `SessionCache` interface (`app.utils.session_cache`). The default implementation, `InMemorySessionCache`, stores cached sessions in process-local memory.
|
Session validation is expensive (SQLite lookup + password verification). To improve performance, **validated session tokens are cached** using the `SessionCache` interface (`app.utils.session_cache`). The default implementation, `InMemorySessionCache`, stores cached sessions in process-local memory.
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,3 @@
|
|||||||
## TASK-021 — `set_jail_config_enabled` and `write_jail_config_file` not atomic
|
|
||||||
|
|
||||||
**Severity:** Medium
|
|
||||||
|
|
||||||
### Where found
|
|
||||||
`backend/app/services/raw_config_io_service.py` lines ~268 (`set_jail_config_enabled`) and ~344 (`write_jail_config_file`) — both use `path.write_text(updated)` directly.
|
|
||||||
|
|
||||||
### Why this is needed
|
|
||||||
Same root cause as TASK-018. A process kill mid-write leaves the jail config file corrupted, disabling that jail on next fail2ban reload.
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
Atomic writes for `set_jail_config_enabled` and `write_jail_config_file`.
|
|
||||||
|
|
||||||
### What to do
|
|
||||||
Same as TASK-018: replace `path.write_text(content)` with the `NamedTemporaryFile` + `os.replace()` pattern in both functions. This is most efficiently done as part of TASK-018 by extracting a shared `atomic_write(path, content)` helper in `config_file_helpers.py`.
|
|
||||||
|
|
||||||
### Possible traps and issues
|
|
||||||
- Same as TASK-018.
|
|
||||||
- Extracting the helper makes TASK-018 and TASK-021 a single coordinated change.
|
|
||||||
|
|
||||||
### Docs changes needed
|
|
||||||
- `Backend-Development.md` — atomic write helper documentation.
|
|
||||||
|
|
||||||
### Doc references
|
|
||||||
- [Backend-Development.md](Backend-Development.md) — file I/O conventions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TASK-022 — Session tokens stored in plaintext in SQLite
|
## TASK-022 — Session tokens stored in plaintext in SQLite
|
||||||
|
|
||||||
**Severity:** High
|
**Severity:** High
|
||||||
|
|||||||
@@ -30,15 +30,15 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
|
|
||||||
_CREATE_SESSIONS: str = """
|
_CREATE_SESSIONS: str = """
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
token TEXT NOT NULL UNIQUE,
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
expires_at TEXT NOT NULL
|
expires_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_CREATE_SESSIONS_TOKEN_INDEX: str = """
|
_CREATE_SESSIONS_TOKEN_INDEX: str = """
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token ON sessions (token);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions (token_hash);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_CREATE_BLOCKLIST_SOURCES: str = """
|
_CREATE_BLOCKLIST_SOURCES: str = """
|
||||||
@@ -107,10 +107,24 @@ _SCHEMA_STATEMENTS: list[str] = [
|
|||||||
_CREATE_HISTORY_ARCHIVE,
|
_CREATE_HISTORY_ARCHIVE,
|
||||||
]
|
]
|
||||||
|
|
||||||
_CURRENT_SCHEMA_VERSION: int = 1
|
_CURRENT_SCHEMA_VERSION: int = 2
|
||||||
|
|
||||||
_MIGRATIONS: dict[int, str] = {
|
_MIGRATIONS: dict[int, str] = {
|
||||||
1: "\n".join(_SCHEMA_STATEMENTS),
|
1: "\n".join(_SCHEMA_STATEMENTS),
|
||||||
|
2: """
|
||||||
|
-- Migration 2: Hash session tokens for security.
|
||||||
|
-- Drop the old sessions table and recreate with token_hash column.
|
||||||
|
-- This invalidates all existing sessions, which is acceptable as the DB
|
||||||
|
-- contents were exposed in plaintext.
|
||||||
|
DROP TABLE IF EXISTS sessions;
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX idx_sessions_token_hash ON sessions (token_hash);
|
||||||
|
""",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,15 @@
|
|||||||
|
|
||||||
Provides storage, retrieval, and deletion of session records in the
|
Provides storage, retrieval, and deletion of session records in the
|
||||||
``sessions`` table of the application SQLite database.
|
``sessions`` table of the application SQLite database.
|
||||||
|
|
||||||
|
Session tokens are stored as one-way SHA256 hashes to ensure that if the
|
||||||
|
database is exposed, the session tokens themselves cannot be directly used.
|
||||||
|
The hash is computed from the raw token before all database operations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -14,6 +19,18 @@ if TYPE_CHECKING:
|
|||||||
from app.models.auth import Session
|
from app.models.auth import Session
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_token(token: str) -> str:
|
||||||
|
"""Return the SHA256 hash of a session token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The raw session token to hash.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The hexadecimal SHA256 digest of the token.
|
||||||
|
"""
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
async def create_session(
|
async def create_session(
|
||||||
db: aiosqlite.Connection,
|
db: aiosqlite.Connection,
|
||||||
token: str,
|
token: str,
|
||||||
@@ -30,10 +47,15 @@ async def create_session(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The newly created :class:`~app.models.auth.Session`.
|
The newly created :class:`~app.models.auth.Session`.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
The token is hashed before storage. The returned Session object
|
||||||
|
contains the original raw token for use in signing and response.
|
||||||
"""
|
"""
|
||||||
|
token_hash = _hash_token(token)
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)",
|
"INSERT INTO sessions (token_hash, created_at, expires_at) VALUES (?, ?, ?)",
|
||||||
(token, created_at, expires_at),
|
(token_hash, created_at, expires_at),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return Session(
|
return Session(
|
||||||
@@ -53,10 +75,14 @@ async def get_session(db: aiosqlite.Connection, token: str) -> Session | None:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The :class:`~app.models.auth.Session` if found, else ``None``.
|
The :class:`~app.models.auth.Session` if found, else ``None``.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
The token is hashed before the database lookup.
|
||||||
"""
|
"""
|
||||||
|
token_hash = _hash_token(token)
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"SELECT id, token, created_at, expires_at FROM sessions WHERE token = ?",
|
"SELECT id, token_hash, created_at, expires_at FROM sessions WHERE token_hash = ?",
|
||||||
(token,),
|
(token_hash,),
|
||||||
) as cursor:
|
) as cursor:
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
@@ -65,7 +91,7 @@ async def get_session(db: aiosqlite.Connection, token: str) -> Session | None:
|
|||||||
|
|
||||||
return Session(
|
return Session(
|
||||||
id=int(row[0]),
|
id=int(row[0]),
|
||||||
token=str(row[1]),
|
token=token,
|
||||||
created_at=str(row[2]),
|
created_at=str(row[2]),
|
||||||
expires_at=str(row[3]),
|
expires_at=str(row[3]),
|
||||||
)
|
)
|
||||||
@@ -77,8 +103,12 @@ async def delete_session(db: aiosqlite.Connection, token: str) -> None:
|
|||||||
Args:
|
Args:
|
||||||
db: Active aiosqlite connection.
|
db: Active aiosqlite connection.
|
||||||
token: The session token to remove.
|
token: The session token to remove.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
The token is hashed before the database lookup.
|
||||||
"""
|
"""
|
||||||
await db.execute("DELETE FROM sessions WHERE token = ?", (token,))
|
token_hash = _hash_token(token)
|
||||||
|
await db.execute("DELETE FROM sessions WHERE token_hash = ?", (token_hash,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ async def test_init_db_records_schema_version(tmp_path: Path) -> None:
|
|||||||
) as cursor:
|
) as cursor:
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
assert row is not None
|
assert row is not None
|
||||||
assert row[0] == 1
|
assert row[0] == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -97,4 +97,4 @@ async def test_init_db_migrates_legacy_database_without_schema_version(tmp_path:
|
|||||||
) as cursor:
|
) as cursor:
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
assert row is not None
|
assert row is not None
|
||||||
assert row[0] == 1
|
assert row[0] == 2
|
||||||
|
|||||||
@@ -116,3 +116,28 @@ class TestSessionRepo:
|
|||||||
assert deleted == 1
|
assert deleted == 1
|
||||||
assert await session_repo.get_session(db, "expired") is None
|
assert await session_repo.get_session(db, "expired") is None
|
||||||
assert await session_repo.get_session(db, "valid") is not None
|
assert await session_repo.get_session(db, "valid") is not None
|
||||||
|
|
||||||
|
async def test_tokens_are_hashed_in_database(
|
||||||
|
self, db: aiosqlite.Connection
|
||||||
|
) -> None:
|
||||||
|
"""Verify that tokens are stored as hashes, not plaintext."""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
token = "plaintext_token_12345"
|
||||||
|
await session_repo.create_session(
|
||||||
|
db,
|
||||||
|
token=token,
|
||||||
|
created_at="2025-01-01T00:00:00+00:00",
|
||||||
|
expires_at="2025-01-01T01:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Query the database directly to verify the token is hashed.
|
||||||
|
async with db.execute("SELECT token_hash FROM sessions WHERE token_hash = ?", (hashlib.sha256(token.encode()).hexdigest(),)) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] == hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
# The plaintext token should not be in the database.
|
||||||
|
async with db.execute("SELECT * FROM sessions WHERE token_hash = ?", (token,)) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
assert row is None
|
||||||
|
|||||||
Reference in New Issue
Block a user