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)
|
||||
root.addHandler(stream_handler)
|
||||
|
||||
# Skip the file handler during pytest runs to avoid polluting the test cwd.
|
||||
_in_pytest = any(k.startswith("PYTEST") for k in os.environ)
|
||||
if not _in_pytest:
|
||||
# Skip the file handler during test runs to avoid polluting the test cwd.
|
||||
# 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).
|
||||
_in_test = os.environ.get("DECNET_TESTING") == "1"
|
||||
if not _in_test:
|
||||
_log_path = os.environ.get("DECNET_SYSTEM_LOGS", "decnet.system.log")
|
||||
# Never let file-handler attach failure kill the process. The
|
||||
# stream handler above is already installed, so losing the file
|
||||
|
||||
@@ -58,8 +58,10 @@ def _require_env(name: str) -> str:
|
||||
return value
|
||||
|
||||
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(
|
||||
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."
|
||||
)
|
||||
_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.
|
||||
|
||||
Called from the FastAPI lifespan so it surfaces at startup, not on
|
||||
first request. Skipped automatically when running under pytest so
|
||||
the test suite doesn't have to set five env vars per fixture.
|
||||
first request. Skipped automatically under the explicit, non-attacker-
|
||||
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
|
||||
if DECNET_API_HOST in _LOOPBACK_HOSTS:
|
||||
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"
|
||||
)
|
||||
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(
|
||||
certfile=str(agent_dir / "worker.crt"),
|
||||
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"
|
||||
)
|
||||
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_verify_locations(cafile=str(ca_cert))
|
||||
ctx.verify_mode = ssl.CERT_REQUIRED
|
||||
|
||||
@@ -165,8 +165,8 @@ async def update(
|
||||
try:
|
||||
return _exec.run_update(
|
||||
body, sha=sha or None,
|
||||
expected_sha256=sha256,
|
||||
install_dir=_Config.install_dir, agent_dir=_Config.agent_dir,
|
||||
expected_sha256=sha256 or None,
|
||||
)
|
||||
except _exec.UpdateError as exc:
|
||||
status = 409 if exc.rolled_back else 500
|
||||
@@ -196,7 +196,7 @@ async def update_self(
|
||||
return _exec.run_update_self(
|
||||
body, sha=sha or None,
|
||||
updater_install_dir=_Config.updater_install_dir,
|
||||
expected_sha256=sha256 or None,
|
||||
expected_sha256=sha256,
|
||||
)
|
||||
except _exec.UpdateError as exc:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -576,19 +576,22 @@ def _point_current_at(install_dir: pathlib.Path, target: pathlib.Path) -> None:
|
||||
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.
|
||||
|
||||
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
|
||||
archive into the staging tree, and giving the operator a way to pin
|
||||
"exactly these bytes" when distributing a vetted release. The form
|
||||
field is optional — if the caller doesn't send one, we skip the
|
||||
check rather than reject (no breaking change for older masters).
|
||||
archive into the staging tree, and pins "exactly these bytes" when
|
||||
distributing a vetted release. It runs BEFORE extraction or any
|
||||
pip-install. There is no skip path: a missing/empty digest is a
|
||||
fail-closed REJECT — every caller (UpdaterClient) computes and sends
|
||||
the digest, so an absent one means an untrusted/malformed request.
|
||||
"""
|
||||
if not expected_sha256:
|
||||
return
|
||||
if not expected_sha256 or not expected_sha256.strip():
|
||||
raise UpdateError(
|
||||
"tarball sha256 digest is required but was missing or empty — refusing to extract"
|
||||
)
|
||||
expected = expected_sha256.strip().lower()
|
||||
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}")
|
||||
@@ -602,9 +605,9 @@ def _verify_tarball_sha256(tarball_bytes: bytes, expected_sha256: Optional[str])
|
||||
def run_update(
|
||||
tarball_bytes: bytes,
|
||||
sha: Optional[str],
|
||||
expected_sha256: str,
|
||||
install_dir: pathlib.Path = DEFAULT_INSTALL_DIR,
|
||||
agent_dir: pathlib.Path = pki.DEFAULT_AGENT_DIR,
|
||||
expected_sha256: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""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)
|
||||
@@ -699,8 +702,8 @@ def run_update_self(
|
||||
tarball_bytes: bytes,
|
||||
sha: Optional[str],
|
||||
updater_install_dir: pathlib.Path,
|
||||
expected_sha256: str,
|
||||
exec_cb: Optional[Callable[[list[str]], None]] = None,
|
||||
expected_sha256: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""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 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
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
|
||||
lazily and validated like DECNET_JWT_SECRET. These tests drive _require_env
|
||||
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
|
||||
|
||||
@@ -14,8 +14,8 @@ import decnet.env as envmod
|
||||
|
||||
|
||||
def _require(monkeypatch: pytest.MonkeyPatch, environ: dict[str, str]) -> str:
|
||||
# Replace the whole environ for the call so the PYTEST_* short-circuit in
|
||||
# _require_env doesn't fire — we want the real production behaviour.
|
||||
# Replace the whole environ for the call so the DECNET_TESTING short-circuit
|
||||
# in _require_env doesn't fire — we want the real production behaviour.
|
||||
monkeypatch.setattr(envmod.os, "environ", dict(environ))
|
||||
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})
|
||||
|
||||
|
||||
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:
|
||||
with pytest.raises(ValueError, match="too short"):
|
||||
_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",
|
||||
"DECNET_DB_USER", "DECNET_DB_PASSWORD", "DECNET_DB_URL"):
|
||||
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()
|
||||
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://")
|
||||
|
||||
|
||||
def test_build_url_requires_password_outside_pytest(monkeypatch):
|
||||
"""Without a password and not in a pytest run, construction must fail loudly."""
|
||||
def test_build_url_requires_password_outside_tests(monkeypatch):
|
||||
"""Without a password and not under the test harness, construction must fail loudly."""
|
||||
for v in ("DECNET_DB_URL", "DECNET_DB_PASSWORD"):
|
||||
monkeypatch.delenv(v, raising=False)
|
||||
# Strip every PYTEST_* env var so the safety check trips.
|
||||
import os
|
||||
for k in list(os.environ):
|
||||
if k.startswith("PYTEST"):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
# Clear the test-harness flag so the safety check trips. A leaked PYTEST_*
|
||||
# var must NOT re-enable the bypass (V2.1.7).
|
||||
monkeypatch.delenv("DECNET_TESTING", raising=False)
|
||||
monkeypatch.setenv("PYTEST_CURRENT_TEST", "x")
|
||||
with pytest.raises(ValueError, match="DECNET_DB_PASSWORD is not set"):
|
||||
build_mysql_url()
|
||||
|
||||
@@ -69,6 +69,18 @@ async def _wait_for(pred, timeout: float = 5.0, interval: float = 0.1) -> bool:
|
||||
# ----------------------------------------------------------- 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:
|
||||
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:
|
||||
monkeypatch.setattr(
|
||||
ex, "run_update",
|
||||
lambda data, sha, install_dir, agent_dir, expected_sha256=None: {"status": "updated", "release": {"slot": "active", "sha": sha}, "probe": "ok"},
|
||||
)
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
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(
|
||||
"/update",
|
||||
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.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:
|
||||
@@ -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:
|
||||
monkeypatch.setattr(
|
||||
ex, "run_update_self",
|
||||
lambda data, sha, updater_install_dir, expected_sha256=None: {"status": "self_update_queued", "argv": ["python", "-m", "decnet", "updater"]},
|
||||
)
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
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(
|
||||
"/update-self",
|
||||
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.json()["status"] == "self_update_queued"
|
||||
assert seen["expected_sha256"] == "0" * 64
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
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 ---------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ against a ``tmp_path`` install dir.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import pathlib
|
||||
import subprocess
|
||||
@@ -32,6 +33,11 @@ def _make_tarball(files: dict[str, str]) -> bytes:
|
||||
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:
|
||||
returncode = 0
|
||||
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:
|
||||
staging = install_dir / "releases" / "active.new"
|
||||
staging.mkdir()
|
||||
@@ -229,7 +269,7 @@ def test_update_rotates_and_probes(
|
||||
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
|
||||
|
||||
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["release"]["sha"] == "NEWSHA"
|
||||
@@ -252,7 +292,7 @@ def test_update_first_install_without_previous(
|
||||
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
|
||||
|
||||
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 not (install_dir / "releases" / "prev").exists()
|
||||
|
||||
@@ -273,7 +313,7 @@ def test_update_pip_failure_aborts_before_rotation(
|
||||
|
||||
tb = _make_tarball({"marker.txt": "new"})
|
||||
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
|
||||
|
||||
# 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"})
|
||||
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 "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"})
|
||||
result = ex.run_update_self(
|
||||
tb, sha="USHA", updater_install_dir=install_dir,
|
||||
expected_sha256=_digest(tb),
|
||||
exec_cb=lambda argv: seen_argv.append(argv),
|
||||
)
|
||||
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"))
|
||||
|
||||
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 len(popen_calls) == 1
|
||||
sh_cmd = popen_calls[0]
|
||||
@@ -431,7 +472,7 @@ def test_update_self_pip_failure_leaves_active_intact(
|
||||
|
||||
tb = _make_tarball({"marker": "new-updater"})
|
||||
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 not (install_dir / "releases" / "active.new").exists()
|
||||
|
||||
|
||||
@@ -23,13 +23,6 @@ def _reload_api(monkeypatch: pytest.MonkeyPatch):
|
||||
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:
|
||||
"""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_DISALLOW_MASTER", "true")
|
||||
monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32)
|
||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
||||
api = _reload_api(monkeypatch)
|
||||
with pytest.raises(RuntimeError, match="master-only"):
|
||||
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_DISALLOW_MASTER", "false")
|
||||
monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32)
|
||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
||||
api = _reload_api(monkeypatch)
|
||||
# Reaching the DB init phase means the gate passed; we don't need to
|
||||
# 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_JWT_SECRET", "y" * 32)
|
||||
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)
|
||||
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, (
|
||||
"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
|
||||
"""validate_public_binding refuses footgun configs at master startup.
|
||||
|
||||
The validator no-ops under pytest by design (so unit tests in unrelated
|
||||
modules don't have to set five env vars per fixture); these tests strip
|
||||
the PYTEST_* vars before calling it so the real code path runs.
|
||||
The validator no-ops under the test harness (DECNET_TESTING=1) by design (so
|
||||
unit tests in unrelated modules don't have to set five env vars per fixture);
|
||||
these tests clear that flag before calling it so the real code path runs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,18 +20,17 @@ def _reimport_env(monkeypatch: pytest.MonkeyPatch):
|
||||
return importlib.import_module("decnet.env")
|
||||
|
||||
|
||||
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)
|
||||
def _strip_test_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# The validator short-circuits on DECNET_TESTING=1 (set globally by
|
||||
# conftest). Clear it so the real production code path runs.
|
||||
monkeypatch.delenv("DECNET_TESTING", raising=False)
|
||||
|
||||
|
||||
def test_validator_noop_on_loopback_binding(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DECNET_API_HOST", "127.0.0.1")
|
||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
||||
env = _reimport_env(monkeypatch)
|
||||
_strip_pytest_vars(monkeypatch)
|
||||
_strip_test_flag(monkeypatch)
|
||||
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_CORS_ORIGINS", "http://localhost:8080")
|
||||
env = _reimport_env(monkeypatch)
|
||||
_strip_pytest_vars(monkeypatch)
|
||||
_strip_test_flag(monkeypatch)
|
||||
with pytest.raises(ValueError, match="loopback origin"):
|
||||
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_CORS_ORIGINS", "https://dashboard.example.com")
|
||||
env = _reimport_env(monkeypatch)
|
||||
_strip_pytest_vars(monkeypatch)
|
||||
_strip_test_flag(monkeypatch)
|
||||
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_CANARY_HTTP_BASE", "http://canary.example.com:8088")
|
||||
env = _reimport_env(monkeypatch)
|
||||
_strip_pytest_vars(monkeypatch)
|
||||
_strip_test_flag(monkeypatch)
|
||||
with pytest.raises(ValueError, match="plaintext HTTP"):
|
||||
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_CANARY_HTTP_BASE", "http://localhost:8088")
|
||||
env = _reimport_env(monkeypatch)
|
||||
_strip_pytest_vars(monkeypatch)
|
||||
_strip_test_flag(monkeypatch)
|
||||
env.validate_public_binding() # no raise
|
||||
|
||||
|
||||
def test_validator_skips_under_pytest(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# With PYTEST_* still in env (default), even a misconfigured env passes —
|
||||
# this is the deliberate bypass so unrelated tests don't trip on it.
|
||||
def test_validator_skips_under_test_harness(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# With DECNET_TESTING=1 still in env (set by conftest), even a misconfigured
|
||||
# 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_CORS_ORIGINS", "http://localhost:8080")
|
||||
env = _reimport_env(monkeypatch)
|
||||
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