diff --git a/README.md b/README.md index a0541e6..67fd003 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ RouteForge Logo

- Version + Version License Status Selfhosted @@ -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 ` only to baseline pre-existing schema that already matches the target revision. diff --git a/frontend/package.json b/frontend/package.json index 020693a..2382f94 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "routeforge-frontend", - "version": "0.8.1", + "version": "0.9.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/frontend/src/components/UsersView.tsx b/frontend/src/components/UsersView.tsx index 85deef2..e9dfaa3 100644 --- a/frontend/src/components/UsersView.tsx +++ b/frontend/src/components/UsersView.tsx @@ -52,6 +52,6 @@ export function UsersView() { - {loading ?

Loading users…
:
{users.map(u => )}
UserRoleStatusActions
{u.username}
{u.email || '—'}
{u.role}{u.is_active ? 'active' : 'inactive'}
} + {loading ?
Loading users…
:
{users.map(u => )}
UserRoleStatusActions
{u.username}
{u.email || '—'}
{u.role}{u.is_active ? 'active' : 'inactive'}
Password resets via API/admin procedure only.
} } diff --git a/frontend/src/components/WatchModeView.tsx b/frontend/src/components/WatchModeView.tsx index 8bda428..c1f7e99 100644 --- a/frontend/src/components/WatchModeView.tsx +++ b/frontend/src/components/WatchModeView.tsx @@ -141,7 +141,7 @@ export function WatchModeView({ role }: { role: UserRole }) { {canEdit &&
- +
} {editForm && canEdit &&