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:
@@ -79,9 +79,12 @@ def _configure_logging(dev: bool) -> None:
|
|||||||
stream_handler.setFormatter(fmt)
|
stream_handler.setFormatter(fmt)
|
||||||
root.addHandler(stream_handler)
|
root.addHandler(stream_handler)
|
||||||
|
|
||||||
# Skip the file handler during pytest runs to avoid polluting the test cwd.
|
# Skip the file handler during test runs to avoid polluting the test cwd.
|
||||||
_in_pytest = any(k.startswith("PYTEST") for k in os.environ)
|
# Gated on the explicit, non-attacker-injectable DECNET_TESTING=1 flag
|
||||||
if not _in_pytest:
|
# (set by the test harness) rather than the attacker-controllable PYTEST*
|
||||||
|
# namespace (V2.1.7).
|
||||||
|
_in_test = os.environ.get("DECNET_TESTING") == "1"
|
||||||
|
if not _in_test:
|
||||||
_log_path = os.environ.get("DECNET_SYSTEM_LOGS", "decnet.system.log")
|
_log_path = os.environ.get("DECNET_SYSTEM_LOGS", "decnet.system.log")
|
||||||
# Never let file-handler attach failure kill the process. The
|
# Never let file-handler attach failure kill the process. The
|
||||||
# stream handler above is already installed, so losing the file
|
# stream handler above is already installed, so losing the file
|
||||||
|
|||||||
@@ -58,8 +58,10 @@ def _require_env(name: str) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
if value.lower() in _KNOWN_BAD:
|
if value.lower() in _KNOWN_BAD:
|
||||||
|
# Do NOT echo the rejected value — that leaks the secret into logs /
|
||||||
|
# stderr / crash reporters (V7.1.3). Name the variable, not its value.
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Environment variable '{name}' is set to an insecure default ('{value}'). "
|
f"Environment variable '{name}' is set to a known-insecure default. "
|
||||||
f"Choose a strong, unique value before starting DECNET."
|
f"Choose a strong, unique value before starting DECNET."
|
||||||
)
|
)
|
||||||
_developer = os.environ.get("DECNET_DEVELOPER", "False").lower() == "true"
|
_developer = os.environ.get("DECNET_DEVELOPER", "False").lower() == "true"
|
||||||
@@ -275,10 +277,14 @@ def validate_public_binding() -> None:
|
|||||||
slip past unmentioned on a public binding.
|
slip past unmentioned on a public binding.
|
||||||
|
|
||||||
Called from the FastAPI lifespan so it surfaces at startup, not on
|
Called from the FastAPI lifespan so it surfaces at startup, not on
|
||||||
first request. Skipped automatically when running under pytest so
|
first request. Skipped automatically under the explicit, non-attacker-
|
||||||
the test suite doesn't have to set five env vars per fixture.
|
injectable DECNET_TESTING=1 flag (set by the test harness) so the test
|
||||||
|
suite doesn't have to set five env vars per fixture. The old "any PYTEST*
|
||||||
|
var present" check was fail-open: PYTEST* is an attacker-controllable
|
||||||
|
namespace, so leaking one into a prod environment silently disabled the
|
||||||
|
public-binding guard. Fail closed (V2.1.7).
|
||||||
"""
|
"""
|
||||||
if any(k.startswith("PYTEST") for k in os.environ):
|
if os.environ.get("DECNET_TESTING") == "1":
|
||||||
return
|
return
|
||||||
if DECNET_API_HOST in _LOOPBACK_HOSTS:
|
if DECNET_API_HOST in _LOOPBACK_HOSTS:
|
||||||
return # not exposed; nothing to validate
|
return # not exposed; nothing to validate
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ def build_worker_ssl_context(agent_dir: pathlib.Path) -> ssl.SSLContext:
|
|||||||
f"no worker bundle at {agent_dir} — enroll from the master first"
|
f"no worker bundle at {agent_dir} — enroll from the master first"
|
||||||
)
|
)
|
||||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
# Pin an explicit TLS 1.2 floor so it can't silently regress if a future
|
||||||
|
# runtime lowers the implicit default (ASVS V9.1.4).
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||||
ctx.load_cert_chain(
|
ctx.load_cert_chain(
|
||||||
certfile=str(agent_dir / "worker.crt"),
|
certfile=str(agent_dir / "worker.crt"),
|
||||||
keyfile=str(agent_dir / "worker.key"),
|
keyfile=str(agent_dir / "worker.key"),
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ def build_listener_ssl_context(ca_dir: pathlib.Path) -> ssl.SSLContext:
|
|||||||
f"master identity missing at {master_dir} — call ensure_master_identity first"
|
f"master identity missing at {master_dir} — call ensure_master_identity first"
|
||||||
)
|
)
|
||||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
# Pin an explicit TLS 1.2 floor so it can't silently regress if a future
|
||||||
|
# runtime lowers the implicit default (ASVS V9.1.4).
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||||
ctx.load_cert_chain(certfile=str(cert), keyfile=str(key))
|
ctx.load_cert_chain(certfile=str(cert), keyfile=str(key))
|
||||||
ctx.load_verify_locations(cafile=str(ca_cert))
|
ctx.load_verify_locations(cafile=str(ca_cert))
|
||||||
ctx.verify_mode = ssl.CERT_REQUIRED
|
ctx.verify_mode = ssl.CERT_REQUIRED
|
||||||
|
|||||||
@@ -165,8 +165,8 @@ async def update(
|
|||||||
try:
|
try:
|
||||||
return _exec.run_update(
|
return _exec.run_update(
|
||||||
body, sha=sha or None,
|
body, sha=sha or None,
|
||||||
|
expected_sha256=sha256,
|
||||||
install_dir=_Config.install_dir, agent_dir=_Config.agent_dir,
|
install_dir=_Config.install_dir, agent_dir=_Config.agent_dir,
|
||||||
expected_sha256=sha256 or None,
|
|
||||||
)
|
)
|
||||||
except _exec.UpdateError as exc:
|
except _exec.UpdateError as exc:
|
||||||
status = 409 if exc.rolled_back else 500
|
status = 409 if exc.rolled_back else 500
|
||||||
@@ -196,7 +196,7 @@ async def update_self(
|
|||||||
return _exec.run_update_self(
|
return _exec.run_update_self(
|
||||||
body, sha=sha or None,
|
body, sha=sha or None,
|
||||||
updater_install_dir=_Config.updater_install_dir,
|
updater_install_dir=_Config.updater_install_dir,
|
||||||
expected_sha256=sha256 or None,
|
expected_sha256=sha256,
|
||||||
)
|
)
|
||||||
except _exec.UpdateError as exc:
|
except _exec.UpdateError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -576,19 +576,22 @@ def _point_current_at(install_dir: pathlib.Path, target: pathlib.Path) -> None:
|
|||||||
os.replace(tmp, link)
|
os.replace(tmp, link)
|
||||||
|
|
||||||
|
|
||||||
def _verify_tarball_sha256(tarball_bytes: bytes, expected_sha256: Optional[str]) -> None:
|
def _verify_tarball_sha256(tarball_bytes: bytes, expected_sha256: str) -> None:
|
||||||
"""Refuse to extract a tarball whose SHA-256 disagrees with the operator-supplied digest.
|
"""Refuse to extract a tarball whose SHA-256 disagrees with the operator-supplied digest.
|
||||||
|
|
||||||
mTLS already authenticates the master, so a network MITM can't forge
|
mTLS already authenticates the master, so a network MITM can't forge
|
||||||
bytes. This check exists for two narrower cases: catching corruption
|
bytes. This check is mandatory defense-in-depth: it catches corruption
|
||||||
in transit (proxies, broken disks) before we explode a half-decoded
|
in transit (proxies, broken disks) before we explode a half-decoded
|
||||||
archive into the staging tree, and giving the operator a way to pin
|
archive into the staging tree, and pins "exactly these bytes" when
|
||||||
"exactly these bytes" when distributing a vetted release. The form
|
distributing a vetted release. It runs BEFORE extraction or any
|
||||||
field is optional — if the caller doesn't send one, we skip the
|
pip-install. There is no skip path: a missing/empty digest is a
|
||||||
check rather than reject (no breaking change for older masters).
|
fail-closed REJECT — every caller (UpdaterClient) computes and sends
|
||||||
|
the digest, so an absent one means an untrusted/malformed request.
|
||||||
"""
|
"""
|
||||||
if not expected_sha256:
|
if not expected_sha256 or not expected_sha256.strip():
|
||||||
return
|
raise UpdateError(
|
||||||
|
"tarball sha256 digest is required but was missing or empty — refusing to extract"
|
||||||
|
)
|
||||||
expected = expected_sha256.strip().lower()
|
expected = expected_sha256.strip().lower()
|
||||||
if len(expected) != 64 or any(c not in "0123456789abcdef" for c in expected):
|
if len(expected) != 64 or any(c not in "0123456789abcdef" for c in expected):
|
||||||
raise UpdateError(f"sha256 digest is not a 64-char hex string: {expected_sha256!r}")
|
raise UpdateError(f"sha256 digest is not a 64-char hex string: {expected_sha256!r}")
|
||||||
@@ -602,9 +605,9 @@ def _verify_tarball_sha256(tarball_bytes: bytes, expected_sha256: Optional[str])
|
|||||||
def run_update(
|
def run_update(
|
||||||
tarball_bytes: bytes,
|
tarball_bytes: bytes,
|
||||||
sha: Optional[str],
|
sha: Optional[str],
|
||||||
|
expected_sha256: str,
|
||||||
install_dir: pathlib.Path = DEFAULT_INSTALL_DIR,
|
install_dir: pathlib.Path = DEFAULT_INSTALL_DIR,
|
||||||
agent_dir: pathlib.Path = pki.DEFAULT_AGENT_DIR,
|
agent_dir: pathlib.Path = pki.DEFAULT_AGENT_DIR,
|
||||||
expected_sha256: Optional[str] = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Apply an update atomically. Rolls back on probe failure."""
|
"""Apply an update atomically. Rolls back on probe failure."""
|
||||||
log.info("update received sha=%s bytes=%d install_dir=%s", sha, len(tarball_bytes), install_dir)
|
log.info("update received sha=%s bytes=%d install_dir=%s", sha, len(tarball_bytes), install_dir)
|
||||||
@@ -699,8 +702,8 @@ def run_update_self(
|
|||||||
tarball_bytes: bytes,
|
tarball_bytes: bytes,
|
||||||
sha: Optional[str],
|
sha: Optional[str],
|
||||||
updater_install_dir: pathlib.Path,
|
updater_install_dir: pathlib.Path,
|
||||||
|
expected_sha256: str,
|
||||||
exec_cb: Optional[Callable[[list[str]], None]] = None,
|
exec_cb: Optional[Callable[[list[str]], None]] = None,
|
||||||
expected_sha256: Optional[str] = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Replace the updater's own source tree, then re-exec this process.
|
"""Replace the updater's own source tree, then re-exec this process.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
from fastapi.responses import ORJSONResponse, Response
|
from fastapi.responses import ORJSONResponse, Response
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import Response as StarletteResponse
|
||||||
|
|
||||||
from decnet.env import (
|
from decnet.env import (
|
||||||
DECNET_CORS_ORIGINS,
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
global ingestion_task, collector_task, attacker_task, sniffer_task
|
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.
|
# Raises ValueError with an actionable message; uvicorn surfaces it.
|
||||||
validate_public_binding()
|
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
|
# 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
|
# commands when DECNET_MODE=agent, but a misconfigured systemd unit or
|
||||||
# a direct `python -m uvicorn decnet.web.api:app` call would bypass that.
|
# 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"],
|
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:
|
if DECNET_PROFILE_REQUESTS:
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|||||||
@@ -50,12 +50,14 @@ def build_mysql_url(
|
|||||||
if password is None:
|
if password is None:
|
||||||
password = os.environ.get("DECNET_DB_PASSWORD") or ""
|
password = os.environ.get("DECNET_DB_PASSWORD") or ""
|
||||||
|
|
||||||
# Allow empty passwords during tests (pytest sets PYTEST_* env vars).
|
# Allow empty passwords during tests, gated on the explicit, non-attacker-
|
||||||
# Outside tests, an empty MySQL password is almost never intentional.
|
# injectable DECNET_TESTING=1 flag (set by the test harness) rather than
|
||||||
if not password and not any(k.startswith("PYTEST") for k in os.environ):
|
# 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(
|
raise ValueError(
|
||||||
"DECNET_DB_PASSWORD is not set. Either export it, set DECNET_DB_URL, "
|
"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)
|
pw_enc = quote_plus(password)
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ async def stream_events(
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
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"
|
yield f"event: error\ndata: {orjson.dumps({'type': 'error', 'message': 'Stream interrupted'}).decode()}\n\n"
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
204
tests/api/test_security_middleware.py
Normal file
204
tests/api/test_security_middleware.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
"""
|
||||||
|
Security-middleware tests covering:
|
||||||
|
- V13.1.4: CORS wildcard guard raises ValueError at app startup
|
||||||
|
- V13.1.5: Content-Type enforcement middleware (415 on wrong CT; pass for
|
||||||
|
application/json; multipart exempt paths; GET/DELETE unaffected)
|
||||||
|
- BUG-17: SSE stream error log uses user["uuid"], not last_event_id
|
||||||
|
- Regression: multipart upload endpoints still work (canary blob, file-drop)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# V13.1.4 — CORS wildcard guard (unit tests; lifespan path in tests/web/)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCORSWildcardGuard:
|
||||||
|
def test_wildcard_raises(self):
|
||||||
|
"""_check_cors_origins raises ValueError when '*' is present."""
|
||||||
|
from decnet.web.api import _check_cors_origins
|
||||||
|
with pytest.raises(ValueError, match="wildcard"):
|
||||||
|
_check_cors_origins(["*"])
|
||||||
|
|
||||||
|
def test_wildcard_among_explicit_origins_raises(self):
|
||||||
|
"""Wildcard in a mixed list is still rejected."""
|
||||||
|
from decnet.web.api import _check_cors_origins
|
||||||
|
with pytest.raises(ValueError, match="wildcard"):
|
||||||
|
_check_cors_origins(["https://example.com", "*"])
|
||||||
|
|
||||||
|
def test_explicit_origins_ok(self):
|
||||||
|
"""Explicit origin URLs pass without raising."""
|
||||||
|
from decnet.web.api import _check_cors_origins
|
||||||
|
_check_cors_origins(["https://example.com", "https://app.internal"])
|
||||||
|
|
||||||
|
def test_empty_origins_ok(self):
|
||||||
|
"""Empty list is valid (no CORS)."""
|
||||||
|
from decnet.web.api import _check_cors_origins
|
||||||
|
_check_cors_origins([])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# V13.1.5 — Content-Type enforcement middleware
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestContentTypeMiddleware:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_wrong_content_type_returns_415(
|
||||||
|
self, client: httpx.AsyncClient
|
||||||
|
):
|
||||||
|
"""POST with text/plain body to a JSON endpoint returns 415.
|
||||||
|
|
||||||
|
/api/v1/auth/login is the most stable JSON POST target — no auth
|
||||||
|
required, always present, middleware fires before the handler.
|
||||||
|
"""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
content=b"not json",
|
||||||
|
headers={"Content-Type": "text/plain"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 415
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_application_json_passes_middleware(
|
||||||
|
self, client: httpx.AsyncClient
|
||||||
|
):
|
||||||
|
"""POST with application/json does NOT get a 415 from middleware."""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "nobody", "password": "wrong"},
|
||||||
|
)
|
||||||
|
# Middleware passes; handler may 401/422 but must not 415.
|
||||||
|
assert resp.status_code != 415
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_json_with_charset_passes(
|
||||||
|
self, client: httpx.AsyncClient
|
||||||
|
):
|
||||||
|
"""application/json; charset=utf-8 is a valid Content-Type."""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
content=b'{"username":"x","password":"y"}',
|
||||||
|
headers={"Content-Type": "application/json; charset=utf-8"},
|
||||||
|
)
|
||||||
|
assert resp.status_code != 415
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_not_enforced(self, client: httpx.AsyncClient, auth_token: str):
|
||||||
|
"""GET requests are never rejected by the CT middleware."""
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/logs",
|
||||||
|
headers={"Authorization": f"Bearer {auth_token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code != 415
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_not_enforced(
|
||||||
|
self, client: httpx.AsyncClient, auth_token: str
|
||||||
|
):
|
||||||
|
"""DELETE requests are never rejected by the CT middleware."""
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/v1/deckies/nonexistent",
|
||||||
|
headers={"Authorization": f"Bearer {auth_token}"},
|
||||||
|
)
|
||||||
|
# Could be 404/401/403 but never 415.
|
||||||
|
assert resp.status_code != 415
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multipart_canary_blob_exempt(
|
||||||
|
self, client: httpx.AsyncClient, auth_token: str
|
||||||
|
):
|
||||||
|
"""Canary blob upload (multipart/form-data) is NOT rejected with 415."""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/canary/blobs",
|
||||||
|
files={"file": ("test.txt", b"hello world", "text/plain")},
|
||||||
|
headers={"Authorization": f"Bearer {auth_token}"},
|
||||||
|
)
|
||||||
|
# 201 on success, 4xx on business-logic errors — never 415.
|
||||||
|
assert resp.status_code != 415
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multipart_file_drop_exempt(
|
||||||
|
self, client: httpx.AsyncClient, auth_token: str
|
||||||
|
):
|
||||||
|
"""Decky file-drop (multipart/form-data) is NOT rejected with 415."""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/deckies/files/some-container",
|
||||||
|
files={"file": ("test.txt", b"data", "text/plain")},
|
||||||
|
headers={"Authorization": f"Bearer {auth_token}"},
|
||||||
|
)
|
||||||
|
# Expect 4xx business error (no real container), never 415.
|
||||||
|
assert resp.status_code != 415
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_body_post_not_enforced(
|
||||||
|
self, client: httpx.AsyncClient, auth_token: str
|
||||||
|
):
|
||||||
|
"""POST with genuinely empty body (Content-Length: 0) is not rejected."""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/logs",
|
||||||
|
content=b"",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {auth_token}",
|
||||||
|
"Content-Length": "0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Middleware should not 415 on empty bodies.
|
||||||
|
assert resp.status_code != 415
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# BUG-17 — SSE error log uses user["uuid"], not last_event_id
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSSEErrorLog:
|
||||||
|
def test_sse_error_log_uses_user_uuid(self):
|
||||||
|
"""
|
||||||
|
Verify the log.exception call in the SSE generator uses user["uuid"],
|
||||||
|
not last_event_id (which is an int cursor, not an identity).
|
||||||
|
"""
|
||||||
|
import ast, pathlib
|
||||||
|
src = pathlib.Path(
|
||||||
|
"decnet/web/router/stream/api_stream_events.py"
|
||||||
|
).read_text()
|
||||||
|
tree = ast.parse(src)
|
||||||
|
|
||||||
|
bad_pattern_found = False
|
||||||
|
correct_pattern_found = False
|
||||||
|
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
continue
|
||||||
|
# Look for log.exception(...) calls
|
||||||
|
func = node.func
|
||||||
|
if not (isinstance(func, ast.Attribute) and func.attr == "exception"):
|
||||||
|
continue
|
||||||
|
# Check args after the format string
|
||||||
|
if len(node.args) >= 2:
|
||||||
|
arg = node.args[1]
|
||||||
|
# Bad pattern: bare Name "last_event_id"
|
||||||
|
if isinstance(arg, ast.Name) and arg.id == "last_event_id":
|
||||||
|
bad_pattern_found = True
|
||||||
|
# Good pattern: user["uuid"] subscript
|
||||||
|
if (
|
||||||
|
isinstance(arg, ast.Subscript)
|
||||||
|
and isinstance(arg.value, ast.Name)
|
||||||
|
and arg.value.id == "user"
|
||||||
|
):
|
||||||
|
correct_pattern_found = True
|
||||||
|
|
||||||
|
assert not bad_pattern_found, (
|
||||||
|
"BUG-17: log.exception still uses last_event_id instead of user['uuid']"
|
||||||
|
)
|
||||||
|
assert correct_pattern_found, (
|
||||||
|
"BUG-17 fix not found: expected log.exception(..., user['uuid']) in SSE handler"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sse_stream_unauthenticated_401(self, client: httpx.AsyncClient):
|
||||||
|
"""SSE endpoint rejects unauthenticated requests (regression guard)."""
|
||||||
|
resp = await client.get("/api/v1/stream")
|
||||||
|
assert resp.status_code == 401
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
DECNET_ADMIN_PASSWORD must never silently default to "admin": it is resolved
|
DECNET_ADMIN_PASSWORD must never silently default to "admin": it is resolved
|
||||||
lazily and validated like DECNET_JWT_SECRET. These tests drive _require_env
|
lazily and validated like DECNET_JWT_SECRET. These tests drive _require_env
|
||||||
against a controlled environ so the production raise paths (which are bypassed
|
against a controlled environ so the production raise paths (which are bypassed
|
||||||
under live pytest) are actually exercised.
|
under the test harness via DECNET_TESTING=1) are actually exercised.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -14,8 +14,8 @@ import decnet.env as envmod
|
|||||||
|
|
||||||
|
|
||||||
def _require(monkeypatch: pytest.MonkeyPatch, environ: dict[str, str]) -> str:
|
def _require(monkeypatch: pytest.MonkeyPatch, environ: dict[str, str]) -> str:
|
||||||
# Replace the whole environ for the call so the PYTEST_* short-circuit in
|
# Replace the whole environ for the call so the DECNET_TESTING short-circuit
|
||||||
# _require_env doesn't fire — we want the real production behaviour.
|
# in _require_env doesn't fire — we want the real production behaviour.
|
||||||
monkeypatch.setattr(envmod.os, "environ", dict(environ))
|
monkeypatch.setattr(envmod.os, "environ", dict(environ))
|
||||||
return envmod._require_env("DECNET_ADMIN_PASSWORD")
|
return envmod._require_env("DECNET_ADMIN_PASSWORD")
|
||||||
|
|
||||||
@@ -36,6 +36,20 @@ def test_admin_password_other_known_bad_raises(monkeypatch: pytest.MonkeyPatch,
|
|||||||
_require(monkeypatch, {"DECNET_ADMIN_PASSWORD": bad})
|
_require(monkeypatch, {"DECNET_ADMIN_PASSWORD": bad})
|
||||||
|
|
||||||
|
|
||||||
|
def test_known_bad_message_does_not_leak_secret_value(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
# V7.1.3: the known-bad rejection must NOT echo the rejected secret value
|
||||||
|
# (it would land in logs / stderr / crash reporters). Name the variable,
|
||||||
|
# not its value.
|
||||||
|
secret = "admin"
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
_require(monkeypatch, {"DECNET_ADMIN_PASSWORD": secret})
|
||||||
|
msg = str(exc.value)
|
||||||
|
assert secret not in msg
|
||||||
|
assert "DECNET_ADMIN_PASSWORD" in msg
|
||||||
|
|
||||||
|
|
||||||
def test_admin_password_too_short_raises_in_production(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_admin_password_too_short_raises_in_production(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
with pytest.raises(ValueError, match="too short"):
|
with pytest.raises(ValueError, match="too short"):
|
||||||
_require(monkeypatch, {"DECNET_ADMIN_PASSWORD": "short1"})
|
_require(monkeypatch, {"DECNET_ADMIN_PASSWORD": "short1"})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ def test_build_url_defaults(monkeypatch):
|
|||||||
for v in ("DECNET_DB_HOST", "DECNET_DB_PORT", "DECNET_DB_NAME",
|
for v in ("DECNET_DB_HOST", "DECNET_DB_PORT", "DECNET_DB_NAME",
|
||||||
"DECNET_DB_USER", "DECNET_DB_PASSWORD", "DECNET_DB_URL"):
|
"DECNET_DB_USER", "DECNET_DB_PASSWORD", "DECNET_DB_URL"):
|
||||||
monkeypatch.delenv(v, raising=False)
|
monkeypatch.delenv(v, raising=False)
|
||||||
# PYTEST_* is set by pytest itself, so empty password is allowed here.
|
# DECNET_TESTING=1 is set by conftest, so empty password is allowed here.
|
||||||
url = build_mysql_url()
|
url = build_mysql_url()
|
||||||
assert url == "mysql+asyncmy://decnet:@localhost:3306/decnet"
|
assert url == "mysql+asyncmy://decnet:@localhost:3306/decnet"
|
||||||
|
|
||||||
@@ -66,14 +66,13 @@ def test_resolve_url_falls_back_to_components(monkeypatch):
|
|||||||
assert url.startswith("mysql+asyncmy://")
|
assert url.startswith("mysql+asyncmy://")
|
||||||
|
|
||||||
|
|
||||||
def test_build_url_requires_password_outside_pytest(monkeypatch):
|
def test_build_url_requires_password_outside_tests(monkeypatch):
|
||||||
"""Without a password and not in a pytest run, construction must fail loudly."""
|
"""Without a password and not under the test harness, construction must fail loudly."""
|
||||||
for v in ("DECNET_DB_URL", "DECNET_DB_PASSWORD"):
|
for v in ("DECNET_DB_URL", "DECNET_DB_PASSWORD"):
|
||||||
monkeypatch.delenv(v, raising=False)
|
monkeypatch.delenv(v, raising=False)
|
||||||
# Strip every PYTEST_* env var so the safety check trips.
|
# Clear the test-harness flag so the safety check trips. A leaked PYTEST_*
|
||||||
import os
|
# var must NOT re-enable the bypass (V2.1.7).
|
||||||
for k in list(os.environ):
|
monkeypatch.delenv("DECNET_TESTING", raising=False)
|
||||||
if k.startswith("PYTEST"):
|
monkeypatch.setenv("PYTEST_CURRENT_TEST", "x")
|
||||||
monkeypatch.delenv(k, raising=False)
|
|
||||||
with pytest.raises(ValueError, match="DECNET_DB_PASSWORD is not set"):
|
with pytest.raises(ValueError, match="DECNET_DB_PASSWORD is not set"):
|
||||||
build_mysql_url()
|
build_mysql_url()
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ async def _wait_for(pred, timeout: float = 5.0, interval: float = 0.1) -> bool:
|
|||||||
# ----------------------------------------------------------- pure helpers
|
# ----------------------------------------------------------- pure helpers
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_ssl_context_pins_tls12_floor(_pki_env: dict) -> None:
|
||||||
|
"""V9.1.4: forwarder client context must set an explicit TLS 1.2 floor."""
|
||||||
|
ctx = fwd.build_worker_ssl_context(_pki_env["worker_dir"])
|
||||||
|
assert ctx.minimum_version == ssl.TLSVersion.TLSv1_2
|
||||||
|
|
||||||
|
|
||||||
|
def test_listener_ssl_context_pins_tls12_floor(_pki_env: dict) -> None:
|
||||||
|
"""V9.1.4: listener server context must set an explicit TLS 1.2 floor."""
|
||||||
|
ctx = lst.build_listener_ssl_context(_pki_env["ca_dir"])
|
||||||
|
assert ctx.minimum_version == ssl.TLSVersion.TLSv1_2
|
||||||
|
|
||||||
|
|
||||||
def test_peer_cn_returns_unknown_when_no_ssl_object() -> None:
|
def test_peer_cn_returns_unknown_when_no_ssl_object() -> None:
|
||||||
assert lst.peer_cn(None) == "unknown"
|
assert lst.peer_cn(None) == "unknown"
|
||||||
|
|
||||||
|
|||||||
@@ -54,10 +54,13 @@ def test_health_returns_role_and_releases(client: TestClient, monkeypatch: pytes
|
|||||||
|
|
||||||
|
|
||||||
def test_update_happy_path(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_update_happy_path(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(
|
seen: dict[str, object] = {}
|
||||||
ex, "run_update",
|
|
||||||
lambda data, sha, install_dir, agent_dir, expected_sha256=None: {"status": "updated", "release": {"slot": "active", "sha": sha}, "probe": "ok"},
|
def _run_update(data, sha, expected_sha256, install_dir, agent_dir):
|
||||||
)
|
seen["expected_sha256"] = expected_sha256
|
||||||
|
return {"status": "updated", "release": {"slot": "active", "sha": sha}, "probe": "ok"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(ex, "run_update", _run_update)
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/update",
|
"/update",
|
||||||
files={"tarball": ("tree.tgz", _tarball(), "application/gzip")},
|
files={"tarball": ("tree.tgz", _tarball(), "application/gzip")},
|
||||||
@@ -65,6 +68,8 @@ def test_update_happy_path(client: TestClient, monkeypatch: pytest.MonkeyPatch)
|
|||||||
)
|
)
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert r.json()["release"]["sha"] == "ABC123"
|
assert r.json()["release"]["sha"] == "ABC123"
|
||||||
|
# Route forwards the digest verbatim — executor verifies it before extract.
|
||||||
|
assert seen["expected_sha256"] == "0" * 64
|
||||||
|
|
||||||
|
|
||||||
def test_update_rollback_returns_409(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_update_rollback_returns_409(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
@@ -104,10 +109,13 @@ def test_update_self_requires_confirm(client: TestClient) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_update_self_happy_path(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_update_self_happy_path(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(
|
seen: dict[str, object] = {}
|
||||||
ex, "run_update_self",
|
|
||||||
lambda data, sha, updater_install_dir, expected_sha256=None: {"status": "self_update_queued", "argv": ["python", "-m", "decnet", "updater"]},
|
def _run_update_self(data, sha, updater_install_dir, expected_sha256):
|
||||||
)
|
seen["expected_sha256"] = expected_sha256
|
||||||
|
return {"status": "self_update_queued", "argv": ["python", "-m", "decnet", "updater"]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(ex, "run_update_self", _run_update_self)
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/update-self",
|
"/update-self",
|
||||||
files={"tarball": ("t.tgz", _tarball(), "application/gzip")},
|
files={"tarball": ("t.tgz", _tarball(), "application/gzip")},
|
||||||
@@ -115,6 +123,7 @@ def test_update_self_happy_path(client: TestClient, monkeypatch: pytest.MonkeyPa
|
|||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["status"] == "self_update_queued"
|
assert r.json()["status"] == "self_update_queued"
|
||||||
|
assert seen["expected_sha256"] == "0" * 64
|
||||||
|
|
||||||
|
|
||||||
def test_rollback_happy(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_rollback_happy(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
@@ -158,6 +167,47 @@ def test_update_without_sha256_is_rejected(client: TestClient) -> None:
|
|||||||
assert "sha256" in r.json()["detail"]
|
assert "sha256" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_with_empty_sha256_is_rejected(client: TestClient) -> None:
|
||||||
|
# An explicit empty form value is treated the same as absent → 400.
|
||||||
|
r = client.post(
|
||||||
|
"/update",
|
||||||
|
files={"tarball": ("t.tgz", _tarball(), "application/gzip")},
|
||||||
|
data={"sha": "ABC", "sha256": ""},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "sha256" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_self_without_sha256_is_rejected(client: TestClient) -> None:
|
||||||
|
r = client.post(
|
||||||
|
"/update-self",
|
||||||
|
files={"tarball": ("t.tgz", _tarball(), "application/gzip")},
|
||||||
|
data={"confirm_self": "true"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "sha256" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_mismatched_sha256_is_rejected_before_apply(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""End-to-end through the REAL executor verify: a non-matching digest is a
|
||||||
|
500 UpdateError and no extraction/pip happens (extract/_run_pip would be
|
||||||
|
reached only AFTER the digest check, so we assert they are never called)."""
|
||||||
|
called: list[str] = []
|
||||||
|
monkeypatch.setattr(ex, "extract_tarball", lambda *a, **k: called.append("extract"))
|
||||||
|
monkeypatch.setattr(ex, "_run_pip", lambda *a, **k: called.append("pip"))
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/update",
|
||||||
|
files={"tarball": ("t.tgz", _tarball(), "application/gzip")},
|
||||||
|
data={"sha": "ABC", "sha256": "0" * 64}, # wrong digest for this tarball
|
||||||
|
)
|
||||||
|
assert r.status_code == 500, r.text
|
||||||
|
assert "mismatch" in r.json()["detail"]["error"]
|
||||||
|
assert called == [] # rejected before any extract/install
|
||||||
|
|
||||||
|
|
||||||
# ------------------------- master-cert gate ---------------------------------
|
# ------------------------- master-cert gate ---------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ against a ``tmp_path`` install dir.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import pathlib
|
import pathlib
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -32,6 +33,11 @@ def _make_tarball(files: dict[str, str]) -> bytes:
|
|||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(tarball: bytes) -> str:
|
||||||
|
"""SHA-256 hex of the tarball — now mandatory on run_update/run_update_self."""
|
||||||
|
return hashlib.sha256(tarball).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
class _PipOK:
|
class _PipOK:
|
||||||
returncode = 0
|
returncode = 0
|
||||||
stdout = ""
|
stdout = ""
|
||||||
@@ -207,6 +213,40 @@ def test_run_update_rejects_malformed_sha256(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("missing", ["", " ", None])
|
||||||
|
def test_run_update_rejects_missing_sha256_fail_closed(
|
||||||
|
install_dir: pathlib.Path, agent_dir: pathlib.Path, missing: Any,
|
||||||
|
) -> None:
|
||||||
|
"""V12.1.2 fail-closed: an absent/empty digest is rejected BEFORE any
|
||||||
|
extraction or pip-install. No staging tree is produced."""
|
||||||
|
tb = _make_tarball({"x.txt": "y"})
|
||||||
|
with pytest.raises(ex.UpdateError, match="required but was missing or empty"):
|
||||||
|
ex.run_update(
|
||||||
|
tb, sha="S", expected_sha256=missing, # type: ignore[arg-type]
|
||||||
|
install_dir=install_dir, agent_dir=agent_dir,
|
||||||
|
)
|
||||||
|
assert not (install_dir / "releases" / "active.new").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("missing", ["", " ", None])
|
||||||
|
def test_run_update_self_rejects_missing_sha256_fail_closed(
|
||||||
|
install_dir: pathlib.Path, missing: Any,
|
||||||
|
) -> None:
|
||||||
|
active = install_dir / "releases" / "active"
|
||||||
|
active.mkdir()
|
||||||
|
(active / "marker").write_text("old-updater")
|
||||||
|
tb = _make_tarball({"marker": "new-updater"})
|
||||||
|
with pytest.raises(ex.UpdateError, match="required but was missing or empty"):
|
||||||
|
ex.run_update_self(
|
||||||
|
tb, sha="U", updater_install_dir=install_dir,
|
||||||
|
expected_sha256=missing, # type: ignore[arg-type]
|
||||||
|
exec_cb=lambda a: None,
|
||||||
|
)
|
||||||
|
# Active untouched, nothing staged.
|
||||||
|
assert (install_dir / "releases" / "active" / "marker").read_text() == "old-updater"
|
||||||
|
assert not (install_dir / "releases" / "active.new").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_clean_stale_staging(install_dir: pathlib.Path) -> None:
|
def test_clean_stale_staging(install_dir: pathlib.Path) -> None:
|
||||||
staging = install_dir / "releases" / "active.new"
|
staging = install_dir / "releases" / "active.new"
|
||||||
staging.mkdir()
|
staging.mkdir()
|
||||||
@@ -229,7 +269,7 @@ def test_update_rotates_and_probes(
|
|||||||
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
|
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
|
||||||
|
|
||||||
tb = _make_tarball({"marker.txt": "new"})
|
tb = _make_tarball({"marker.txt": "new"})
|
||||||
result = ex.run_update(tb, sha="NEWSHA", install_dir=install_dir, agent_dir=agent_dir)
|
result = ex.run_update(tb, sha="NEWSHA", expected_sha256=_digest(tb), install_dir=install_dir, agent_dir=agent_dir)
|
||||||
|
|
||||||
assert result["status"] == "updated"
|
assert result["status"] == "updated"
|
||||||
assert result["release"]["sha"] == "NEWSHA"
|
assert result["release"]["sha"] == "NEWSHA"
|
||||||
@@ -252,7 +292,7 @@ def test_update_first_install_without_previous(
|
|||||||
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
|
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
|
||||||
|
|
||||||
tb = _make_tarball({"marker.txt": "first"})
|
tb = _make_tarball({"marker.txt": "first"})
|
||||||
result = ex.run_update(tb, sha="S1", install_dir=install_dir, agent_dir=agent_dir)
|
result = ex.run_update(tb, sha="S1", expected_sha256=_digest(tb), install_dir=install_dir, agent_dir=agent_dir)
|
||||||
assert result["status"] == "updated"
|
assert result["status"] == "updated"
|
||||||
assert not (install_dir / "releases" / "prev").exists()
|
assert not (install_dir / "releases" / "prev").exists()
|
||||||
|
|
||||||
@@ -273,7 +313,7 @@ def test_update_pip_failure_aborts_before_rotation(
|
|||||||
|
|
||||||
tb = _make_tarball({"marker.txt": "new"})
|
tb = _make_tarball({"marker.txt": "new"})
|
||||||
with pytest.raises(ex.UpdateError, match="pip install failed") as ei:
|
with pytest.raises(ex.UpdateError, match="pip install failed") as ei:
|
||||||
ex.run_update(tb, sha="S", install_dir=install_dir, agent_dir=agent_dir)
|
ex.run_update(tb, sha="S", expected_sha256=_digest(tb), install_dir=install_dir, agent_dir=agent_dir)
|
||||||
assert "resolver error" in ei.value.stderr
|
assert "resolver error" in ei.value.stderr
|
||||||
|
|
||||||
# Nothing rotated — old active still live, no prev created.
|
# Nothing rotated — old active still live, no prev created.
|
||||||
@@ -309,7 +349,7 @@ def test_update_probe_failure_rolls_back(
|
|||||||
|
|
||||||
tb = _make_tarball({"marker.txt": "new"})
|
tb = _make_tarball({"marker.txt": "new"})
|
||||||
with pytest.raises(ex.UpdateError, match="health probe") as ei:
|
with pytest.raises(ex.UpdateError, match="health probe") as ei:
|
||||||
ex.run_update(tb, sha="NEWSHA", install_dir=install_dir, agent_dir=agent_dir)
|
ex.run_update(tb, sha="NEWSHA", expected_sha256=_digest(tb), install_dir=install_dir, agent_dir=agent_dir)
|
||||||
assert ei.value.rolled_back is True
|
assert ei.value.rolled_back is True
|
||||||
assert "connection refused" in ei.value.stderr
|
assert "connection refused" in ei.value.stderr
|
||||||
|
|
||||||
@@ -381,6 +421,7 @@ def test_update_self_rotates_and_calls_exec_cb(
|
|||||||
tb = _make_tarball({"marker": "new-updater"})
|
tb = _make_tarball({"marker": "new-updater"})
|
||||||
result = ex.run_update_self(
|
result = ex.run_update_self(
|
||||||
tb, sha="USHA", updater_install_dir=install_dir,
|
tb, sha="USHA", updater_install_dir=install_dir,
|
||||||
|
expected_sha256=_digest(tb),
|
||||||
exec_cb=lambda argv: seen_argv.append(argv),
|
exec_cb=lambda argv: seen_argv.append(argv),
|
||||||
)
|
)
|
||||||
assert result["status"] == "self_update_queued"
|
assert result["status"] == "self_update_queued"
|
||||||
@@ -412,7 +453,7 @@ def test_update_self_under_systemd_defers_to_systemctl(
|
|||||||
monkeypatch.setattr(ex.os, "execv", lambda *a, **k: pytest.fail("execv taken under systemd"))
|
monkeypatch.setattr(ex.os, "execv", lambda *a, **k: pytest.fail("execv taken under systemd"))
|
||||||
|
|
||||||
tb = _make_tarball({"marker": "new-updater"})
|
tb = _make_tarball({"marker": "new-updater"})
|
||||||
result = ex.run_update_self(tb, sha="USHA", updater_install_dir=install_dir)
|
result = ex.run_update_self(tb, sha="USHA", updater_install_dir=install_dir, expected_sha256=_digest(tb))
|
||||||
assert result == {"status": "self_update_queued", "via": "systemd"}
|
assert result == {"status": "self_update_queued", "via": "systemd"}
|
||||||
assert len(popen_calls) == 1
|
assert len(popen_calls) == 1
|
||||||
sh_cmd = popen_calls[0]
|
sh_cmd = popen_calls[0]
|
||||||
@@ -431,7 +472,7 @@ def test_update_self_pip_failure_leaves_active_intact(
|
|||||||
|
|
||||||
tb = _make_tarball({"marker": "new-updater"})
|
tb = _make_tarball({"marker": "new-updater"})
|
||||||
with pytest.raises(ex.UpdateError, match="pip install failed"):
|
with pytest.raises(ex.UpdateError, match="pip install failed"):
|
||||||
ex.run_update_self(tb, sha="U", updater_install_dir=install_dir, exec_cb=lambda a: None)
|
ex.run_update_self(tb, sha="U", updater_install_dir=install_dir, expected_sha256=_digest(tb), exec_cb=lambda a: None)
|
||||||
assert (install_dir / "releases" / "active" / "marker").read_text() == "old-updater"
|
assert (install_dir / "releases" / "active" / "marker").read_text() == "old-updater"
|
||||||
assert not (install_dir / "releases" / "active.new").exists()
|
assert not (install_dir / "releases" / "active.new").exists()
|
||||||
|
|
||||||
|
|||||||
@@ -23,13 +23,6 @@ def _reload_api(monkeypatch: pytest.MonkeyPatch):
|
|||||||
return importlib.import_module("decnet.web.api")
|
return importlib.import_module("decnet.web.api")
|
||||||
|
|
||||||
|
|
||||||
def _strip_pytest_vars(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
import os
|
|
||||||
for k in list(os.environ):
|
|
||||||
if k.startswith("PYTEST"):
|
|
||||||
monkeypatch.delenv(k, raising=False)
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_lifespan_startup(api_mod) -> None:
|
async def _run_lifespan_startup(api_mod) -> None:
|
||||||
"""Run the lifespan up to (but not past) yield, then unwind cleanly.
|
"""Run the lifespan up to (but not past) yield, then unwind cleanly.
|
||||||
|
|
||||||
@@ -59,6 +52,7 @@ def test_master_api_refuses_to_start_in_agent_mode(
|
|||||||
monkeypatch.setenv("DECNET_MODE", "agent")
|
monkeypatch.setenv("DECNET_MODE", "agent")
|
||||||
monkeypatch.setenv("DECNET_DISALLOW_MASTER", "true")
|
monkeypatch.setenv("DECNET_DISALLOW_MASTER", "true")
|
||||||
monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32)
|
monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32)
|
||||||
|
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
||||||
api = _reload_api(monkeypatch)
|
api = _reload_api(monkeypatch)
|
||||||
with pytest.raises(RuntimeError, match="master-only"):
|
with pytest.raises(RuntimeError, match="master-only"):
|
||||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||||
@@ -74,6 +68,7 @@ def test_master_api_starts_when_dual_role_enabled(
|
|||||||
monkeypatch.setenv("DECNET_MODE", "agent")
|
monkeypatch.setenv("DECNET_MODE", "agent")
|
||||||
monkeypatch.setenv("DECNET_DISALLOW_MASTER", "false")
|
monkeypatch.setenv("DECNET_DISALLOW_MASTER", "false")
|
||||||
monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32)
|
monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32)
|
||||||
|
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
||||||
api = _reload_api(monkeypatch)
|
api = _reload_api(monkeypatch)
|
||||||
# Reaching the DB init phase means the gate passed; we don't need to
|
# Reaching the DB init phase means the gate passed; we don't need to
|
||||||
# actually finish startup. Cancel via a synthetic exception that the
|
# actually finish startup. Cancel via a synthetic exception that the
|
||||||
@@ -107,7 +102,7 @@ def test_master_api_eager_loads_jwt_secret_at_startup(
|
|||||||
monkeypatch.setenv("DECNET_MODE", "master")
|
monkeypatch.setenv("DECNET_MODE", "master")
|
||||||
monkeypatch.setenv("DECNET_JWT_SECRET", "y" * 32)
|
monkeypatch.setenv("DECNET_JWT_SECRET", "y" * 32)
|
||||||
monkeypatch.setenv("DECNET_API_HOST", "127.0.0.1")
|
monkeypatch.setenv("DECNET_API_HOST", "127.0.0.1")
|
||||||
monkeypatch.delenv("DECNET_CORS_ORIGINS", raising=False)
|
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
||||||
api = _reload_api(monkeypatch)
|
api = _reload_api(monkeypatch)
|
||||||
import decnet.env as env_mod
|
import decnet.env as env_mod
|
||||||
|
|
||||||
@@ -130,3 +125,45 @@ def test_master_api_eager_loads_jwt_secret_at_startup(
|
|||||||
assert "DECNET_JWT_SECRET" in seen, (
|
assert "DECNET_JWT_SECRET" in seen, (
|
||||||
"lifespan must access env.DECNET_JWT_SECRET at startup"
|
"lifespan must access env.DECNET_JWT_SECRET at startup"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# V13.1.4 — CORS wildcard guard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_cors_wildcard_guard_function_raises() -> None:
|
||||||
|
"""_check_cors_origins raises ValueError when '*' is in the list."""
|
||||||
|
from decnet.web.api import _check_cors_origins
|
||||||
|
with pytest.raises(ValueError, match="wildcard"):
|
||||||
|
_check_cors_origins(["*"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_wildcard_among_explicit_origins_raises() -> None:
|
||||||
|
"""Wildcard in a mixed list is still rejected."""
|
||||||
|
from decnet.web.api import _check_cors_origins
|
||||||
|
with pytest.raises(ValueError, match="wildcard"):
|
||||||
|
_check_cors_origins(["https://example.com", "*"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_explicit_origins_pass() -> None:
|
||||||
|
"""Explicit origin URLs pass the guard without raising."""
|
||||||
|
from decnet.web.api import _check_cors_origins
|
||||||
|
_check_cors_origins(["https://example.com", "https://app.internal"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_wildcard_raises_in_lifespan(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Lifespan raises ValueError when DECNET_CORS_ORIGINS contains '*'.
|
||||||
|
|
||||||
|
Uses _reload_api to pick up the patched env; tests the full guard
|
||||||
|
path including the lifespan call to _check_cors_origins.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("DECNET_MODE", "master")
|
||||||
|
monkeypatch.setenv("DECNET_JWT_SECRET", "z" * 32)
|
||||||
|
monkeypatch.setenv("DECNET_CORS_ORIGINS", "*")
|
||||||
|
api = _reload_api(monkeypatch)
|
||||||
|
with pytest.raises(ValueError, match="wildcard"):
|
||||||
|
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||||
|
_run_lifespan_startup(api)
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
"""validate_public_binding refuses footgun configs at master startup.
|
"""validate_public_binding refuses footgun configs at master startup.
|
||||||
|
|
||||||
The validator no-ops under pytest by design (so unit tests in unrelated
|
The validator no-ops under the test harness (DECNET_TESTING=1) by design (so
|
||||||
modules don't have to set five env vars per fixture); these tests strip
|
unit tests in unrelated modules don't have to set five env vars per fixture);
|
||||||
the PYTEST_* vars before calling it so the real code path runs.
|
these tests clear that flag before calling it so the real code path runs.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -20,18 +20,17 @@ def _reimport_env(monkeypatch: pytest.MonkeyPatch):
|
|||||||
return importlib.import_module("decnet.env")
|
return importlib.import_module("decnet.env")
|
||||||
|
|
||||||
|
|
||||||
def _strip_pytest_vars(monkeypatch: pytest.MonkeyPatch) -> None:
|
def _strip_test_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
import os
|
# The validator short-circuits on DECNET_TESTING=1 (set globally by
|
||||||
for k in list(os.environ):
|
# conftest). Clear it so the real production code path runs.
|
||||||
if k.startswith("PYTEST"):
|
monkeypatch.delenv("DECNET_TESTING", raising=False)
|
||||||
monkeypatch.delenv(k, raising=False)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validator_noop_on_loopback_binding(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_validator_noop_on_loopback_binding(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setenv("DECNET_API_HOST", "127.0.0.1")
|
monkeypatch.setenv("DECNET_API_HOST", "127.0.0.1")
|
||||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
||||||
env = _reimport_env(monkeypatch)
|
env = _reimport_env(monkeypatch)
|
||||||
_strip_pytest_vars(monkeypatch)
|
_strip_test_flag(monkeypatch)
|
||||||
env.validate_public_binding() # no raise
|
env.validate_public_binding() # no raise
|
||||||
|
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ def test_validator_rejects_loopback_cors_on_public_bind(
|
|||||||
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
|
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
|
||||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
||||||
env = _reimport_env(monkeypatch)
|
env = _reimport_env(monkeypatch)
|
||||||
_strip_pytest_vars(monkeypatch)
|
_strip_test_flag(monkeypatch)
|
||||||
with pytest.raises(ValueError, match="loopback origin"):
|
with pytest.raises(ValueError, match="loopback origin"):
|
||||||
env.validate_public_binding()
|
env.validate_public_binding()
|
||||||
|
|
||||||
@@ -52,7 +51,7 @@ def test_validator_accepts_public_cors_on_public_bind(
|
|||||||
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
|
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
|
||||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "https://dashboard.example.com")
|
monkeypatch.setenv("DECNET_CORS_ORIGINS", "https://dashboard.example.com")
|
||||||
env = _reimport_env(monkeypatch)
|
env = _reimport_env(monkeypatch)
|
||||||
_strip_pytest_vars(monkeypatch)
|
_strip_test_flag(monkeypatch)
|
||||||
env.validate_public_binding() # no raise
|
env.validate_public_binding() # no raise
|
||||||
|
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ def test_validator_rejects_plaintext_canary_on_public_bind(
|
|||||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "https://dashboard.example.com")
|
monkeypatch.setenv("DECNET_CORS_ORIGINS", "https://dashboard.example.com")
|
||||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "http://canary.example.com:8088")
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "http://canary.example.com:8088")
|
||||||
env = _reimport_env(monkeypatch)
|
env = _reimport_env(monkeypatch)
|
||||||
_strip_pytest_vars(monkeypatch)
|
_strip_test_flag(monkeypatch)
|
||||||
with pytest.raises(ValueError, match="plaintext HTTP"):
|
with pytest.raises(ValueError, match="plaintext HTTP"):
|
||||||
env.validate_public_binding()
|
env.validate_public_binding()
|
||||||
|
|
||||||
@@ -77,14 +76,28 @@ def test_validator_allows_loopback_canary_even_on_public_bind(
|
|||||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "https://dashboard.example.com")
|
monkeypatch.setenv("DECNET_CORS_ORIGINS", "https://dashboard.example.com")
|
||||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "http://localhost:8088")
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "http://localhost:8088")
|
||||||
env = _reimport_env(monkeypatch)
|
env = _reimport_env(monkeypatch)
|
||||||
_strip_pytest_vars(monkeypatch)
|
_strip_test_flag(monkeypatch)
|
||||||
env.validate_public_binding() # no raise
|
env.validate_public_binding() # no raise
|
||||||
|
|
||||||
|
|
||||||
def test_validator_skips_under_pytest(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_validator_skips_under_test_harness(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
# With PYTEST_* still in env (default), even a misconfigured env passes —
|
# With DECNET_TESTING=1 still in env (set by conftest), even a misconfigured
|
||||||
# this is the deliberate bypass so unrelated tests don't trip on it.
|
# env passes — this is the deliberate bypass so unrelated tests don't trip.
|
||||||
|
monkeypatch.setenv("DECNET_TESTING", "1")
|
||||||
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
|
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
|
||||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
||||||
env = _reimport_env(monkeypatch)
|
env = _reimport_env(monkeypatch)
|
||||||
env.validate_public_binding() # no raise — guard short-circuits
|
env.validate_public_binding() # no raise — guard short-circuits
|
||||||
|
|
||||||
|
|
||||||
|
def test_validator_pytest_var_leak_does_not_bypass(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# V2.1.7 regression: a leaked PYTEST_* env var must NOT disable the guard.
|
||||||
|
# With DECNET_TESTING cleared, a misconfigured public binding still raises
|
||||||
|
# even though a PYTEST_* var is present.
|
||||||
|
monkeypatch.delenv("DECNET_TESTING", raising=False)
|
||||||
|
monkeypatch.setenv("PYTEST_CURRENT_TEST", "x")
|
||||||
|
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
|
||||||
|
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
||||||
|
env = _reimport_env(monkeypatch)
|
||||||
|
with pytest.raises(ValueError, match="loopback origin"):
|
||||||
|
env.validate_public_binding()
|
||||||
|
|||||||
Reference in New Issue
Block a user