Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<img src="frontend/public/routeforge.png" alt="RouteForge Logo" width="420">
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-v0.8.1--beta-blue" alt="Version">
<img src="https://img.shields.io/badge/version-v0.9.0--rc-blue" alt="Version">
<img src="https://img.shields.io/badge/license-AGPL--3.0--or--later-orange" alt="License">
<img src="https://img.shields.io/badge/status-beta-yellow" alt="Status">
<img src="https://img.shields.io/badge/selfhosted-ready-success" alt="Selfhosted">
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 5 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions backend/app/api/routes_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}}

Expand All @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/routes_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
2 changes: 1 addition & 1 deletion backend/app/api/routes_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
2 changes: 2 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
29 changes: 23 additions & 6 deletions backend/app/core/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
8 changes: 7 additions & 1 deletion backend/app/core/system_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
38 changes: 38 additions & 0 deletions backend/scripts/check_upgrade_path.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion backend/tests/test_api_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/operations/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
13 changes: 13 additions & 0 deletions docs/operations/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
7 changes: 7 additions & 0 deletions docs/operations/upgrades.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <revision>` only to baseline pre-existing schema that already matches the target revision.
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/UsersView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,6 @@ export function UsersView() {
<button className='rf-btn-primary' onClick={onCreate}>Create</button>
</div>
</div>
{loading ? <div className='text-sm text-slate-500'>Loading users…</div> : <div className='overflow-x-auto'><table className='w-full text-sm'><thead><tr className='text-left'><th>User</th><th>Role</th><th>Status</th><th>Actions</th></tr></thead><tbody>{users.map(u => <tr key={u.id} className='border-t'><td>{u.username}<div className='text-xs text-slate-500'>{u.email || '—'}</div></td><td><span className='rounded-full bg-violet-50 px-2 py-1 text-xs text-violet-700'>{u.role}</span></td><td>{u.is_active ? 'active' : 'inactive'}</td><td><div className='flex flex-wrap gap-2'><select className='rf-input' value={u.role} onChange={e => onPatch(u, { role: e.target.value as UserRole })}>{ROLES.map(r => <option key={r}>{r}</option>)}</select><button className='rf-btn-secondary' onClick={() => onPatch(u, { is_active: !u.is_active })}>{u.is_active ? 'Deactivate' : 'Activate'}</button><button className='rf-btn-secondary' onClick={() => { const p = prompt(`Set new password for ${u.username}`); if (p) onPatch(u, { password: p }) }}>Reset password</button></div></td></tr>)}</tbody></table></div>}
{loading ? <div className='text-sm text-slate-500'>Loading users…</div> : <div className='overflow-x-auto'><table className='w-full text-sm'><thead><tr className='text-left'><th>User</th><th>Role</th><th>Status</th><th>Actions</th></tr></thead><tbody>{users.map(u => <tr key={u.id} className='border-t'><td>{u.username}<div className='text-xs text-slate-500'>{u.email || '—'}</div></td><td><span className='rounded-full bg-violet-50 px-2 py-1 text-xs text-violet-700'>{u.role}</span></td><td>{u.is_active ? 'active' : 'inactive'}</td><td><div className='flex flex-wrap gap-2'><select className='rf-input' value={u.role} onChange={e => onPatch(u, { role: e.target.value as UserRole })}>{ROLES.map(r => <option key={r}>{r}</option>)}</select><button className='rf-btn-secondary' onClick={() => onPatch(u, { is_active: !u.is_active })}>{u.is_active ? 'Deactivate' : 'Activate'}</button><span className='text-xs text-slate-500'>Password resets via API/admin procedure only.</span></div></td></tr>)}</tbody></table></div>}
</section>
}
2 changes: 1 addition & 1 deletion frontend/src/components/WatchModeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export function WatchModeView({ role }: { role: UserRole }) {
{canEdit && <div className='flex gap-2 flex-wrap'>
<button className='rf-btn-secondary' disabled={submitting} onClick={async()=>{ setSubmitting(true); setError(null); await runWatchTarget(selected.id); await loadTargets(selected.id); const nt = await listWatchTargets(); setSelected(nt.find(x=>x.id===selected.id)||null); setSuccess('Run started successfully.'); setSubmitting(false) }}>Run Now</button>
<button className='rf-btn-secondary' onClick={startEdit}>Edit</button>
<button className='rf-btn-secondary' disabled={submitting} onClick={async()=>{ if(!confirm(`Delete watch target "${selected.name}"?`)) return; setSubmitting(true); await deleteWatchTarget(selected.id); setSelected(null); setRuns([]); await loadTargets(); setSuccess('Watch target deleted.'); setSubmitting(false) }}>Delete</button>
<button className='rf-btn-secondary' disabled={submitting} onClick={async()=>{ setSubmitting(true); await deleteWatchTarget(selected.id); setSelected(null); setRuns([]); await loadTargets(); setSuccess('Watch target deleted.'); setSubmitting(false) }}>Delete</button>
</div>}

{editForm && canEdit && <div className='space-y-2 border-t pt-3'>
Expand Down
Loading