feat(canary): worker decodes ?d=/?o=/?s=&i=&n=&d= fingerprint params
The fingerprint payload beacons fingerprint data as base64url JSON in GET query params: ?o=1 for the bare-open beacon, ?d=<blob> for a single-shot dump, or ?s/i/n/d=<chunk> for chunked dumps. Until now those params were buried inside request_path; consumers had to parse the URL themselves. Worker now extracts them in _extract_fingerprint and merges into raw_headers under reserved _fp* keys: * _fp_open — bare-open marker * _fp — decoded fingerprint dict (single-shot path) * _fp_sid/idx/total/chunk — chunked metadata + raw base64 (reassembly is a downstream concern, not the worker's job) * _fp_decode_error / _fp_oversize — failure markers for trash dumps Per-chunk size capped at 8KB so an attacker spamming /c/<known_slug> can't inflate trigger rows indefinitely. Decode failures degrade gracefully — the trigger row still records the hit, just with a _fp_decode_error flag instead of structured fingerprint data. Tests cover the single-shot decode, bare-open flag, chunked metadata, malformed input, and oversize drop paths.
This commit is contained in:
@@ -108,6 +108,110 @@ async def test_xff_is_honored(repo: SQLiteRepository, bus: FakeBus) -> None:
|
||||
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"] as a dict."""
|
||||
import base64
|
||||
import json
|
||||
|
||||
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",
|
||||
})
|
||||
fp = {"mint": "abc-123", "nav": {"ua": "Test/1.0"}, "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
|
||||
|
||||
|
||||
@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."""
|
||||
|
||||
Reference in New Issue
Block a user