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