From 60242bd529d01710af12ff205c1ecd155d6002b6 Mon Sep 17 00:00:00 2001 From: DeepZone Date: Wed, 20 May 2026 19:16:39 +0100 Subject: [PATCH] release: v0.5.4-beta security baseline and production polish --- .gitignore | 5 +++ README.md | 14 ++++++-- RELEASE_NOTES.md | 24 +++++++++++++ backend/Dockerfile | 13 ++++++- backend/app/api/routes_health.py | 2 +- backend/app/api/routes_system.py | 2 +- backend/app/config.py | 4 ++- backend/app/core/system_status.py | 10 +++++- backend/app/main.py | 19 ++++++++++- backend/pyproject.toml | 2 +- backend/tests/test_api_smoke.py | 2 +- docs/operations/release-checklist.md | 30 ++++++++++++++++ docs/operations/security.md | 51 ++++++++++++++++++++++++++++ frontend/nginx.conf | 6 ++++ frontend/package-lock.json | 5 +-- frontend/package.json | 2 +- frontend/src/App.tsx | 7 ++-- frontend/src/components/Layout.tsx | 2 +- frontend/src/types.ts | 1 + 19 files changed, 184 insertions(+), 17 deletions(-) create mode 100644 docs/operations/release-checklist.md create mode 100644 docs/operations/security.md diff --git a/.gitignore b/.gitignore index 4f6b134..4cab1e1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,10 @@ __pycache__/ .pytest_cache/ .venv/ node_modules/ +dist/ frontend/dist/ backend/.pytest_cache/ +coverage/ +.env +.env.* +!.env.example diff --git a/README.md b/README.md index d168bca..577d528 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ RouteForge Logo

