fix(security): close LOW ASVS findings — env bypass, SSE/deployment authz, CN fail-close, password byte-limit, exception leaks, BUG-12..16
Auth/session (V2.1.7, V4.1.5, V4.1.6, V2.1.4/V2.1.5): - env secret validation no longer bypassed by attacker-injectable PYTEST* env; gated on explicit DECNET_TESTING=1 (set only in conftest). - must_change_password now enforced on the SSE header-JWT path, not just ticket mint. - GET /system/deployment-mode requires viewer auth (was leaking role + topology size). - CreateUser/ResetUser passwords min_length=12; passwords >72 bytes rejected explicitly instead of bcrypt silently truncating. Swarm ingestion (V9.1.3, BUG-16): - Log listener hard-rejects peers with unparseable/empty cert CN (fail closed, ingests nothing) instead of tagging 'unknown'. - Shutdown handlers no longer swallow real errors (narrowed to CancelledError). Info leakage (V7.1.2, V14.1.2): - Exception text sanitized on swarm-update, health, tarpit, realism, file-drop, blank-topology endpoints (raw tc/docker stderr, DB/Docker errors logged server-side, generic detail returned). pyproject license corrected to AGPL-3.0. Correctness (BUG-12..16): - BUG-12 atomic credential upsert (UNIQUE constraint + IntegrityError retry, consistent principal_key canonicalization). - BUG-13 rule-tail watermark uses >= with seen-id dedup (no same-second drop). - BUG-14 worker wake cleared before wait (no lost wake during tick). - BUG-15 intel gather tolerates an unexpected provider raise. - BUG-16 see above. Already-closed (verified, no change): V2.1.6, V5.1.3, V9.1.2. Accept-risk + documented: V2.1.8 cache window, V3.1.3 idle timeout. Tests added for every fix; unanimous adversarial review after two refute-fix rounds.
This commit is contained in:
206
tests/api/auth/test_password_policy.py
Normal file
206
tests/api/auth/test_password_policy.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""V2.1.4 + V2.1.5 password policy tests.
|
||||
|
||||
Covers:
|
||||
- min_length=12 enforced on CreateUserRequest and ResetUserPasswordRequest
|
||||
- bcrypt 72-byte limit: multi-byte passwords >72 bytes are rejected (not silently truncated)
|
||||
"""
|
||||
import pytest
|
||||
import httpx
|
||||
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
||||
|
||||
|
||||
# '€' (U+20AC) encodes to 3 UTF-8 bytes.
|
||||
# 25 × 3 = 75 bytes > 72 — valid char count, over the byte cap.
|
||||
_OVER_72_BYTES: str = "€" * 25
|
||||
|
||||
# Exactly at the limit: 24 × 3 = 72 bytes — must be ACCEPTED.
|
||||
_EXACTLY_72_BYTES: str = "€" * 24
|
||||
|
||||
|
||||
# ─── V2.1.4: min_length=12 on create ────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user_11char_password_rejected(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
"""11-character password must be rejected on user creation (min is 12)."""
|
||||
resp = await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "shortpwduser", "password": "short11char", "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
# Schema-guard middleware may surface as 400; FastAPI validation as 422.
|
||||
assert resp.status_code in (400, 422), (
|
||||
f"Expected 400/422 for 11-char password on create, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user_12char_password_accepted(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
"""Exactly 12-character ASCII password must be accepted."""
|
||||
resp = await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "minpwduser12", "password": "exactly12chr", "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200, (
|
||||
f"Expected 200 for 12-char password on create, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
|
||||
# ─── V2.1.4: min_length=12 on reset ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reset_password_11char_rejected(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
"""11-character new_password must be rejected on admin password reset."""
|
||||
# Create a user to reset
|
||||
create_resp = await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "resetpolicy1", "password": "securepass123", "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert create_resp.status_code == 200
|
||||
user_uuid = create_resp.json()["uuid"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/v1/config/users/{user_uuid}/reset-password",
|
||||
json={"new_password": "short11char"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code in (400, 422), (
|
||||
f"Expected 400/422 for 11-char password on reset, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reset_password_12char_accepted(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
"""Exactly 12-character password must be accepted on admin password reset."""
|
||||
create_resp = await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "resetpolicy2", "password": "securepass123", "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert create_resp.status_code == 200
|
||||
user_uuid = create_resp.json()["uuid"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/v1/config/users/{user_uuid}/reset-password",
|
||||
json={"new_password": "exactly12chr"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200, (
|
||||
f"Expected 200 for 12-char password on reset, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
|
||||
# ─── V2.1.5: bcrypt 72-byte rejection on create ─────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user_over_72_bytes_rejected(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
"""Password >72 UTF-8 bytes must be rejected on create (bcrypt truncation guard)."""
|
||||
resp = await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "bytepolicyusr", "password": _OVER_72_BYTES, "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code in (400, 422), (
|
||||
f"Expected 400/422 for >{72}-byte password on create, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user_exactly_72_bytes_accepted(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
"""Password of exactly 72 UTF-8 bytes must be accepted (at the limit, not over)."""
|
||||
resp = await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "byteedgeuser1", "password": _EXACTLY_72_BYTES, "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200, (
|
||||
f"Expected 200 for exactly-72-byte password on create, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
|
||||
# ─── V2.1.5: bcrypt 72-byte rejection on reset ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reset_password_over_72_bytes_rejected(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
"""new_password >72 UTF-8 bytes must be rejected on admin password reset."""
|
||||
create_resp = await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "byteresetuser", "password": "securepass123", "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert create_resp.status_code == 200
|
||||
user_uuid = create_resp.json()["uuid"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/v1/config/users/{user_uuid}/reset-password",
|
||||
json={"new_password": _OVER_72_BYTES},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code in (400, 422), (
|
||||
f"Expected 400/422 for >{72}-byte new_password on reset, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
# ─── V2.1.5: bcrypt 72-byte rejection on change-password ────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_change_password_new_over_72_bytes_rejected(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""new_password >72 UTF-8 bytes must be rejected on change-password."""
|
||||
login_resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD},
|
||||
)
|
||||
token = login_resp.json()["access_token"]
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"old_password": DECNET_ADMIN_PASSWORD, "new_password": _OVER_72_BYTES},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code in (400, 422), (
|
||||
f"Expected 400/422 for >{72}-byte new_password on change-password, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_change_password_old_over_72_bytes_rejected(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""old_password >72 UTF-8 bytes must be rejected (no point checking against hash)."""
|
||||
login_resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD},
|
||||
)
|
||||
token = login_resp.json()["access_token"]
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"old_password": _OVER_72_BYTES, "new_password": "new_secure_password"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code in (400, 422), (
|
||||
f"Expected 400/422 for >{72}-byte old_password on change-password, got {resp.status_code}"
|
||||
)
|
||||
@@ -80,6 +80,43 @@ async def test_sse_ticket_endpoint_mints_and_redeems(
|
||||
assert "uuid" in identity and identity["role"] in ("admin", "viewer")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sse_header_jwt_rejects_must_change_password(monkeypatch) -> None:
|
||||
"""V4.1.5: the header-JWT SSE branch must enforce must_change_password the
|
||||
same way require_role does. A user blocked from every REST endpoint must not
|
||||
be able to subscribe to live SSE streams with their existing token."""
|
||||
async def _fake_resolve(token: str):
|
||||
return "user-1", {"uuid": "user-1", "role": "viewer", "must_change_password": True}
|
||||
|
||||
monkeypatch.setattr(deps, "_resolve_token", _fake_resolve)
|
||||
|
||||
class _Req:
|
||||
headers = {"Authorization": "Bearer some.jwt.token"}
|
||||
|
||||
gate = deps.require_stream_role("viewer", "admin")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await gate(_Req(), ticket=None) # type: ignore[arg-type]
|
||||
assert exc.value.status_code == 403
|
||||
assert "Password change required" in exc.value.detail
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sse_header_jwt_allows_cleared_user(monkeypatch) -> None:
|
||||
"""Control: a user who has cleared must_change_password passes the header-JWT
|
||||
SSE gate (proves the new guard didn't break the happy path)."""
|
||||
async def _fake_resolve(token: str):
|
||||
return "user-1", {"uuid": "user-1", "role": "viewer", "must_change_password": False}
|
||||
|
||||
monkeypatch.setattr(deps, "_resolve_token", _fake_resolve)
|
||||
|
||||
class _Req:
|
||||
headers = {"Authorization": "Bearer some.jwt.token"}
|
||||
|
||||
gate = deps.require_stream_role("viewer", "admin")
|
||||
user = await gate(_Req(), ticket=None) # type: ignore[arg-type]
|
||||
assert user["uuid"] == "user-1"
|
||||
|
||||
|
||||
def test_raw_jwt_in_sse_query_rejected() -> None:
|
||||
"""V3.1.1: a raw JWT is not a valid opaque ticket — _redeem_sse_ticket rejects
|
||||
any token that wasn't minted by mint_sse_ticket (unknown key → 401)."""
|
||||
|
||||
275
tests/api/deckies/test_tarpit_leak.py
Normal file
275
tests/api/deckies/test_tarpit_leak.py
Normal file
@@ -0,0 +1,275 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""V7.1.2 regression: tarpit endpoints must NOT leak raw tc stderr to API callers.
|
||||
|
||||
Covers both fleet (deckies) and topology tarpit paths. Forces a tc failure
|
||||
by monkeypatching _apply_tarpit / _remove_tarpit to raise RuntimeError with
|
||||
a realistic iproute2/kernel error string (veth name, qdisc id, errno text),
|
||||
then asserts:
|
||||
|
||||
1. Status code is 409.
|
||||
2. The response body detail is a generic string.
|
||||
3. The response body contains NO raw tc output — no veth names, no
|
||||
"RTNETLINK", no kernel errno strings, no qdisc identifiers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
from decnet.web.router.deckies import api_tarpit as _deckies_tarpit
|
||||
from decnet.web.router.topology import api_tarpit as _topology_tarpit
|
||||
|
||||
_FLEET_URL = "/api/v1/deckies/web1/tarpit"
|
||||
_TOPO_URL = "/api/v1/topologies/topo1/deckies/web1/tarpit"
|
||||
|
||||
# Realistic iproute2 stderr fragments — must NOT appear in any API response.
|
||||
_TC_STDERR_FRAGMENTS = [
|
||||
"RTNETLINK",
|
||||
"veth",
|
||||
"qdisc",
|
||||
"Cannot find device",
|
||||
"No such file or directory",
|
||||
"Error: Exclusivity flag on, cannot modify.",
|
||||
"NLMSG_ERROR",
|
||||
"errno",
|
||||
]
|
||||
|
||||
# Realistic docker exec / runtime stderr fragments that must NOT leak via the
|
||||
# get_container_veth LookupError path (V7.1.2 — 404 path).
|
||||
_DOCKER_STDERR_FRAGMENTS = [
|
||||
"Error response from daemon",
|
||||
"No such container",
|
||||
"OCI runtime",
|
||||
"exec failed",
|
||||
"permission denied",
|
||||
]
|
||||
|
||||
_TARPIT_BODY = {"ports": [22, 80], "delay_ms": 500}
|
||||
|
||||
|
||||
def _hdr(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _assert_no_tc_leak(body: dict) -> None:
|
||||
"""Assert that the response detail contains no raw tc/kernel output."""
|
||||
detail = str(body.get("detail", ""))
|
||||
for fragment in _TC_STDERR_FRAGMENTS:
|
||||
assert fragment not in detail, (
|
||||
f"V7.1.2 leak: raw tc stderr fragment {fragment!r} found in API response: {detail!r}"
|
||||
)
|
||||
|
||||
|
||||
# ── fleet / deckies ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fleet_enable_tarpit_tc_failure_returns_generic_detail(
|
||||
client: httpx.AsyncClient,
|
||||
auth_token: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""POST fleet tarpit with forced tc failure yields 409 + generic detail."""
|
||||
|
||||
def _fake_apply(veth: str, ports: list[int], delay_ms: int) -> None:
|
||||
raise RuntimeError(
|
||||
"RTNETLINK answers: File exists\n"
|
||||
f"Error: Exclusivity flag on, cannot modify. veth={veth} qdisc=1:"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_deckies_tarpit, "_apply_tarpit", _fake_apply)
|
||||
|
||||
# Also patch get_container_veth so the 404 path doesn't fire first.
|
||||
monkeypatch.setattr(
|
||||
_deckies_tarpit,
|
||||
"get_container_veth",
|
||||
lambda name: f"veth-{name}-abc123",
|
||||
)
|
||||
|
||||
res = await client.post(_FLEET_URL, json=_TARPIT_BODY, headers=_hdr(auth_token))
|
||||
assert res.status_code == 409, res.text
|
||||
_assert_no_tc_leak(res.json())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fleet_disable_tarpit_tc_failure_returns_generic_detail(
|
||||
client: httpx.AsyncClient,
|
||||
auth_token: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""DELETE fleet tarpit with forced tc failure yields 409 + generic detail."""
|
||||
|
||||
def _fake_remove(veth: str) -> bool:
|
||||
raise RuntimeError(
|
||||
f"RTNETLINK answers: No such file or directory\nveth={veth}"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_deckies_tarpit, "_remove_tarpit", _fake_remove)
|
||||
monkeypatch.setattr(
|
||||
_deckies_tarpit,
|
||||
"get_container_veth",
|
||||
lambda name: f"veth-{name}-abc123",
|
||||
)
|
||||
|
||||
res = await client.delete(_FLEET_URL, headers=_hdr(auth_token))
|
||||
assert res.status_code == 409, res.text
|
||||
_assert_no_tc_leak(res.json())
|
||||
|
||||
|
||||
# ── topology ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topology_enable_tarpit_tc_failure_returns_generic_detail(
|
||||
client: httpx.AsyncClient,
|
||||
auth_token: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""POST topology tarpit with forced tc failure yields 409 + generic detail."""
|
||||
|
||||
def _fake_apply(veth: str, ports: list[int], delay_ms: int) -> None:
|
||||
raise RuntimeError(
|
||||
f"RTNETLINK answers: File exists\nveth={veth} qdisc=1: NLMSG_ERROR errno=17"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_topology_tarpit, "_apply_tarpit", _fake_apply)
|
||||
|
||||
async def _fake_resolve(repo, decky_name, *, topology_id):
|
||||
return f"decnet_t_{decky_name}"
|
||||
|
||||
monkeypatch.setattr(
|
||||
_topology_tarpit,
|
||||
"resolve_decky_container",
|
||||
_fake_resolve,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_topology_tarpit,
|
||||
"get_container_veth",
|
||||
lambda name: f"veth-{name}-abc123",
|
||||
)
|
||||
|
||||
res = await client.post(_TOPO_URL, json=_TARPIT_BODY, headers=_hdr(auth_token))
|
||||
assert res.status_code == 409, res.text
|
||||
_assert_no_tc_leak(res.json())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topology_disable_tarpit_tc_failure_returns_generic_detail(
|
||||
client: httpx.AsyncClient,
|
||||
auth_token: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""DELETE topology tarpit with forced tc failure yields 409 + generic detail."""
|
||||
|
||||
def _fake_remove(veth: str) -> bool:
|
||||
raise RuntimeError(
|
||||
f"RTNETLINK answers: No such file or directory\nveth={veth} errno=2"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_topology_tarpit, "_remove_tarpit", _fake_remove)
|
||||
|
||||
async def _fake_resolve(repo, decky_name, *, topology_id):
|
||||
return f"decnet_t_{decky_name}"
|
||||
|
||||
monkeypatch.setattr(
|
||||
_topology_tarpit,
|
||||
"resolve_decky_container",
|
||||
_fake_resolve,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_topology_tarpit,
|
||||
"get_container_veth",
|
||||
lambda name: f"veth-{name}-abc123",
|
||||
)
|
||||
|
||||
res = await client.delete(_TOPO_URL, headers=_hdr(auth_token))
|
||||
assert res.status_code == 409, res.text
|
||||
_assert_no_tc_leak(res.json())
|
||||
|
||||
|
||||
# ── veth LookupError 404 path (V7.1.2 — docker stderr must not leak) ────────
|
||||
|
||||
|
||||
def _assert_no_docker_stderr_leak(body: dict) -> None:
|
||||
"""Assert that the response detail contains no raw docker/runtime output."""
|
||||
detail = str(body.get("detail", ""))
|
||||
for fragment in _DOCKER_STDERR_FRAGMENTS:
|
||||
assert fragment not in detail, (
|
||||
f"V7.1.2 leak: raw docker stderr fragment {fragment!r} found in 404 detail: {detail!r}"
|
||||
)
|
||||
# The detail must NOT contain a colon-separated suffix (i.e. no ': <stderr>')
|
||||
# — generic message ends with 'not reachable', nothing after.
|
||||
assert "Error response from daemon" not in detail
|
||||
assert "OCI runtime" not in detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fleet_enable_tarpit_veth_failure_does_not_leak_stderr(
|
||||
client: httpx.AsyncClient,
|
||||
auth_token: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""POST fleet tarpit: veth LookupError 404 must not expose docker stderr."""
|
||||
|
||||
def _fake_veth(name: str) -> str:
|
||||
raise LookupError(f"container {name!r} not reachable")
|
||||
|
||||
monkeypatch.setattr(_deckies_tarpit, "get_container_veth", _fake_veth)
|
||||
|
||||
res = await client.post(_FLEET_URL, json=_TARPIT_BODY, headers=_hdr(auth_token))
|
||||
assert res.status_code == 404, res.text
|
||||
body = res.json()
|
||||
detail = str(body.get("detail", ""))
|
||||
# Generic message present, no docker runtime fragments
|
||||
assert "not reachable" in detail
|
||||
_assert_no_docker_stderr_leak(body)
|
||||
# Specifically assert the colon+stderr suffix is absent
|
||||
assert ":" not in detail.split("not reachable", 1)[-1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fleet_disable_tarpit_veth_failure_does_not_leak_stderr(
|
||||
client: httpx.AsyncClient,
|
||||
auth_token: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""DELETE fleet tarpit: veth LookupError 404 must not expose docker stderr."""
|
||||
|
||||
def _fake_veth(name: str) -> str:
|
||||
raise LookupError(f"container {name!r} not reachable")
|
||||
|
||||
monkeypatch.setattr(_deckies_tarpit, "get_container_veth", _fake_veth)
|
||||
|
||||
res = await client.delete(_FLEET_URL, headers=_hdr(auth_token))
|
||||
assert res.status_code == 404, res.text
|
||||
body = res.json()
|
||||
detail = str(body.get("detail", ""))
|
||||
assert "not reachable" in detail
|
||||
_assert_no_docker_stderr_leak(body)
|
||||
assert ":" not in detail.split("not reachable", 1)[-1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topology_enable_tarpit_veth_failure_does_not_leak_stderr(
|
||||
client: httpx.AsyncClient,
|
||||
auth_token: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""POST topology tarpit: veth LookupError 404 must not expose docker stderr."""
|
||||
|
||||
def _fake_veth(name: str) -> str:
|
||||
raise LookupError(f"container {name!r} not reachable")
|
||||
|
||||
async def _fake_resolve(repo, decky_name, *, topology_id):
|
||||
return f"decnet_t_{decky_name}"
|
||||
|
||||
monkeypatch.setattr(_topology_tarpit, "resolve_decky_container", _fake_resolve)
|
||||
monkeypatch.setattr(_topology_tarpit, "get_container_veth", _fake_veth)
|
||||
|
||||
res = await client.post(_TOPO_URL, json=_TARPIT_BODY, headers=_hdr(auth_token))
|
||||
assert res.status_code == 404, res.text
|
||||
body = res.json()
|
||||
detail = str(body.get("detail", ""))
|
||||
assert "not reachable" in detail
|
||||
_assert_no_docker_stderr_leak(body)
|
||||
assert ":" not in detail.split("not reachable", 1)[-1]
|
||||
@@ -190,3 +190,15 @@ async def test_deployment_mode_endpoint(client, auth_token, monkeypatch):
|
||||
assert body["role"] == "master"
|
||||
assert body["mode"] == "unihost"
|
||||
assert body["swarm_host_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deployment_mode_endpoint_requires_auth(client):
|
||||
# V4.1.6: the response leaks host role + enrolled-worker count, so the
|
||||
# endpoint must not be reachable without a valid viewer/admin JWT. The
|
||||
# dashboard only ever calls it from inside the post-login app shell.
|
||||
resp = await client.get("/api/v1/system/deployment-mode")
|
||||
assert resp.status_code == 401
|
||||
# Body must not leak the recon fields on the unauthenticated path.
|
||||
assert "role" not in resp.text
|
||||
assert "swarm_host_count" not in resp.text
|
||||
|
||||
@@ -5,14 +5,16 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.web.router.health.api_get_health import _reset_docker_cache
|
||||
from decnet.web.router.health.api_get_health import _reset_docker_cache, _reset_db_cache
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_docker_cache():
|
||||
def _clear_health_caches():
|
||||
_reset_docker_cache()
|
||||
_reset_db_cache()
|
||||
yield
|
||||
_reset_docker_cache()
|
||||
_reset_db_cache()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -144,7 +146,9 @@ async def test_health_docker_failing(client: httpx.AsyncClient, auth_token: str)
|
||||
|
||||
comp = resp.json()["components"]["docker"]
|
||||
assert comp["status"] == "failing"
|
||||
assert "connection refused" in comp["detail"]
|
||||
# Internal exception message must NOT be in the detail (V7.1.2 fix).
|
||||
assert "connection refused" not in comp["detail"]
|
||||
assert comp["detail"] == "docker daemon unavailable"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -161,7 +165,9 @@ async def test_health_database_failing(client: httpx.AsyncClient, auth_token: st
|
||||
|
||||
comp = resp.json()["components"]["database"]
|
||||
assert comp["status"] == "failing"
|
||||
assert "disk full" in comp["detail"]
|
||||
# Internal exception message must NOT be in the detail (V7.1.2 fix).
|
||||
assert "disk full" not in comp["detail"]
|
||||
assert comp["detail"] == "database unavailable"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -181,7 +187,50 @@ async def test_health_worker_exited_with_exception(client: httpx.AsyncClient, au
|
||||
|
||||
comp = resp.json()["components"]["collector_worker"]
|
||||
assert comp["status"] == "failing"
|
||||
assert "segfault" in comp["detail"]
|
||||
# Internal exception message must NOT be in the detail (V7.1.2 fix).
|
||||
assert "segfault" not in comp["detail"]
|
||||
assert comp["detail"] == "exited with error"
|
||||
|
||||
|
||||
# ── V7.1.2: no internal exception detail in response body ────────────────────
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_db_failure_does_not_leak_exception_class(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
) -> None:
|
||||
"""V7.1.2: DB exception class/message must not appear in the HTTP response."""
|
||||
from decnet.web.dependencies import repo as real_repo
|
||||
|
||||
with patch("decnet.web.api.get_background_tasks") as mock_tasks, \
|
||||
patch("docker.from_env") as mock_docker, \
|
||||
patch.object(
|
||||
real_repo, "get_total_logs",
|
||||
new=AsyncMock(side_effect=OSError("[Errno 28] No space left on device")),
|
||||
):
|
||||
_make_all_running(mock_tasks)
|
||||
mock_docker.return_value = MagicMock()
|
||||
resp = await client.get("/api/v1/health", headers={"Authorization": f"Bearer {auth_token}"})
|
||||
|
||||
detail = resp.json()["components"]["database"].get("detail", "")
|
||||
assert "Errno" not in detail
|
||||
assert "No space left" not in detail
|
||||
assert "OSError" not in detail
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_docker_failure_does_not_leak_exception_class(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
) -> None:
|
||||
"""V7.1.2: Docker socket exception must not appear in the HTTP response."""
|
||||
with patch("decnet.web.api.get_background_tasks") as mock_tasks, \
|
||||
patch("docker.from_env", side_effect=OSError("[Errno 111] Connection refused")):
|
||||
_make_all_running(mock_tasks)
|
||||
resp = await client.get("/api/v1/health", headers={"Authorization": f"Bearer {auth_token}"})
|
||||
|
||||
detail = resp.json()["components"]["docker"].get("detail", "")
|
||||
assert "Errno" not in detail
|
||||
assert "Connection refused" not in detail
|
||||
assert "OSError" not in detail
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,7 +34,10 @@ async def test_admin_lists_reachable_and_unreachable_hosts(
|
||||
assert hosts["alpha"]["current_sha"] == "aaaa111"
|
||||
assert hosts["alpha"]["previous_sha"] == "0000000"
|
||||
assert hosts["beta"]["reachable"] is False
|
||||
assert "TLS handshake" in hosts["beta"]["detail"]
|
||||
# V7.1.2: internal exception text must not leak to the response body.
|
||||
assert "TLS handshake" not in hosts["beta"]["detail"]
|
||||
assert "RuntimeError" not in hosts["beta"]["detail"]
|
||||
assert hosts["beta"]["detail"] == "host unreachable"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -2,8 +2,28 @@
|
||||
"""POST /api/v1/swarm-updates/push — happy paths, rollback, validation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_INTERNAL_LEAK_RE = re.compile(
|
||||
r"Errno|ConnectionRefused|TimeoutError|OSError|RuntimeError|Exception|"
|
||||
r"Traceback|\w+Error:\s|\w+Exception:\s|File \"|line \d+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
def _assert_no_internal_detail(detail: str | None) -> None:
|
||||
"""Assert the detail string does not contain any internal exception noise."""
|
||||
if detail is None:
|
||||
return
|
||||
assert not _INTERNAL_LEAK_RE.search(detail), (
|
||||
f"V7.1.2: internal exception detail leaked to client: {detail!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_to_single_host_success(client, auth_token, add_host, fake_updater):
|
||||
@@ -62,6 +82,49 @@ async def test_push_all_aggregates_mixed_results(client, auth_token, add_host, f
|
||||
assert statuses == {"alpha": "updated", "beta": "failed"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transport_exception_detail_does_not_leak_internals(
|
||||
client, auth_token, add_host, fake_updater,
|
||||
):
|
||||
"""V7.1.2: a raw transport exception must never appear in the response body."""
|
||||
await add_host("alpha", "10.0.0.1")
|
||||
fake_updater["client"].update_responses = {
|
||||
"alpha": OSError("[Errno 111] Connection refused"),
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"all": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()["results"][0]
|
||||
assert result["status"] == "failed"
|
||||
_assert_no_internal_detail(result.get("detail"))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_include_self_failure_detail_does_not_leak_internals(
|
||||
client, auth_token, add_host, fake_updater,
|
||||
):
|
||||
"""V7.1.2: include_self transport failure must not expose exception class/msg."""
|
||||
await add_host("alpha", "10.0.0.1")
|
||||
fake_updater["client"].update_self_responses = {
|
||||
"alpha": OSError("[Errno 104] Connection reset by peer"),
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"all": True, "include_self": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()["results"][0]
|
||||
# status is self-failed (non-expected drop)
|
||||
assert result["status"] == "self-failed"
|
||||
_assert_no_internal_detail(result.get("detail"))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tarball_built_once_across_multi_host_push(
|
||||
client, auth_token, add_host, fake_updater, monkeypatch,
|
||||
|
||||
@@ -50,7 +50,10 @@ async def test_rollback_transport_failure_reported(client, auth_token, add_host,
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "failed"
|
||||
assert "TLS handshake" in body["detail"]
|
||||
# V7.1.2: internal exception text must not leak to the response body.
|
||||
assert "TLS handshake" not in body["detail"]
|
||||
assert "RuntimeError" not in body["detail"]
|
||||
assert body["detail"] == "transport failure"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
Reference in New Issue
Block a user