From 0db4fe056583c654642abcb2d32c0a3f6b03cfb5 Mon Sep 17 00:00:00 2001
From: DeepZone
-
+
@@ -214,7 +214,7 @@ In the standard setup, RouteForge does **not** require a hardcoded host IP in th
### Database
- Recommended production path: PostgreSQL via `docker-compose.prod.yml`.
- Production/PostgreSQL lifecycle is managed with Alembic migrations.
-- SQLite/dev mode keeps lightweight startup initialization (`create_all`) for local/demo compatibility.
+- SQLite/dev mode can keep lightweight startup initialization (`create_all`) only when `ALLOW_SQLITE_CREATE_ALL=true` (default for local/demo). Disable it in production-like environments.
- Run migrations manually before production upgrades (`alembic upgrade head`).
### SQLite permission note
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 26f9351..8fdbfbe 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,3 +1,12 @@
+
+## v0.9.0-rc (2026-05-20)
+
+- Security review hardening for session cookies (`COOKIE_SAMESITE` support), CORS warnings, and stronger PBKDF2 password hashing with backward-compatible legacy hash verification.
+- Added system-status security warnings for invalid SameSite configurations and permissive CORS settings.
+- Guarded SQLite `create_all` startup path behind `ALLOW_SQLITE_CREATE_ALL` to avoid replacing migration discipline in production-like setups.
+- Version bump across backend/frontend/system status to `0.9.0` / `v0.9.0-rc`.
+- Added upgrade validation script for Alembic empty-db/head/current checks and migration-behind detection.
+- UX cleanup: removed browser prompt/confirm usage in Users and Watch Mode flows.
## v0.8.1-beta hotfix: Watch Mode UX
### Motivation
diff --git a/ROADMAP.md b/ROADMAP.md
index 64f32b7..ada4a67 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,19 +1,19 @@
# RouteForge Roadmap
## Current Status
-v0.8.1-beta, BGP Visibility Details completed, read-only
+v0.9.0-rc, BGP Visibility Details completed, read-only
-## v0.8.1-beta
+## v0.9.0-rc
- projects/change cases
- grouped preflight reports
-## v0.8.1-beta
+## v0.9.0-rc
- bgp visibility details
-## v0.8.1-beta
+## v0.9.0-rc
- roa planner / roa preflight
-## v0.8.1-beta
+## v0.9.0-rc
- watch mode / scheduled rechecks
## v0.9.0-rc
diff --git a/backend/app/api/routes_auth.py b/backend/app/api/routes_auth.py
index 1e4876f..ac1742d 100644
--- a/backend/app/api/routes_auth.py
+++ b/backend/app/api/routes_auth.py
@@ -38,7 +38,7 @@ def setup(payload: SetupRequest, request: Request, response: Response, db: Sessi
user = User(username=payload.username.strip(), email=payload.email, password_hash=hash_password(payload.password), role='admin', is_active=True)
db.add(user); db.commit(); db.refresh(user)
token = create_session_token(user)
- response.set_cookie(config.settings.session_cookie_name, token, httponly=True, samesite='lax', secure=config.settings.cookie_secure)
+ response.set_cookie(config.settings.session_cookie_name, token, httponly=True, samesite=config.settings.cookie_samesite, secure=config.settings.cookie_secure)
write_audit_log_for_request(db, request, action='initial_admin_setup', actor=user, target_type='user', target_id=str(user.id), details_json={'username': user.username, 'role': user.role})
return {"user": {"id": user.id, "username": user.username, "email": user.email, "role": user.role}}
@@ -50,14 +50,14 @@ def login(payload: LoginRequest, request: Request, response: Response, db: Sessi
raise HTTPException(status_code=401, detail='Invalid credentials')
user.last_login_at = datetime.utcnow(); db.commit()
token = create_session_token(user)
- response.set_cookie(config.settings.session_cookie_name, token, httponly=True, samesite='lax', secure=config.settings.cookie_secure)
+ response.set_cookie(config.settings.session_cookie_name, token, httponly=True, samesite=config.settings.cookie_samesite, secure=config.settings.cookie_secure)
write_audit_log_for_request(db, request, action='login_success', actor=user, target_type='user', target_id=str(user.id), details_json={'username': user.username})
return {"user": {"id": user.id, "username": user.username, "email": user.email, "role": user.role}}
@router.post('/logout')
def logout(request: Request, response: Response, user: User = Depends(require_authenticated_user), db: Session = Depends(get_db)):
write_audit_log_for_request(db, request, action='logout', actor=user, target_type='user', target_id=str(user.id), details_json={'username': user.username})
- response.delete_cookie(config.settings.session_cookie_name, httponly=True, samesite='lax', secure=config.settings.cookie_secure)
+ response.delete_cookie(config.settings.session_cookie_name, httponly=True, samesite=config.settings.cookie_samesite, secure=config.settings.cookie_secure)
return {"ok": True}
@router.get('/me')
diff --git a/backend/app/api/routes_health.py b/backend/app/api/routes_health.py
index 5467e56..a6ce757 100644
--- a/backend/app/api/routes_health.py
+++ b/backend/app/api/routes_health.py
@@ -8,4 +8,4 @@
@router.get('/health')
def health() -> dict:
- return {"status": "ok", "version": "v0.6.2-beta", "database": get_database_status(engine).get("status", "unknown")}
+ return {"status": "ok", "version": "v0.9.0-rc", "database": get_database_status(engine).get("status", "unknown")}
diff --git a/backend/app/api/routes_system.py b/backend/app/api/routes_system.py
index 591c688..c169116 100644
--- a/backend/app/api/routes_system.py
+++ b/backend/app/api/routes_system.py
@@ -12,7 +12,7 @@
def system_info():
return {
'name': 'RouteForge',
- 'version': 'v0.8.1-beta',
+ 'version': 'v0.9.0-rc',
'demo_mode': settings.demo_mode,
'read_only': True,
'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'],
diff --git a/backend/app/config.py b/backend/app/config.py
index c02635a..9b890e8 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -21,6 +21,8 @@ class Settings(BaseSettings):
session_cookie_name: str = Field(default="routeforge_session", validation_alias="SESSION_COOKIE_NAME")
session_expire_hours: int = Field(default=12, validation_alias="SESSION_EXPIRE_HOURS")
cookie_secure: bool = Field(default=False, validation_alias="COOKIE_SECURE")
+ cookie_samesite: str = Field(default="lax", validation_alias="COOKIE_SAMESITE")
+ allow_sqlite_create_all: bool = Field(default=True, validation_alias="ALLOW_SQLITE_CREATE_ALL")
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
index 5a520d5..a8ede89 100644
--- a/backend/app/core/security.py
+++ b/backend/app/core/security.py
@@ -5,20 +5,37 @@
import os
import re
+PBKDF2_ITERATIONS = 210_000
+
def hash_password(password: str) -> str:
- salt = os.urandom(16).hex()
- digest = hashlib.sha256((salt + password).encode()).hexdigest()
- return f"sha256${salt}${digest}"
+ salt = os.urandom(16)
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS).hex()
+ return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest}"
def verify_password(password: str, password_hash: str) -> bool:
try:
- _, salt, digest = password_hash.split('$', 2)
+ scheme, *parts = password_hash.split('$')
except ValueError:
return False
- check = hashlib.sha256((salt + password).encode()).hexdigest()
- return hmac.compare_digest(check, digest)
+
+ if scheme == "pbkdf2_sha256" and len(parts) == 3:
+ iterations_s, salt_hex, digest = parts
+ try:
+ iterations = int(iterations_s)
+ salt = bytes.fromhex(salt_hex)
+ except ValueError:
+ return False
+ check = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations).hex()
+ return hmac.compare_digest(check, digest)
+
+ if scheme == "sha256" and len(parts) == 2:
+ salt, digest = parts
+ check = hashlib.sha256((salt + password).encode()).hexdigest()
+ return hmac.compare_digest(check, digest)
+
+ return False
def validate_password_strength(password: str) -> list[str]:
diff --git a/backend/app/core/system_status.py b/backend/app/core/system_status.py
index c71168e..37e1b39 100644
--- a/backend/app/core/system_status.py
+++ b/backend/app/core/system_status.py
@@ -125,6 +125,12 @@ def _security_warnings() -> list[str]:
warnings.append("POSTGRES_PASSWORD uses the default example value. Change it before production use.")
if not settings.cookie_secure:
warnings.append("COOKIE_SECURE is false. Use true behind HTTPS in production.")
+ if settings.cookie_samesite.lower() not in {"lax", "strict", "none"}:
+ warnings.append("COOKIE_SAMESITE should be one of: lax, strict, none.")
+ if settings.cookie_samesite.lower() == "none" and not settings.cookie_secure:
+ warnings.append("COOKIE_SAMESITE=none requires COOKIE_SECURE=true in modern browsers.")
+ if settings.cors_origins.strip() in {"*", ""}:
+ warnings.append("CORS_ORIGINS is too permissive. Set explicit frontend origins.")
return warnings
@@ -147,7 +153,7 @@ def build_system_status(engine: Engine | None) -> dict:
return {
"status": "ok",
"name": settings.app_name,
- "version": "v0.8.1-beta",
+ "version": "v0.9.0-rc",
"read_only": True,
"mode": "demo" if settings.demo_mode else "live",
"demo_mode": settings.demo_mode,
diff --git a/backend/app/main.py b/backend/app/main.py
index 0191601..fa77fc9 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -20,7 +20,7 @@
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
logger = logging.getLogger("routeforge")
-app = FastAPI(title="RouteForge", version="0.8.1")
+app = FastAPI(title="RouteForge", version="0.9.0")
app.add_middleware(
CORSMiddleware,
@@ -33,7 +33,7 @@
@app.on_event("startup")
def startup() -> None:
- if settings.database_url.startswith("sqlite"):
+ if settings.database_url.startswith("sqlite") and settings.allow_sqlite_create_all:
Base.metadata.create_all(bind=engine)
logger.info(
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index cacd797..6e6da93 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "routeforge-backend"
-version = "0.8.1"
+version = "0.9.0"
description = "RouteForge backend"
license = "AGPL-3.0-or-later"
requires-python = ">=3.12"
diff --git a/backend/scripts/check_upgrade_path.py b/backend/scripts/check_upgrade_path.py
new file mode 100644
index 0000000..5afa356
--- /dev/null
+++ b/backend/scripts/check_upgrade_path.py
@@ -0,0 +1,38 @@
+from pathlib import Path
+import os
+import sqlite3
+import subprocess
+import tempfile
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def run(cmd, env):
+ return subprocess.run(cmd, cwd=ROOT, env=env, capture_output=True, text=True, check=True)
+
+
+def main():
+ with tempfile.TemporaryDirectory() as td:
+ db_path = Path(td) / "upgrade-check.db"
+ env = os.environ.copy()
+ env["DATABASE_URL"] = f"sqlite:///{db_path}"
+
+ run(["alembic", "upgrade", "head"], env)
+ current = run(["alembic", "current"], env).stdout
+ heads = run(["alembic", "heads"], env).stdout
+ assert "000" in current and "000" in heads
+
+ conn = sqlite3.connect(db_path)
+ cur = conn.cursor()
+ cur.execute("UPDATE alembic_version SET version_num = ?", ("0001_initial_schema",))
+ conn.commit(); conn.close()
+
+ current2 = run(["alembic", "current"], env).stdout
+ if "0001_initial_schema" not in current2:
+ raise SystemExit("Failed to simulate behind revision")
+
+ print("Upgrade checks passed")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py
index 5819815..a1a4b6e 100644
--- a/backend/tests/test_api_smoke.py
+++ b/backend/tests/test_api_smoke.py
@@ -177,7 +177,7 @@ def test_system_status_endpoint() -> None:
response = client.get('/api/system/status')
assert response.status_code == 200
payload = response.json()
- assert payload.get('version') == 'v0.8.1-beta'
+ assert payload.get('version') == 'v0.9.0-rc'
assert payload.get('read_only') is True
assert payload.get('database', {}).get('status')
assert payload.get('ripestat', {}).get('cache_ttl_seconds') is not None
diff --git a/docs/operations/security.md b/docs/operations/security.md
index ad25db4..5a454ca 100644
--- a/docs/operations/security.md
+++ b/docs/operations/security.md
@@ -60,3 +60,11 @@ The backend entrypoint ensures `/app/data` is writable for the non-root runtime
- Inactive users cannot log in
- Password reset is admin-driven (set new password in user management)
- No external auth/SSO in this version
+
+
+## v0.9.0-rc baseline
+
+- Session cookies use `HttpOnly`, configurable `COOKIE_SAMESITE` (default `lax`) and `COOKIE_SECURE` policy.
+- Password hashing uses `pbkdf2_sha256` with legacy `sha256` verify compatibility for existing users.
+- Viewer role remains strictly read-only for checks and watch execution/mutation endpoints.
+- RouteForge remains read-only to external systems (no write operations to RIPE DB/RPKI/router APIs).
diff --git a/docs/operations/troubleshooting.md b/docs/operations/troubleshooting.md
index b993ad3..47370e6 100644
--- a/docs/operations/troubleshooting.md
+++ b/docs/operations/troubleshooting.md
@@ -50,3 +50,16 @@ Prüfen: User aktiv? Passwort korrekt? Wurde `SECRET_KEY` geändert?
## Nach SECRET_KEY Änderung
Alle Sessions sind ungültig. Bitte neu einloggen.
+
+
+## Migration required in UI/System Status
+
+Wenn `migration_status=behind` gemeldet wird, führe nacheinander aus:
+
+```bash
+alembic current
+alembic heads
+alembic upgrade head
+```
+
+Nur wenn Schema bereits existiert und exakt zum Baseline-Stand passt: `alembic stamp 0001_initial_schema`.
diff --git a/docs/operations/upgrades.md b/docs/operations/upgrades.md
index f0db106..a01d48d 100644
--- a/docs/operations/upgrades.md
+++ b/docs/operations/upgrades.md
@@ -44,3 +44,10 @@ docker compose -f docker-compose.prod.yml run --rm backend alembic stamp 0001_in
- `alembic current`
- `alembic heads`
- `alembic upgrade head`
+
+
+## v0.9.0-rc upgrade validation
+
+- Run `python backend/scripts/check_upgrade_path.py` for a CI-friendly SQLite upgrade sanity check (empty DB -> head, current/head checks, simulated behind revision).
+- Use `alembic upgrade head` for real schema upgrades.
+- Use `alembic stamp
{users.map(u => User Role Status Actions )}{u.username} {u.role} {u.is_active ? 'active' : 'inactive'} {users.map(u => User Role Status Actions )}{u.username} {u.role} {u.is_active ? 'active' : 'inactive'}