Files
DECNET/tests/api/deckies/test_tarpit_leak.py
anti 245975a6dd 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.
2026-06-10 13:27:14 -04:00

276 lines
9.3 KiB
Python

# 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]