- Version + Version License Status Selfhosted @@ -43,7 +43,7 @@ Routing changes often require fast but traceable checks across multiple external ## Current Alpha Status -RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.5.3-beta**. +RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.5.4-beta**. ## Quickstart with Docker Compose @@ -248,3 +248,13 @@ npm run build ## License RouteForge is licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). + + +## Security baseline + +For production polish and selfhosting hardening guidance, see: + +- `docs/operations/security.md` +- `docs/operations/release-checklist.md` + +In the standard Docker setup, API calls are same-origin via frontend nginx (`/api` proxy). CORS is primarily needed for split frontend/backend deployments. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7902cfc..7321812 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,29 @@ # Release Notes +## v0.5.4-beta + +**Production Polish & Security Baseline** + +### Highlights + +- Security headers for frontend Nginx +- Improved same-origin proxy security guidance +- Container hardening review +- Safer environment and secrets documentation +- Logging baseline improvements +- Security operations documentation +- Release checklist documentation +- Optional security warnings in system status + +### Known limitations + +- No authentication yet +- No multi-user support yet +- No advanced RBAC yet +- No automated release pipeline yet + +--- + ## Licensing diff --git a/backend/Dockerfile b/backend/Dockerfile index b454f49..f9f5fd0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,9 +1,20 @@ FROM python:3.12-slim + WORKDIR /app + +RUN useradd --create-home --shell /usr/sbin/nologin routeforge + COPY pyproject.toml ./ COPY app ./app COPY alembic ./alembic COPY alembic.ini ./alembic.ini -RUN pip install --no-cache-dir . + +RUN pip install --no-cache-dir . \ + && mkdir -p /app/data \ + && chown -R routeforge:routeforge /app + +USER routeforge + EXPOSE 8000 + CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/api/routes_health.py b/backend/app/api/routes_health.py index a884714..66fbd54 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.5.3-beta", "database": get_database_status(engine).get("status", "unknown")} + return {"status": "ok", "version": "v0.5.4-beta", "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 5bf99d3..de8514d 100644 --- a/backend/app/api/routes_system.py +++ b/backend/app/api/routes_system.py @@ -11,7 +11,7 @@ def system_info(): return { 'name': 'RouteForge', - 'version': 'v0.5.3-beta', + 'version': 'v0.5.4-beta', '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 851dd0c..8e0adff 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -13,8 +13,10 @@ class Settings(BaseSettings): ripestat_retry_backoff_seconds: float = 0.5 ripestat_use_stale_cache_on_error: bool = True cache_ttl_seconds: int = Field(default=900, validation_alias="RIPESTAT_CACHE_TTL_SECONDS") - cors_origins: str = "http://192.168.58.167:3000,http://127.0.0.1:3000" + cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000" demo_mode: bool = Field(default=False, validation_alias="ROUTEFORGE_DEMO_MODE") + log_level: str = "INFO" + postgres_password: str = Field(default="", validation_alias="POSTGRES_PASSWORD") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") diff --git a/backend/app/core/system_status.py b/backend/app/core/system_status.py index c7ba5cd..38e3460 100644 --- a/backend/app/core/system_status.py +++ b/backend/app/core/system_status.py @@ -102,11 +102,18 @@ def get_database_status(engine: Engine | None) -> dict: return payload +def _security_warnings() -> list[str]: + warnings: list[str] = [] + if settings.postgres_password == "change-me": + warnings.append("POSTGRES_PASSWORD uses the default example value. Change it before production use.") + return warnings + + def build_system_status(engine: Engine | None) -> dict: return { "status": "ok", "name": settings.app_name, - "version": "v0.5.3-beta", + "version": "v0.5.4-beta", "read_only": True, "mode": "demo" if settings.demo_mode else "live", "demo_mode": settings.demo_mode, @@ -119,6 +126,7 @@ def build_system_status(engine: Engine | None) -> dict: "retry_backoff_seconds": settings.ripestat_retry_backoff_seconds, "use_stale_cache_on_error": settings.ripestat_use_stale_cache_on_error, }, + "security_warnings": _security_warnings(), "features": { "asn_check": True, "prefix_check": True, diff --git a/backend/app/main.py b/backend/app/main.py index 01ca29c..46c4e77 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later +import logging + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -7,9 +9,13 @@ from app.api.routes_reports import router as reports_router from app.api.routes_system import router as system_router from app.config import settings +from app.core.system_status import database_type_from_url from app.database import Base, engine -app = FastAPI(title="RouteForge", version="0.5.3") +logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO)) +logger = logging.getLogger("routeforge") + +app = FastAPI(title="RouteForge", version="0.5.4") app.add_middleware( CORSMiddleware, @@ -25,6 +31,17 @@ def startup() -> None: if settings.database_url.startswith("sqlite"): Base.metadata.create_all(bind=engine) + logger.info( + "RouteForge startup: version=%s mode=%s database_type=%s read_only=%s ripestat_cache_ttl=%s ripestat_timeout=%s ripestat_retries=%s", + app.version, + "DEMO" if settings.demo_mode else "LIVE", + database_type_from_url(settings.database_url), + True, + settings.cache_ttl_seconds, + settings.ripestat_timeout_seconds, + settings.ripestat_max_retries, + ) + app.include_router(health_router) app.include_router(checks_router) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1a6846f..b6d164b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "routeforge-backend" -version = "0.5.3" +version = "0.5.4" description = "RouteForge backend" license = "AGPL-3.0-or-later" requires-python = ">=3.12" diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py index bee9d36..11c623b 100644 --- a/backend/tests/test_api_smoke.py +++ b/backend/tests/test_api_smoke.py @@ -157,7 +157,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.5.3-beta' + assert payload.get('version') == 'v0.5.4-beta' 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/release-checklist.md b/docs/operations/release-checklist.md new file mode 100644 index 0000000..43c83bc --- /dev/null +++ b/docs/operations/release-checklist.md @@ -0,0 +1,30 @@ +# Release Checklist + +## Before release + +- `git pull` +- Run backend tests (`cd backend && pytest -q`) +- Run frontend production build (`cd frontend && npm run build`) +- Validate Compose (`docker compose config` and production config) +- Check system status endpoint (`/api/system/status`) +- Check migration status visibility (`database.schema_version`, `migration_head`, `migration_status`) + +## Tagging + +- `git tag -a vX.Y.Z[-beta] -m "RouteForge vX.Y.Z[-beta]"` +- `git push origin vX.Y.Z[-beta]` + +## GitHub Release + +- Use release title matching the version and sprint scope +- Enable **prerelease** checkbox for beta versions +- Include structured release notes with highlights and known limitations + +## Post-release smoke test + +- `GET /health` +- `GET /api/system/info` +- `GET /api/system/status` +- Basic ASN check +- Basic Prefix check +- Export summary from report history diff --git a/docs/operations/security.md b/docs/operations/security.md new file mode 100644 index 0000000..9156458 --- /dev/null +++ b/docs/operations/security.md @@ -0,0 +1,51 @@ +# Security Baseline + +## Read-only safety model + +RouteForge is read-only by design. It validates routing state and preflight conditions but does not push configuration changes to external systems. + +## Reverse proxy / HTTPS recommendation + +Use RouteForge behind a reverse proxy with TLS termination in production (for example Caddy, Traefik, or Nginx). Keep plain HTTP only for local lab usage. + +## Secrets and .env handling + +- Copy `.env.example` to `.env` and set real values before production use. +- Never commit `.env` files to git. +- Change `POSTGRES_PASSWORD` from the example default. + +## Default password warning + +`/api/system/status` exposes `security_warnings` when a risky default is detected (currently: `POSTGRES_PASSWORD=change-me`). No secret values are returned. + +## CORS guidance + +In the standard Docker setup, frontend and API are same-origin via nginx (`/api` proxy). CORS is mainly relevant for split deployments where frontend and backend are served from different origins. + +## Security headers + +Frontend nginx sets baseline headers: + +- `X-Content-Type-Options: nosniff` +- `X-Frame-Options: DENY` +- `Referrer-Policy: no-referrer-when-downgrade` +- `Permissions-Policy: geolocation=(), microphone=(), camera=()` +- A conservative initial Content-Security-Policy for SPA usage + +## Container hardening + +- Backend container runs as non-root user `routeforge`. +- Backend image keeps only runtime-relevant files. +- Frontend image is multi-stage (build + runtime). +- Nginx container still runs with default upstream behavior; strict non-root nginx runtime can be added later as a dedicated hardening step. + +## What RouteForge does not do + +- no ROA creation +- no RIPE DB writes +- no router deployment + +## Current limitations + +- no authentication yet +- no multi-user support yet diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3682c14..51c3ab1 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -5,6 +5,12 @@ server { root /usr/share/nginx/html; index index.html; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';" always; + location /api/ { proxy_pass http://backend:8000/api/; proxy_http_version 1.1; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8b0e1e0..8080088 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,13 @@ { "name": "routeforge-frontend", - "version": "0.5.3", + "version": "0.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "routeforge-frontend", - "version": "0.5.3", + "version": "0.5.4", + "license": "AGPL-3.0-or-later", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/frontend/package.json b/frontend/package.json index 3d672fb..7a4c616 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "routeforge-frontend", - "version": "0.5.3", + "version": "0.5.4", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 975c18a..5375fff 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,14 +22,14 @@ export default function App() { getSystemStatus().then((payload) => { setSystemStatus(payload); setSystemStatusError('') }).catch(() => setSystemStatusError('System status could not be loaded.')) }, []) - const systemLine = useMemo(() => system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.5.3-beta · read-only preflight checks', [system]) + const systemLine = useMemo(() => system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.5.4-beta · read-only preflight checks', [system]) const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', reports: 'Reports', system: 'System Status', about: 'About RouteForge' }[active] const proxyStatus = systemStatusError ? 'ERROR' : 'OK' const migrationStatus = systemStatus?.database?.migration_status || 'unknown' return {active === 'dashboard' &&

