fix: comprehensive security, stability, and code quality audit
Security: - Force API key auth for LAN (non-loopback) requests; remove shipped dev key - Block path-traversal in backup restore; require auth on backup endpoints - SSRF protection: DNS resolve + private/loopback/link-local IP rejection - AES-256-GCM encryption for HA tokens and MQTT passwords with auto-migration - WebSocket auth migrated from query-string to first-message protocol - Asset upload: extension allowlist, server-side mime, Content-Disposition - Update installer: SHA256 verification, tar/zip member validation - Tightened CORS (explicit methods/headers, no credentials) - ADB serial regex allowlist, webhook rate-limit key fix, log scrubbing Android: - Root-capture: ordered teardown, screenrecord respawn watchdog, child reaping - USB permission blocking API via CompletableDeferred - Python init crash guard with fatal-error screen - Moved root grant + QR generation off Main thread - Cached PyObject engine for per-frame bridge calls - Ordered ScreenCapture resource cleanup, allowBackup=false Python: - Replaced all asyncio.get_event_loop() with get_running_loop/to_thread - Split color_strip_sources.py (1683->5 files) and color_strip_stream.py (1324->7 files) into packages - Extracted FrameLimiter utility, migrated 9 stream loops - Provider base-class reuse, WLED state caching + URL normalization - Narrowed broad except-pass in WS routes, threading fixes in BaseStore Frontend: - XSS fix: escapeHtml on dynamic option labels, reconcile-based list renders - Typed DOM helpers, safe localStorage access, AbortController listener hygiene - openAuthedWs helper for first-message WS auth protocol - Migrated remaining plain <select>s to IconSelect/EntitySelect Design: - WCAG AA primary color on light theme (#2e7d32, 5.4:1 contrast) - Android TV 10-foot breakpoint (tv.css) - Consolidated z-index tokens, unified easing, card-running GPU hints
This commit is contained in:
+224
-20
@@ -1,10 +1,13 @@
|
||||
"""Authentication module for API key validation."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Security, status
|
||||
from fastapi import Depends, HTTPException, Request, Security, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
from ledgrab.config import get_config
|
||||
from ledgrab.utils import get_logger
|
||||
@@ -14,34 +17,69 @@ logger = get_logger(__name__)
|
||||
# Security scheme for Bearer token
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
"""Return True when at least one API key is configured."""
|
||||
return bool(get_config().auth.api_keys)
|
||||
|
||||
|
||||
def _is_loopback(host: str | None) -> bool:
|
||||
"""Return True when *host* is a loopback address."""
|
||||
if not host:
|
||||
return False
|
||||
# Strip IPv6 brackets and zone IDs
|
||||
h = host.strip().lower()
|
||||
if h.startswith("[") and h.endswith("]"):
|
||||
h = h[1:-1]
|
||||
h = h.split("%", 1)[0]
|
||||
return h in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def verify_api_key(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)]
|
||||
request: Request,
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
|
||||
) -> str:
|
||||
"""Verify API key from Authorization header.
|
||||
|
||||
When no API keys are configured, authentication is disabled and all
|
||||
requests are allowed through as "anonymous".
|
||||
Behavior:
|
||||
- When no API keys are configured AND the request comes from a loopback
|
||||
address, anonymous access is allowed.
|
||||
- When no API keys are configured AND the request is from a non-loopback
|
||||
(LAN) address, the request is REJECTED with 401 (security default —
|
||||
LAN access requires an API key).
|
||||
- When API keys ARE configured, valid Bearer credentials are required.
|
||||
|
||||
Args:
|
||||
request: incoming request (used to read client host)
|
||||
credentials: HTTP authorization credentials
|
||||
|
||||
Returns:
|
||||
Label/identifier of the authenticated client
|
||||
Label/identifier of the authenticated client ("anonymous" for
|
||||
loopback unauthenticated access).
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication is required but invalid
|
||||
HTTPException: If authentication is required but invalid / missing.
|
||||
"""
|
||||
config = get_config()
|
||||
client_host = request.client.host if request.client else None
|
||||
|
||||
# No keys configured → auth disabled, allow all requests
|
||||
if not config.auth.api_keys:
|
||||
return "anonymous"
|
||||
# No keys configured — allow loopback only.
|
||||
if _is_loopback(client_host):
|
||||
return "anonymous"
|
||||
# Allow caller to authenticate explicitly even without configured keys?
|
||||
# No — there are no keys to compare against. Reject.
|
||||
logger.warning("Rejected LAN request from %s: no API key configured", client_host)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=(
|
||||
"LAN access requires an API key. Configure auth.api_keys in "
|
||||
"config.yaml (see config/default_config.yaml for the format)."
|
||||
),
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Check if credentials are provided
|
||||
if not credentials:
|
||||
@@ -81,18 +119,184 @@ def verify_api_key(
|
||||
AuthRequired = Annotated[str, Depends(verify_api_key)]
|
||||
|
||||
|
||||
def verify_ws_token(token: str) -> bool:
|
||||
"""Check a WebSocket query-param token against configured API keys.
|
||||
def require_authenticated(label: str) -> None:
|
||||
"""Reject the anonymous (loopback) auth label.
|
||||
|
||||
When no API keys are configured, authentication is disabled and all
|
||||
WebSocket connections are allowed.
|
||||
Use this in endpoints that must NEVER be called anonymously even
|
||||
from loopback (e.g. backup download, secret reveal).
|
||||
|
||||
Raises:
|
||||
HTTPException: If *label* is "anonymous".
|
||||
"""
|
||||
if label == "anonymous":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=(
|
||||
"This endpoint requires an API key. Configure auth.api_keys "
|
||||
"in config.yaml and provide a Bearer token."
|
||||
),
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
WS_AUTH_CLOSE_CODE = 4401
|
||||
|
||||
|
||||
async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0) -> str | None:
|
||||
"""Accept the WebSocket, then perform first-message auth handshake.
|
||||
|
||||
Convenience wrapper over :func:`verify_ws_auth` that handles
|
||||
``websocket.accept()`` and automatically closes the connection with
|
||||
:data:`WS_AUTH_CLOSE_CODE` on failure.
|
||||
|
||||
Returns the caller label on success, ``None`` on failure (connection
|
||||
already closed).
|
||||
"""
|
||||
await websocket.accept()
|
||||
label = await verify_ws_auth(websocket, timeout=timeout)
|
||||
if label is None:
|
||||
try:
|
||||
await websocket.close(code=WS_AUTH_CLOSE_CODE)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
return label
|
||||
|
||||
|
||||
"""Close code sent when a WebSocket fails first-message auth (timeout or bad token)."""
|
||||
|
||||
|
||||
def _match_api_key(token: str) -> str | None:
|
||||
"""Return the label matching *token* using constant-time comparison, or None."""
|
||||
config = get_config()
|
||||
if not token:
|
||||
return None
|
||||
for label, api_key in config.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
return label
|
||||
return None
|
||||
|
||||
|
||||
async def verify_ws_auth(
|
||||
websocket: WebSocket,
|
||||
timeout: float = 3.0,
|
||||
) -> str | None:
|
||||
"""Authenticate a WebSocket via a first-message auth handshake.
|
||||
|
||||
Protocol:
|
||||
1. The caller must have already ``await websocket.accept()`` ed the
|
||||
connection.
|
||||
2. This function waits up to *timeout* seconds for the first message,
|
||||
which must be JSON of the form ``{"type": "auth", "token": "<key>"}``.
|
||||
``token`` may be null/missing on loopback when no API keys are
|
||||
configured.
|
||||
3. On success, sends ``{"type": "auth_ok"}`` and returns the caller
|
||||
label (e.g. ``"dev"``, or ``"anonymous"`` for loopback with no
|
||||
configured keys).
|
||||
4. On failure, sends ``{"type": "auth_error", "reason": ...}`` and
|
||||
returns ``None``. The caller is responsible for calling
|
||||
``await websocket.close(code=WS_AUTH_CLOSE_CODE)``.
|
||||
|
||||
Loopback policy mirrors :func:`verify_api_key`:
|
||||
- No API keys configured + loopback client → anonymous access allowed.
|
||||
If a client sends an ``auth`` message anyway, it's accepted as a
|
||||
no-op so the protocol stays uniform.
|
||||
- No API keys configured + non-loopback client → rejected.
|
||||
- Keys configured → valid token required regardless of loopback.
|
||||
|
||||
Returns:
|
||||
Caller label on success, ``None`` on failure.
|
||||
"""
|
||||
config = get_config()
|
||||
# No keys configured → auth disabled, allow all connections
|
||||
if not config.auth.api_keys:
|
||||
return True
|
||||
if token:
|
||||
for _label, api_key in config.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
return True
|
||||
return False
|
||||
client_host = websocket.client.host if websocket.client else None
|
||||
loopback = _is_loopback(client_host)
|
||||
keys_configured = bool(config.auth.api_keys)
|
||||
|
||||
# Try to read the auth message with a timeout.
|
||||
raw: str | None
|
||||
try:
|
||||
raw = await asyncio.wait_for(websocket.receive_text(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
if not keys_configured and loopback:
|
||||
# Loopback anonymous: no auth message arrived, but none is required.
|
||||
try:
|
||||
await websocket.send_json({"type": "auth_ok"})
|
||||
except Exception:
|
||||
return None
|
||||
return "anonymous"
|
||||
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
|
||||
try:
|
||||
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
except WebSocketDisconnect:
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.debug("WebSocket auth receive error: %s", exc)
|
||||
return None
|
||||
|
||||
# Parse the auth message.
|
||||
try:
|
||||
msg = json.loads(raw) if raw else {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{"type": "auth_error", "reason": "invalid JSON in auth message"}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
if not isinstance(msg, dict) or msg.get("type") != "auth":
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{"type": "auth_error", "reason": "first message must be {type:'auth'}"}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
token = msg.get("token")
|
||||
if token is not None and not isinstance(token, str):
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{"type": "auth_error", "reason": "token must be a string or null"}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# Loopback + no keys configured: accept regardless of token contents.
|
||||
if not keys_configured:
|
||||
if loopback:
|
||||
await websocket.send_json({"type": "auth_ok"})
|
||||
return "anonymous"
|
||||
logger.warning("Rejected LAN WebSocket from %s: no API key configured", client_host)
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "auth_error",
|
||||
"reason": "LAN access requires an API key",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# Keys configured: require a matching token.
|
||||
label = _match_api_key(token or "")
|
||||
if not label:
|
||||
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
|
||||
try:
|
||||
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
try:
|
||||
await websocket.send_json({"type": "auth_ok"})
|
||||
except Exception:
|
||||
return None
|
||||
logger.debug("WebSocket authenticated as: %s", label)
|
||||
return label
|
||||
|
||||
Reference in New Issue
Block a user