fix: comprehensive security, stability, and code quality audit
Build Android APK / build-android (push) Failing after 1m45s
Lint & Test / test (push) Successful in 4m54s

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:
2026-04-16 04:56:04 +03:00
parent 5fcb9f82bd
commit 123da1b5c4
124 changed files with 6276 additions and 3705 deletions
+224 -20
View File
@@ -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