fix(security): close INFO ASVS findings — secret echo, TLS floor, mandatory tarball SHA, CORS/Content-Type guards, BUG-17

- V7.1.3: env known-insecure-default error no longer echoes the rejected secret value.
- V9.1.4: syslog-over-TLS forwarder + listener pin minimum_version=TLSv1_2.
- V12.1.2: updater tarball SHA-256 verification is now mandatory and fail-closed —
  /update and /update-self reject a missing digest (400), the executor rejects
  missing/mismatched digests before extract/apply. Every push path supplies it.
- V13.1.4: reject a wildcard '*' in DECNET_CORS_ORIGINS at startup.
- V13.1.5: enforce application/json on JSON write endpoints (415 otherwise),
  exempting multipart upload routes.
- BUG-17: SSE error log records the user uuid, not the resume cursor.

Also completes V2.1.7 consistently: the attacker-injectable PYTEST* env bypass is
replaced with explicit DECNET_TESTING=1 in the three remaining sites
(env.validate_public_binding, config logging, mysql url builder).

Tests added for every fix; unanimous adversarial review (no update-outage risk —
all push paths verified to send the digest).
This commit is contained in:
2026-06-10 13:50:06 -04:00
parent 245975a6dd
commit 337520c7ad
17 changed files with 520 additions and 73 deletions

View File

@@ -15,6 +15,8 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import ORJSONResponse, Response
from pydantic import ValidationError
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response as StarletteResponse
from decnet.env import (
DECNET_CORS_ORIGINS,
@@ -59,6 +61,20 @@ def get_background_tasks() -> dict[str, Optional[asyncio.Task[Any]]]:
}
def _check_cors_origins(origins: list[str]) -> None:
"""V13.1.4 — raise at startup if a wildcard CORS origin is configured.
Called from the lifespan so the error surfaces before any worker or DB
comes up, making misconfiguration immediately visible in uvicorn logs.
Exposed as a module-level function so tests can exercise it directly
without needing to reload the module.
"""
if "*" in origins:
raise ValueError(
"DECNET_CORS_ORIGINS must not contain a wildcard '*' — list explicit origin URLs"
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
global ingestion_task, collector_task, attacker_task, sniffer_task
@@ -79,6 +95,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Raises ValueError with an actionable message; uvicorn surfaces it.
validate_public_binding()
# V13.1.4 — CORS wildcard guard: wildcard '*' bypasses SOP and must be
# rejected at startup so the operator sees an actionable error immediately.
_check_cors_origins(DECNET_CORS_ORIGINS)
# Defence-in-depth on top of the CLI mode gating. Typer hides master-only
# commands when DECNET_MODE=agent, but a misconfigured systemd unit or
# a direct `python -m uvicorn decnet.web.api:app` call would bypass that.
@@ -254,6 +274,43 @@ app.add_middleware(
allow_headers=["Authorization", "Content-Type", "Last-Event-ID"],
)
# V13.1.5 — Content-Type enforcement: POST/PUT/PATCH with a non-empty body
# under /api/ must send application/json. Multipart/form-data endpoints
# (file-drop, canary blob upload) are exempt by their Content-Type prefix.
_MULTIPART_EXEMPT_PATHS: frozenset[str] = frozenset({
"/api/v1/deckies/files",
"/api/v1/canary/blobs",
})
_JSON_ENFORCE_METHODS: frozenset[str] = frozenset({"POST", "PUT", "PATCH"})
class _ContentTypeMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> StarletteResponse:
if (
request.method in _JSON_ENFORCE_METHODS
and request.url.path.startswith("/api/")
):
# Allow through if the path belongs to a multipart-exempt route.
path = request.url.path
exempt = any(path.startswith(p) for p in _MULTIPART_EXEMPT_PATHS)
if not exempt:
ct = request.headers.get("content-type", "")
# Only enforce when a body is actually present (non-zero Content-Length
# or chunked transfer), so empty-body POST health-checks stay clean.
cl = request.headers.get("content-length", "")
te = request.headers.get("transfer-encoding", "")
has_body = (cl not in ("", "0")) or ("chunked" in te.lower())
if has_body and not ct.lower().startswith("application/json"):
return StarletteResponse(
content="Unsupported Media Type — Content-Type must be application/json",
status_code=415,
media_type="text/plain",
)
return await call_next(request)
app.add_middleware(_ContentTypeMiddleware)
if DECNET_PROFILE_REQUESTS:
import time
from pathlib import Path

View File

@@ -50,12 +50,14 @@ def build_mysql_url(
if password is None:
password = os.environ.get("DECNET_DB_PASSWORD") or ""
# Allow empty passwords during tests (pytest sets PYTEST_* env vars).
# Outside tests, an empty MySQL password is almost never intentional.
if not password and not any(k.startswith("PYTEST") for k in os.environ):
# Allow empty passwords during tests, gated on the explicit, non-attacker-
# injectable DECNET_TESTING=1 flag (set by the test harness) rather than
# the attacker-controllable PYTEST* namespace (V2.1.7). Outside tests, an
# empty MySQL password is almost never intentional.
if not password and os.environ.get("DECNET_TESTING") != "1":
raise ValueError(
"DECNET_DB_PASSWORD is not set. Either export it, set DECNET_DB_URL, "
"or run under pytest for an empty-password default."
"or run under the test harness (DECNET_TESTING=1) for an empty-password default."
)
pw_enc = quote_plus(password)

View File

@@ -139,7 +139,7 @@ async def stream_events(
except asyncio.CancelledError:
pass
except Exception:
log.exception("SSE stream error for user %s", last_event_id)
log.exception("SSE stream error for user %s", user["uuid"])
yield f"event: error\ndata: {orjson.dumps({'type': 'error', 'message': 'Stream interrupted'}).decode()}\n\n"
return StreamingResponse(