Files
DECNET/tests/canary/test_worker_http.py
anti 2fc5f1bdc5 feat(canary): auto-deregister fingerprint slug after first valid beacon
Once a fingerprint canary's HTTP beacon passes all 4 validation layers
and the trigger row lands, the token is immediately set to state=revoked
and canary.<id>.revoked is published on the bus. The slug lookup is
tightened to only return planted tokens, so subsequent requests to the
same URL silently return the transparent GIF without persisting anything
(stealth posture preserved). Plain http/dns canaries with no
fingerprint_nonce are not affected.

Changes:
- sqlmodel_repo/canary.py: add state == "planted" filter to
  get_canary_token_by_slug so revoked slugs resolve to None
- worker.py: after record_canary_trigger, if parsed_fp survived all
  layers and token has a fingerprint_nonce, call
  update_canary_token_state("revoked") + publish CANARY_REVOKED; errors
  are best-effort (trigger row already landed)
- test_worker_http.py: assert state=revoked in test_fp_valid_nonce_persists;
  new test_fp_deregisters_slug_after_valid_hit (second hit records nothing);
  new test_plain_http_canary_not_deregistered (env_file stays planted)
2026-04-29 17:49:31 -04:00

432 lines
16 KiB
Python

"""HTTP surface coverage for the canary worker.
We exercise the FastAPI app via Starlette's TestClient so the test
doesn't need a real socket. Asserts:
* ``GET /c/{slug}`` for a known slug returns 200 + image/gif, persists
a trigger row, bumps the token's counters, and publishes
``canary.<token_id>.triggered`` on the bus.
* ``GET /c/{slug}`` for an unknown slug returns the same 200 (stealth)
but persists nothing.
* The Server header is rewritten to a generic value (``nginx``).
* Bare root returns 404.
* X-Forwarded-For is honored.
"""
from __future__ import annotations
import asyncio
from typing import AsyncIterator
import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from decnet.bus import topics
from decnet.bus.fake import FakeBus
from decnet.canary.worker import _build_app
from decnet.web.db.sqlite.repository import SQLiteRepository
import decnet.web.db.models # noqa: F401
@pytest_asyncio.fixture
async def repo(tmp_path) -> AsyncIterator[SQLiteRepository]:
r = SQLiteRepository(str(tmp_path / "w.db"))
await r.initialize()
yield r
@pytest_asyncio.fixture
async def bus() -> AsyncIterator[FakeBus]:
b = FakeBus()
await b.connect()
yield b
await b.close()
@pytest.mark.asyncio
async def test_known_slug_records_trigger_and_publishes(
repo: SQLiteRepository, bus: FakeBus,
) -> None:
await repo.create_canary_token({
"uuid": "tok-w1", "kind": "http", "decky_name": "web1",
"generator": "env_file", "placement_path": "/x",
"callback_token": "slug-W1", "secret_seed": "s", "created_by": "u1",
})
sub = bus.subscribe("canary.>")
app = _build_app(repo, bus)
with TestClient(app) as client:
resp = client.get("/c/slug-W1", headers={"User-Agent": "curl/8.0"})
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/gif")
assert resp.headers.get("server") == "nginx"
event = await asyncio.wait_for(sub.__anext__(), timeout=2.0)
assert event.topic == topics.canary("tok-w1", topics.CANARY_TRIGGERED)
assert event.payload["src_ip"]
assert event.payload["user_agent"] == "curl/8.0"
triggers = await repo.list_canary_triggers("tok-w1")
assert len(triggers) == 1
assert triggers[0]["request_path"] == "/c/slug-W1"
tok = await repo.get_canary_token("tok-w1")
assert tok["trigger_count"] == 1
@pytest.mark.asyncio
async def test_unknown_slug_returns_same_response_but_persists_nothing(
repo: SQLiteRepository, bus: FakeBus,
) -> None:
app = _build_app(repo, bus)
with TestClient(app) as client:
resp = client.get("/c/unknown-slug")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/gif")
# No tokens, no triggers, no nothing.
assert await repo.list_canary_tokens() == []
@pytest.mark.asyncio
async def test_root_returns_404(repo: SQLiteRepository, bus: FakeBus) -> None:
app = _build_app(repo, bus)
with TestClient(app) as client:
resp = client.get("/")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_xff_is_honored(repo: SQLiteRepository, bus: FakeBus) -> None:
await repo.create_canary_token({
"uuid": "tok-xff", "kind": "http", "decky_name": "web1",
"generator": "env_file", "placement_path": "/x",
"callback_token": "slug-xff", "secret_seed": "s", "created_by": "u1",
})
app = _build_app(repo, bus)
with TestClient(app) as client:
client.get("/c/slug-xff", headers={"X-Forwarded-For": "9.9.9.9, 10.0.0.1"})
triggers = await repo.list_canary_triggers("tok-xff")
assert triggers[0]["src_ip"] == "9.9.9.9"
@pytest.mark.asyncio
async def test_fingerprint_query_param_decoded_into_raw_headers(
repo: SQLiteRepository, bus: FakeBus,
) -> None:
"""``?d=<b64url(json)>`` is decoded into raw_headers["_fp"] when valid."""
import base64
import json
import uuid as _uuid
_MINT_NS = _uuid.UUID("a3f7c821-9d1e-4b6a-8c2d-1e4f9a7b3c5d")
mint_id = str(_uuid.uuid5(_MINT_NS, "slug-FP1"))
await repo.create_canary_token({
"uuid": "tok-fp1", "kind": "http", "decky_name": "web1",
"generator": "fingerprint_html", "placement_path": "/x",
"callback_token": "slug-FP1", "secret_seed": "s", "created_by": "u1",
})
# Token has no fingerprint_nonce → Layer A skipped; must satisfy B + C.
fp = {
"mint": mint_id,
"nav": {"ua": "Test/1.0"}, "scr": {"w": 1920}, "tz": {"z": "UTC"},
"id": "h" * 64,
}
blob = base64.urlsafe_b64encode(json.dumps(fp).encode()).rstrip(b"=").decode()
app = _build_app(repo, bus)
with TestClient(app) as client:
client.get(f"/c/slug-FP1?d={blob}")
triggers = await repo.list_canary_triggers("tok-fp1")
headers = json.loads(triggers[0]["raw_headers"])
assert headers["_fp"] == fp
@pytest.mark.asyncio
async def test_bare_open_beacon_records_fp_open_flag(
repo: SQLiteRepository, bus: FakeBus,
) -> None:
import json
await repo.create_canary_token({
"uuid": "tok-fp2", "kind": "http", "decky_name": "web1",
"generator": "fingerprint_html", "placement_path": "/x",
"callback_token": "slug-FP2", "secret_seed": "s", "created_by": "u1",
})
app = _build_app(repo, bus)
with TestClient(app) as client:
client.get("/c/slug-FP2?o=1")
triggers = await repo.list_canary_triggers("tok-fp2")
headers = json.loads(triggers[0]["raw_headers"])
assert headers["_fp_open"] == "1"
@pytest.mark.asyncio
async def test_chunked_fingerprint_stores_metadata(
repo: SQLiteRepository, bus: FakeBus,
) -> None:
import json
await repo.create_canary_token({
"uuid": "tok-fp3", "kind": "http", "decky_name": "web1",
"generator": "fingerprint_html", "placement_path": "/x",
"callback_token": "slug-FP3", "secret_seed": "s", "created_by": "u1",
})
app = _build_app(repo, bus)
with TestClient(app) as client:
client.get("/c/slug-FP3?s=abc&i=0&n=2&d=Zm9vYmFy")
triggers = await repo.list_canary_triggers("tok-fp3")
headers = json.loads(triggers[0]["raw_headers"])
assert headers["_fp_sid"] == "abc"
assert headers["_fp_idx"] == "0"
assert headers["_fp_total"] == "2"
assert headers["_fp_chunk"] == "Zm9vYmFy"
# Single-shot decode should NOT have run for a chunked payload.
assert "_fp" not in headers
@pytest.mark.asyncio
async def test_malformed_fingerprint_records_decode_error(
repo: SQLiteRepository, bus: FakeBus,
) -> None:
import json
await repo.create_canary_token({
"uuid": "tok-fp4", "kind": "http", "decky_name": "web1",
"generator": "fingerprint_html", "placement_path": "/x",
"callback_token": "slug-FP4", "secret_seed": "s", "created_by": "u1",
})
app = _build_app(repo, bus)
with TestClient(app) as client:
# base64-decodable but not JSON
client.get("/c/slug-FP4?d=Zm9vYmFy") # "foobar"
triggers = await repo.list_canary_triggers("tok-fp4")
headers = json.loads(triggers[0]["raw_headers"])
assert headers["_fp_decode_error"] == "1"
assert "_fp" not in headers
@pytest.mark.asyncio
async def test_oversize_fingerprint_dropped(
repo: SQLiteRepository, bus: FakeBus,
) -> None:
import json
await repo.create_canary_token({
"uuid": "tok-fp5", "kind": "http", "decky_name": "web1",
"generator": "fingerprint_html", "placement_path": "/x",
"callback_token": "slug-FP5", "secret_seed": "s", "created_by": "u1",
})
app = _build_app(repo, bus)
with TestClient(app) as client:
# 9KB blob exceeds the 8KB per-chunk cap
client.get("/c/slug-FP5?d=" + "A" * (9 * 1024))
triggers = await repo.list_canary_triggers("tok-fp5")
headers = json.loads(triggers[0]["raw_headers"])
assert headers["_fp_oversize"] == "1"
assert "_fp" not in headers
def _make_fp_blob(slug: str, extra_keys: int = 3) -> tuple[str, str]:
"""Return (b64url_blob, mint_uuid) for a fingerprint matching *slug*."""
import base64
import json
import uuid as _uuid
_MINT_NS = _uuid.UUID("a3f7c821-9d1e-4b6a-8c2d-1e4f9a7b3c5d")
mint_id = str(_uuid.uuid5(_MINT_NS, slug))
base_keys = ["nav", "scr", "tz", "cv", "gl"]
fp: dict = {"mint": mint_id}
for k in base_keys[:extra_keys]:
fp[k] = {"ok": True}
fp["id"] = "a" * 64
blob = base64.urlsafe_b64encode(json.dumps(fp).encode()).rstrip(b"=").decode()
return blob, mint_id
@pytest.mark.asyncio
async def test_fp_valid_nonce_persists(repo: SQLiteRepository, bus: FakeBus) -> None:
"""Valid nonce + valid shape + correct mint UUID → ``_fp`` is persisted."""
import json
blob, _ = _make_fp_blob("slug-NONCE1")
await repo.create_canary_token({
"uuid": "tok-n1", "kind": "http", "decky_name": "web1",
"generator": "fingerprint_html", "placement_path": "/x",
"callback_token": "slug-NONCE1", "secret_seed": "s", "created_by": "u1",
"fingerprint_nonce": "deadbeef01234567",
})
app = _build_app(repo, bus)
with TestClient(app) as client:
client.get(f"/c/slug-NONCE1?d={blob}&k=deadbeef01234567")
triggers = await repo.list_canary_triggers("tok-n1")
headers = json.loads(triggers[0]["raw_headers"])
assert "_fp" in headers
assert "_fp_invalid_nonce" not in headers
# Valid fingerprint → token auto-revoked.
tok = await repo.get_canary_token("tok-n1")
assert tok["state"] == "revoked"
@pytest.mark.asyncio
async def test_fp_invalid_nonce_rejected(repo: SQLiteRepository, bus: FakeBus) -> None:
"""Wrong ``?k=`` value → ``_fp_invalid_nonce=1``, no ``_fp``."""
import json
blob, _ = _make_fp_blob("slug-NONCE2")
await repo.create_canary_token({
"uuid": "tok-n2", "kind": "http", "decky_name": "web1",
"generator": "fingerprint_html", "placement_path": "/x",
"callback_token": "slug-NONCE2", "secret_seed": "s", "created_by": "u1",
"fingerprint_nonce": "deadbeef01234567",
})
app = _build_app(repo, bus)
with TestClient(app) as client:
client.get(f"/c/slug-NONCE2?d={blob}&k=wrongnonce000000")
triggers = await repo.list_canary_triggers("tok-n2")
headers = json.loads(triggers[0]["raw_headers"])
assert headers["_fp_invalid_nonce"] == "1"
assert "_fp" not in headers
@pytest.mark.asyncio
async def test_fp_invalid_shape_rejected(repo: SQLiteRepository, bus: FakeBus) -> None:
"""Fewer than 3 known dict keys → ``_fp_invalid_shape=1``, no ``_fp``."""
import base64
import json
import uuid as _uuid
_MINT_NS = _uuid.UUID("a3f7c821-9d1e-4b6a-8c2d-1e4f9a7b3c5d")
mint_id = str(_uuid.uuid5(_MINT_NS, "slug-SHAPE1"))
fp = {"mint": mint_id, "nav": {"ua": "x"}} # only 1 known dict key
blob = base64.urlsafe_b64encode(json.dumps(fp).encode()).rstrip(b"=").decode()
await repo.create_canary_token({
"uuid": "tok-sh1", "kind": "http", "decky_name": "web1",
"generator": "fingerprint_html", "placement_path": "/x",
"callback_token": "slug-SHAPE1", "secret_seed": "s", "created_by": "u1",
})
app = _build_app(repo, bus)
with TestClient(app) as client:
client.get(f"/c/slug-SHAPE1?d={blob}")
triggers = await repo.list_canary_triggers("tok-sh1")
headers = json.loads(triggers[0]["raw_headers"])
assert headers["_fp_invalid_shape"] == "1"
assert "_fp" not in headers
@pytest.mark.asyncio
async def test_fp_invalid_mint_rejected(repo: SQLiteRepository, bus: FakeBus) -> None:
"""Wrong mint UUID in payload → ``_fp_invalid_mint=1``, no ``_fp``."""
import base64
import json
fp = {
"mint": "wrong-uuid-entirely",
"nav": {"x": 1}, "scr": {"x": 1}, "tz": {"x": 1},
"id": "a" * 64,
}
blob = base64.urlsafe_b64encode(json.dumps(fp).encode()).rstrip(b"=").decode()
await repo.create_canary_token({
"uuid": "tok-mint1", "kind": "http", "decky_name": "web1",
"generator": "fingerprint_html", "placement_path": "/x",
"callback_token": "slug-MINT1", "secret_seed": "s", "created_by": "u1",
})
app = _build_app(repo, bus)
with TestClient(app) as client:
client.get(f"/c/slug-MINT1?d={blob}")
triggers = await repo.list_canary_triggers("tok-mint1")
headers = json.loads(triggers[0]["raw_headers"])
assert headers["_fp_invalid_mint"] == "1"
assert "_fp" not in headers
@pytest.mark.asyncio
async def test_fp_rate_limited_on_excess_submissions(
repo: SQLiteRepository, bus: FakeBus,
) -> None:
"""31st rapid-fire submission → ``_fp_rate_limited=1``, no ``_fp``."""
import json
import decnet.canary.worker as _worker
# Reset the rate bucket so other tests don't bleed in.
_worker._fp_rate_buckets.clear()
blob, _ = _make_fp_blob("slug-RATE1")
await repo.create_canary_token({
"uuid": "tok-rate1", "kind": "http", "decky_name": "web1",
"generator": "fingerprint_html", "placement_path": "/x",
"callback_token": "slug-RATE1", "secret_seed": "s", "created_by": "u1",
})
app = _build_app(repo, bus)
with TestClient(app) as client:
for _ in range(31):
client.get(
f"/c/slug-RATE1?d={blob}",
headers={"X-Forwarded-For": "1.2.3.4"},
)
triggers = await repo.list_canary_triggers("tok-rate1")
# list_canary_triggers orders DESC (newest first) — index 0 is the 31st hit.
newest_headers = json.loads(triggers[0]["raw_headers"])
assert newest_headers["_fp_rate_limited"] == "1"
assert "_fp" not in newest_headers
# Oldest (30th or earlier) should be clean.
oldest_headers = json.loads(triggers[-1]["raw_headers"])
assert "_fp_rate_limited" not in oldest_headers
assert "_fp" in oldest_headers
_worker._fp_rate_buckets.clear()
@pytest.mark.asyncio
async def test_fp_deregisters_slug_after_valid_hit(
repo: SQLiteRepository, bus: FakeBus,
) -> None:
"""After a valid fingerprint beacon the slug goes dark — second hit records nothing."""
import json
blob, _ = _make_fp_blob("slug-DEREG1")
await repo.create_canary_token({
"uuid": "tok-dereg1", "kind": "http", "decky_name": "web1",
"generator": "fingerprint_html", "placement_path": "/x",
"callback_token": "slug-DEREG1", "secret_seed": "s", "created_by": "u1",
"fingerprint_nonce": "deadbeef01234567",
})
app = _build_app(repo, bus)
with TestClient(app) as client:
# First hit — valid FP, deregisters slug.
client.get(f"/c/slug-DEREG1?d={blob}&k=deadbeef01234567")
# Second hit — slug is revoked, stealth 200 but nothing persisted.
client.get(f"/c/slug-DEREG1?d={blob}&k=deadbeef01234567")
triggers = await repo.list_canary_triggers("tok-dereg1")
assert len(triggers) == 1 # only the first hit landed
@pytest.mark.asyncio
async def test_plain_http_canary_not_deregistered(
repo: SQLiteRepository, bus: FakeBus,
) -> None:
"""Plain HTTP canaries (no fingerprint_nonce) are NOT auto-revoked on a hit."""
await repo.create_canary_token({
"uuid": "tok-plain1", "kind": "http", "decky_name": "web1",
"generator": "env_file", "placement_path": "/x",
"callback_token": "slug-PLAIN1", "secret_seed": "s", "created_by": "u1",
# fingerprint_nonce intentionally absent / NULL
})
app = _build_app(repo, bus)
with TestClient(app) as client:
client.get("/c/slug-PLAIN1")
client.get("/c/slug-PLAIN1")
triggers = await repo.list_canary_triggers("tok-plain1")
assert len(triggers) == 2 # both hits recorded — no deregistration
tok = await repo.get_canary_token("tok-plain1")
assert tok["state"] == "planted"
@pytest.mark.asyncio
async def test_no_decnet_strings_in_response(repo: SQLiteRepository, bus: FakeBus) -> None:
"""Stealth posture: nothing in the HTTP surface mentions DECNET."""
app = _build_app(repo, bus)
with TestClient(app) as client:
resp = client.get("/c/anything")
body = resp.content.lower()
for v in resp.headers.values():
assert b"decnet" not in v.lower().encode()
assert b"decnet" not in body
# Docs / openapi / redoc are disabled.
assert client.get("/docs").status_code == 404
assert client.get("/openapi.json").status_code == 404
assert client.get("/redoc").status_code == 404