security: harden web server, daemon socket, and write paths

Security review findings, fixed and verified:

Critical
- Fix unauthenticated arbitrary file read: the SPA catch-all route
  joined the raw URL path onto the dist dir without containment, so
  encoded '..' segments (/%2e%2e/etc/passwd) leaked any file readable
  by the root server. Resolve with realpath and reject paths outside
  the dist dir (fail-closed 404).

High
- Daemon socket: serve_start no longer accepts caller-chosen
  host/port. The socket is world-connectable (unprivileged CLI users),
  so callers could previously rebind the root web server to 0.0.0.0.
  The daemon now always binds the operator-configured address and
  reports it in the response; the CLI warns on mismatch.

Medium
- Remove the per-request max_delta_khz override from the API: the
  server-enforced safety cap is now authoritative. CLI direct paths
  (write, profile apply, verify) honor the configured cap; --max-delta
  still overrides for explicit root use.
- Snapshot restore: confine filepath to the snapshot directory
  (realpath containment; blocks symlink escapes).
- Login lockout: honor X-Forwarded-For only for peers listed in the
  new trusted_proxies config (rightmost untrusted hop), so the
  per-IP lockout works behind a reverse proxy. Spoofed headers from
  untrusted peers are ignored.
- /api/shutdown: new allow_api_shutdown config (default true);
  shared systems can disable the API shutdown path.

TLS (opt-in, like auth)
- New ssl_certfile/ssl_keyfile config + CLI flags (serve start,
  service install/configure, --no-ssl to disable). When active:
  HTTPS for UI/API, wss:// for WebSockets, Secure session cookie,
  CLI auto-switches to https://. Cert/key paths are validated up
  front with a clear error instead of a silent uvicorn crash.

Tests & docs
- tests/test_security.py: standalone regression tests (no new deps)
  covering SPA containment, snapshot containment, cap removal,
  client-IP derivation, proxy normalization, TLS scheme detection,
  and daemon host/port hardening.
- README + Usage-Guide: TLS section, new config keys, updated
  security notes.
This commit is contained in:
ARIA committed 2026-09-10 16:19:21 +02:00
1 parent 8956fc9d7b
commit 39701c12ff
9 files changed
+648 -55

No files matched your search

