a365059976
- Reduce access_token_ttl from 900s to 300s (5 min) to shrink XSS exploit window - Add session_id claim to JWT tokens tied to browser session (X-Session-Id header) - Flask middleware validates session_id matches header on every request - CSP headers: default-src/script-src 'self', no unsafe-inline/eval, frame-ancestors none - X-Content-Type-Options: nosniff on all responses - Move refresh token from localStorage to sessionStorage (tab-scoped, cleared on close) - Timing-safe password verification (dummy Argon2id for unknown users) - WebSocket auth also validates session_id header - Add 5 session_id tests and 3 CSP header tests
307 lines
9.6 KiB
Python
307 lines
9.6 KiB
Python
"""
|
|
server.py - Vacuum Wall management WebUI entry point.
|
|
|
|
Serves the Flask application on 127.0.0.1:9090. Nginx terminates SSL
|
|
and enforces basic authentication before proxying to this port.
|
|
"""
|
|
|
|
import contextlib
|
|
import importlib
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from flask import Flask, abort, jsonify, request
|
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
|
|
from lib.auth import validate_token
|
|
from lib.db import get_db
|
|
from lib.logging import setup_logging
|
|
from webui.api.auth import bp as auth_bp
|
|
from webui.api.certs import bp as certs_bp
|
|
from webui.api.dhcp import bp as dhcp_bp
|
|
from webui.api.firewall import bp as firewall_bp
|
|
from webui.api.logs import bp as logs_bp
|
|
from webui.api.network import bp as network_bp
|
|
from webui.api.proxy import bp as proxy_bp
|
|
from webui.api.status import bp as status_bp
|
|
from webui.api.wireguard import bp as wireguard_bp
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging — must be first so subsequent modules inherit the config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
|
|
|
setup_logging()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
logger.info(
|
|
"Python %s.%s.%s",
|
|
sys.version_info.major,
|
|
sys.version_info.minor,
|
|
sys.version_info.micro,
|
|
)
|
|
logger.info("Project directory: %s", PROJECT_DIR)
|
|
logger.info("Process ID: %d", os.getpid())
|
|
|
|
_reloading = False
|
|
|
|
|
|
def _sighup_handler(signum, frame):
|
|
"""Handle SIGHUP by reloading modules then restarting via SIGTERM.
|
|
|
|
Reloads all ``webui.*`` and ``lib.*`` modules, re-registers blueprints,
|
|
and requests systemd restart by sending SIGTERM with default handler.
|
|
"""
|
|
global _reloading
|
|
if _reloading:
|
|
return
|
|
_reloading = True
|
|
logger.info("Received SIGHUP, reloading modules...")
|
|
for mod_name, mod in sys.modules.items():
|
|
if mod_name.startswith("webui.") or mod_name.startswith("lib."):
|
|
with contextlib.suppress(Exception):
|
|
importlib.reload(mod)
|
|
logger.info("Modules reloaded, sending SIGTERM to restart under systemd...")
|
|
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
|
|
|
|
signal.signal(signal.SIGHUP, _sighup_handler)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# App factory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
app = Flask(__name__)
|
|
app.config["SECRET_KEY"] = os.urandom(32).hex()
|
|
|
|
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
|
|
# Cache-control: short TTL in dev, aggressive caching in prod (versioned assets)
|
|
_DEV_MODE = bool(os.environ.get("VACUUM_WALL_DEV")) or False
|
|
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 5 if _DEV_MODE else 31536000
|
|
|
|
# Flask is behind nginx — trust X-Forwarded-* headers for scheme/host detection
|
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
|
|
|
get_db()
|
|
|
|
app.register_blueprint(auth_bp, url_prefix="/api/auth")
|
|
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
|
|
app.register_blueprint(network_bp, url_prefix="/api/network")
|
|
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
|
|
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
|
|
app.register_blueprint(certs_bp, url_prefix="/api/certs")
|
|
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
|
|
app.register_blueprint(logs_bp, url_prefix="/api/logs")
|
|
app.register_blueprint(status_bp, url_prefix="/api/status")
|
|
|
|
BLUEPRINTS = [
|
|
("auth", auth_bp),
|
|
("firewall", firewall_bp),
|
|
("network", network_bp),
|
|
("dhcp", dhcp_bp),
|
|
("proxy", proxy_bp),
|
|
("certs", certs_bp),
|
|
("wireguard", wireguard_bp),
|
|
("logs", logs_bp),
|
|
("status", status_bp),
|
|
]
|
|
|
|
for name, _ in BLUEPRINTS:
|
|
logger.info("Registered blueprint '%s' at /api/%s", name, name)
|
|
|
|
# ── Public endpoints (no auth required) ──
|
|
_AUTH_EXEMPT = {
|
|
("GET", "/"),
|
|
("POST", "/api/auth/login"),
|
|
("POST", "/api/auth/refresh"),
|
|
("POST", "/api/auth/webauthn/authenticate-begin"),
|
|
("POST", "/api/auth/webauthn/authenticate-finish"),
|
|
}
|
|
|
|
|
|
def _subsystem_from_path(path: str) -> str | None:
|
|
"""Extract subsystem name from API path."""
|
|
if not path.startswith("/api/"):
|
|
return None
|
|
parts = path.split("/")
|
|
if len(parts) >= 3:
|
|
return parts[2]
|
|
return None
|
|
|
|
|
|
def _has_permission(perms: dict, subsystem: str, method: str) -> bool:
|
|
"""Check if user has permission for subsystem + method."""
|
|
level = perms.get(subsystem)
|
|
if method == "GET":
|
|
return level in ("read", "rw")
|
|
return level == "rw"
|
|
|
|
|
|
# ── JWT authentication middleware ──
|
|
|
|
|
|
@app.before_request
|
|
def _auth_middleware():
|
|
"""Validate JWT from Authorization header for API routes.
|
|
|
|
Exempts: static routes, vendor files, and public auth endpoints.
|
|
Attaches request._user_ctx with user info for downstream handlers.
|
|
"""
|
|
method = request.method
|
|
path = request.path
|
|
|
|
# Exempt specific paths
|
|
if (method, path) in _AUTH_EXEMPT:
|
|
return
|
|
if method == "GET" and path.startswith("/vendor/"):
|
|
return
|
|
if method in ("GET", "HEAD") and path.startswith("/static/"):
|
|
return
|
|
|
|
# For non-API routes, skip auth
|
|
if not path.startswith("/api/"):
|
|
return
|
|
|
|
# Extract token from Authorization header
|
|
auth_header = request.headers.get("Authorization", "")
|
|
if not auth_header.startswith("Bearer "):
|
|
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
|
|
|
token_string = auth_header[7:] # strip "Bearer "
|
|
session_header = request.headers.get("X-Session-Id")
|
|
payload = validate_token(token_string, token_type="access", session_id=session_header)
|
|
if payload is None:
|
|
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
|
|
|
username = payload.get("sub")
|
|
if not username:
|
|
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
|
|
|
# Check subsystem permissions
|
|
subsystem = _subsystem_from_path(path)
|
|
if subsystem:
|
|
perms = payload.get("permissions", {})
|
|
if subsystem not in perms:
|
|
return jsonify({"ok": False, "error": "forbidden"}), 403
|
|
if not _has_permission(perms, subsystem, method):
|
|
return jsonify({"ok": False, "error": "forbidden"}), 403
|
|
|
|
request._user_ctx = {
|
|
"username": username,
|
|
"permissions": perms if subsystem else payload.get("permissions", {}),
|
|
"jti": payload.get("jti"),
|
|
}
|
|
return
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request logging
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@app.before_request
|
|
def _log_request_start():
|
|
"""Record request start time for duration tracking."""
|
|
request._start_time = time.monotonic()
|
|
|
|
|
|
@app.after_request
|
|
def _log_request_finish(response):
|
|
"""Log request duration and status code after response generation.
|
|
|
|
Args:
|
|
response: The HTTP response object.
|
|
|
|
Returns:
|
|
The unchanged response object.
|
|
"""
|
|
elapsed_ms = (
|
|
time.monotonic() - getattr(request, "_start_time", time.monotonic())
|
|
) * 1000
|
|
logger.info(
|
|
"%s %s -> %d (%.1f ms)",
|
|
request.method,
|
|
request.path,
|
|
response.status_code,
|
|
elapsed_ms,
|
|
)
|
|
|
|
# Content Security Policy — prevent inline script execution and XSS
|
|
if "Content-Security-Policy" not in response.headers:
|
|
response.headers["Content-Security-Policy"] = (
|
|
"default-src 'self'; "
|
|
"script-src 'self'; "
|
|
"style-src 'self' 'unsafe-inline'; "
|
|
"img-src 'self' data:; "
|
|
"font-src 'self'; "
|
|
"connect-src 'self'; "
|
|
"frame-ancestors 'none'; "
|
|
"base-uri 'self'; "
|
|
"form-action 'self'"
|
|
)
|
|
|
|
# set X-Content-Type-Options to prevent MIME sniffing
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
|
|
# Set cache headers: short in dev, long with staleness tolerance in prod
|
|
if response.content_type.startswith("text/html"):
|
|
# index.html: always short cache so browser revalidates
|
|
response.headers["Cache-Control"] = "no-cache"
|
|
elif response.content_type.startswith(("text/javascript", "text/css")):
|
|
if _DEV_MODE:
|
|
response.headers["Cache-Control"] = "max-age=5"
|
|
else:
|
|
response.headers["Cache-Control"] = (
|
|
"public, max-age=31536000, stale-while-revalidate=86400"
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SPA entry point — serve index.html for /, 404 for everything else
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SPA_DIR = STATIC_DIR
|
|
VENDOR_DIR = PROJECT_DIR / "vendor"
|
|
|
|
|
|
@app.route("/")
|
|
def spa_root():
|
|
"""Serve the SPA entry point. No catch-all — client handles routing."""
|
|
scheme = "wss" if request.is_secure else "ws"
|
|
ws_url = f"{scheme}://{request.host}/ws"
|
|
html = (SPA_DIR / "index.html").read_text()
|
|
return html.replace("__WS_URL_PLACEHOLDER__", ws_url)
|
|
|
|
|
|
@app.route("/vendor/<path:filename>")
|
|
def vendor_files(filename):
|
|
"""Serve vendored JS libraries (htm.js, etc.)."""
|
|
from flask import send_file
|
|
|
|
target = (VENDOR_DIR / filename).resolve()
|
|
if not target.is_relative_to(VENDOR_DIR):
|
|
abort(404)
|
|
return send_file(target)
|
|
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(e):
|
|
"""Return 404 JSON for API clients, 404 HTML for everything else."""
|
|
if request.path.startswith("/api/"):
|
|
return {"ok": False, "error": "Not found"}, 404
|
|
return "", 404
|
|
|
|
|
|
if __name__ == "__main__":
|
|
logger.info("Starting Flask on 127.0.0.1:9090")
|
|
app.run(host="127.0.0.1", port=9090)
|