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

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

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

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

View File

@@ -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)
)