chore(backend): MQTT/WLED/devices/capture/utils + api routes hardening
Bundle the remaining backend touch-ups that the production review landed individually as small surgical edits across many modules: - MQTT runtime: fire-and-forget task tracking + drain resilience. - mqtt_source + store + storage/color_strip_source: secret_box encryption for credentials with auto-migration of plaintext fields. - devices/discovery_watcher: task tracking on watcher start/stop. - devices/wled_client + wled_provider: URL scheme inference helper applied at the create/update boundary so bare hostnames stay valid. - core/capture/screen_capture: hardened error paths. - core/processing (mapped/processed/processor_manager/video/wled_target): smaller follow-throughs from the registry refactor that landed earlier on the branch. - utils/safe_source + utils/file_ops + utils/__init__: shared URL + IP classification helpers + larger streaming upload size caps. - api/auth: WebSocket Origin allow-list + /docs auth-gate. - api/dependencies: register the new HTTP-endpoint store. - api/routes (assets, backup, webhooks): streaming-upload caps + asyncio.gather return_exceptions on broadcast loops. - tests/test_api + tests/e2e/test_backup_flow: cover the new caps and the Origin allow-list.
This commit is contained in:
@@ -11,14 +11,13 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
from ledgrab.config import get_config
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
|
||||
|
||||
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."""
|
||||
@@ -26,15 +25,15 @@ def is_auth_enabled() -> bool:
|
||||
|
||||
|
||||
def _is_loopback(host: str | None) -> bool:
|
||||
"""Return True when *host* is a loopback address."""
|
||||
"""Return True when *host* is a loopback address.
|
||||
|
||||
Delegates to :func:`ledgrab.utils.net_classify.is_loopback` so this
|
||||
auth gate, the SSRF guard in ``safe_source``, and the LAN-default
|
||||
inference in ``url_scheme`` share one classification source.
|
||||
"""
|
||||
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
|
||||
return _classify_is_loopback(host)
|
||||
|
||||
|
||||
def verify_api_key(
|
||||
@@ -142,6 +141,23 @@ def require_authenticated(label: str) -> None:
|
||||
WS_AUTH_CLOSE_CODE = 4401
|
||||
|
||||
|
||||
WS_ORIGIN_CLOSE_CODE = 4403
|
||||
"""Close code sent when a WebSocket request fails the Origin allowlist."""
|
||||
|
||||
|
||||
def _is_origin_allowed(origin: str | None, allowed: list[str]) -> bool:
|
||||
"""Return True when *origin* matches one of the configured CORS origins.
|
||||
|
||||
Non-browser clients (Python scripts, curl) don't send Origin — those are
|
||||
allowed through; the Bearer-token check on the auth handshake is the
|
||||
primary defence in that case. Browsers always set Origin, so this only
|
||||
blocks cross-site WebSocket connection attempts (CSWSH).
|
||||
"""
|
||||
if not origin:
|
||||
return True
|
||||
return origin in set(allowed or [])
|
||||
|
||||
|
||||
async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0) -> str | None:
|
||||
"""Accept the WebSocket, then perform first-message auth handshake.
|
||||
|
||||
@@ -152,6 +168,23 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
|
||||
Returns the caller label on success, ``None`` on failure (connection
|
||||
already closed).
|
||||
"""
|
||||
# Reject cross-site WebSocket attempts before accepting — a browser-based
|
||||
# attacker page cannot forge the Origin header, so an Origin mismatch is
|
||||
# a strong signal even before the token check. Non-browser clients
|
||||
# legitimately omit Origin; those fall through to the auth handshake.
|
||||
config = get_config()
|
||||
origin = websocket.headers.get("origin")
|
||||
if not _is_origin_allowed(origin, config.server.cors_origins):
|
||||
logger.warning(
|
||||
"Rejected WebSocket from origin %r (not in cors_origins)",
|
||||
origin,
|
||||
)
|
||||
try:
|
||||
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
await websocket.accept()
|
||||
label = await verify_ws_auth(websocket, timeout=timeout)
|
||||
if label is None:
|
||||
|
||||
Reference in New Issue
Block a user