+106 -18
View File
@@ -11,7 +11,7 @@ import logging
import os
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from typing import Any
from typing import Any, Protocol
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
@@ -20,7 +20,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import auth
from .config import Config, default_config
from .config import Config, default_config, tls_enabled
from .hal.dashboard import get_dashboard_info
from .hal.fans import (
get_fan_info,
@@ -552,12 +552,10 @@ app.add_middleware(AuthMiddleware)
class WriteRequest(BaseModel):
deltas: dict[int, int] # {point_index: delta_kHz}
max_delta_khz: int | None = None # per-request safety limit override
class GlobalOffsetRequest(BaseModel):
delta_khz: int
max_delta_khz: int | None = None # per-request safety limit override
class VerifyRequest(BaseModel):
@@ -654,7 +652,7 @@ async def api_auth_login(req: LoginRequest, request: Request):
if not users:
raise HTTPException(status_code=404, detail="Authentication is not enabled")
client_ip = request.client.host if request.client else "unknown"
client_ip = _client_ip(request, cfg.trusted_proxies)
if auth.is_locked_out(client_ip):
raise HTTPException(
status_code=429, detail="Too many failed attempts. Try again later."
@@ -677,6 +675,7 @@ async def api_auth_login(req: LoginRequest, request: Request):
max_age=auth.SESSION_TTL_S,
httponly=True,
samesite="lax",
secure=tls_enabled(cfg),
path="/",
)
return response
@@ -710,6 +709,36 @@ def _require_gpu(gpu_index: int = 0):
return gpu, g_state
class _ClientIpSource(Protocol):
"""Structural type for the request objects _client_ip accepts.
Both Starlette's Request and WebSocket expose these; tests may pass
lightweight duck types.
"""
client: Any
headers: Any
def _client_ip(request: _ClientIpSource, trusted_proxies: list[str]) -> str:
"""Best-effort client IP for the login lockout.
When the direct peer is a configured trusted proxy (e.g. a TLS
reverse proxy), use the rightmost X-Forwarded-For entry that is not
itself a trusted proxy. Otherwise use the direct peer address —
X-Forwarded-For is spoofable, so it is only honoured for peers the
operator explicitly listed in ``trusted_proxies``.
"""
peer = request.client.host if request.client else "unknown"
if not trusted_proxies or peer not in trusted_proxies:
return peer
hops = [h.strip() for h in request.headers.get("x-forwarded-for", "").split(",")]
for hop in reversed(hops):
if hop and hop not in trusted_proxies:
return hop
return peer
# ── REST endpoints ────────────────────────────────────────────────────────────
@@ -1407,10 +1436,10 @@ async def api_curve_write(req: WriteRequest, gpu_index: int = 0):
vfp_state, _ = await _run(read_curve, gpu, g_state["gpu_name"])
effective_limit = (
req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz
)
errors = validate_write(req.deltas, effective_limit)
# The safety cap is always the server-side config value — clients cannot
# raise it per request (shared systems must not let one user override the
# hardware safety limit). Raise it in /etc/nvcurve/config.json if needed.
errors = validate_write(req.deltas, cfg.max_delta_khz)
if errors:
raise HTTPException(status_code=400, detail={"errors": errors})
@@ -1461,10 +1490,8 @@ async def api_curve_write_global(req: GlobalOffsetRequest, gpu_index: int = 0):
raise HTTPException(status_code=500, detail="Failed to read curve")
all_deltas = {p.index: req.delta_khz for p in vfp_state.points if p.domain == "gpu"}
effective_limit = (
req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz
)
errors = validate_write(all_deltas, effective_limit)
# Safety cap is the server-side config value only (see api_curve_write).
errors = validate_write(all_deltas, cfg.max_delta_khz)
if errors:
raise HTTPException(status_code=400, detail={"errors": errors})
@@ -1605,10 +1632,21 @@ async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0):
@app.post("/api/shutdown")
async def api_shutdown():
"""Gracefully shut down the server process."""
"""Gracefully shut down the server process.
Disabled when ``allow_api_shutdown`` is false in the config — on shared
systems stop the service via systemd instead.
"""
import os
import signal
cfg: Config = _state["config"]
if not cfg.allow_api_shutdown:
raise HTTPException(
status_code=403,
detail="API shutdown is disabled (allow_api_shutdown: false)",
)
loop = asyncio.get_running_loop()
loop.call_later(0.1, lambda: os.kill(os.getpid(), signal.SIGTERM))
return {"ok": True}
@@ -1795,7 +1833,15 @@ async def serve_spa(catchall: str):
if not os.path.isdir(_dist_dir):
return {"error": "Frontend not built. Run pnpm build in frontend/."}
path = os.path.join(_dist_dir, catchall)
# Contain the resolved path inside the dist directory. The raw URL path
# can carry encoded ".." segments (e.g. /%2e%2e/etc/passwd) that would
# otherwise escape the dist dir via os.path.join — an unauthenticated
# arbitrary-file-read since the server runs as root.
base = os.path.realpath(_dist_dir)
path = os.path.realpath(os.path.join(_dist_dir, catchall))
if path != base and not path.startswith(base + os.sep):
raise HTTPException(status_code=404, detail="Not Found")
if os.path.isfile(path) and catchall:
return FileResponse(path)
@@ -1821,8 +1867,14 @@ def run(
gpu_index: int = 0,
config: Config = default_config,
open_browser: bool = False,
ssl_certfile: str | None = None,
ssl_keyfile: str | None = None,
) -> None:
"""Start the uvicorn server. Blocking."""
"""Start the uvicorn server. Blocking.
When both ssl_certfile and ssl_keyfile are given (either here or in the
config), the server serves HTTPS and the session cookie is Secure.
"""
import socket
import threading
@@ -1830,6 +1882,34 @@ def run(
_state["config"] = config
# CLI flags take precedence over config values.
certfile = ssl_certfile or config.ssl_certfile
keyfile = ssl_keyfile or config.ssl_keyfile
if certfile:
config.ssl_certfile = certfile
if keyfile:
config.ssl_keyfile = keyfile
tls = bool(certfile and keyfile)
# Fail fast on a bad TLS configuration — otherwise uvicorn dies at
# startup and (in daemon mode) the error is only visible in the server
# log while `serve status` reports "not running".
tls = False
if certfile and keyfile:
missing = [
f"{label} ({path})"
for label, path in (("certificate", certfile), ("key", keyfile))
if not os.path.isfile(path)
]
if missing:
print(f"Error: TLS file(s) not found: {', '.join(missing)}")
print(
"Fix the path (nvcurve service configure --ssl-certfile/--ssl-keyfile) "
"or disable TLS (--no-ssl)."
)
return
tls = True
# Suppress noisy websockets keepalive ping-timeout tracebacks — these are
# normal disconnection events (browser tab closed, network hiccup) and
# logging them at ERROR level creates false alarm noise.
@@ -1847,7 +1927,7 @@ def run(
)
return
url = f"http://{host}:{port}"
url = f"{'https' if tls else 'http'}://{host}:{port}"
# Print banner *before* uvicorn starts so it appears above uvicorn's own output.
# GPU name is populated by the lifespan; we omit it here since the server
@@ -1862,4 +1942,12 @@ def run(
if open_browser and not _DEV_PORT:
threading.Timer(1.2, lambda: _open_browser_as_user(url)).start()
uvicorn.run(app, host=host, port=port, log_level="warning", access_log=False)
uvicorn.run(
app,
host=host,
port=port,
log_level="warning",
access_log=False,
ssl_certfile=certfile if tls else None,
ssl_keyfile=keyfile if tls else None,
)