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:
1 parent
8956fc9d7b
commit
39701c12ff
9 files changed
+648
-55
No files matched your search
+106
-18
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user