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