-

RouteForge v0.5.3-beta

Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.

+

RouteForge v0.5.4-beta

Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.

System Health

{systemStatusError ?

{systemStatusError}

:
Status: {systemStatus?.status || 'unknown'}
Mode: {systemStatus?.mode || 'unknown'}
Database: {systemStatus?.database?.status || 'unknown'}
Version: {systemStatus?.version || 'unknown'}
}
} {active === 'asn' && } @@ -44,10 +44,11 @@ export default function App() {
Version: {systemStatus.version}
Mode: {systemStatus.mode}
Read-only: {String(systemStatus.read_only)}
Demo mode: {String(systemStatus.demo_mode)}
API Proxy: {proxyStatus}
Database: {systemStatus.database?.status || 'unknown'}
Schema Version: {systemStatus.database?.schema_version || 'unknown'}
Migration Head: {systemStatus.database?.migration_head || 'unknown'}
Migration Status:
+ {systemStatus.security_warnings && systemStatus.security_warnings.length > 0 &&

Security Warnings

    {systemStatus.security_warnings.map((warning) =>
  • {warning}
  • )}
}

RIPEstat Settings

{JSON.stringify(systemStatus.ripestat, null, 2)}

Features

{JSON.stringify(systemStatus.features, null, 2)}
} } - {active === 'about' &&

RouteForge liefert nachvollziehbare Routing-Preflightchecks für technische Operator-Workflows.

Version: v0.5.3-beta

} + {active === 'about' &&

RouteForge liefert nachvollziehbare Routing-Preflightchecks für technische Operator-Workflows.

Version: v0.5.4-beta

} } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9a8498c..9d72415 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -34,7 +34,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode }:
{demoMode ? 'DEMO' : 'LIVE'} READ-ONLY - v0.5.3-beta + v0.5.4-beta
{children}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 80a90c6..b8c0863 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -89,4 +89,5 @@ export type SystemStatus = { api_proxy?: ApiProxyStatus ripestat?: RipestatRuntimeSettings features?: SystemFeatures + security_warnings?: string[] }