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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user