The audit that produced these had every verifier agent die, so none were
confirmed. Checked each against the running system rather than guessing.
1. COOKIE Secure FLAG — REAL, fixed. The Cloudflare Tunnel runs OFF this box
(observed source 10.30.20.67, 155 requests in the journal) and uvicorn
only honours X-Forwarded-* from --forwarded-allow-ips, default 127.0.0.1.
Proven by hitting the LAN IP with X-Forwarded-Proto: https and watching
Secure vanish from Set-Cookie. Every internet visitor's session cookie
was going out without it.
Fixed in the unit drop-in with --proxy-headers and an allow-list scoped
to the tunnel host — NOT "*", because trusting that header from anywhere
would let a LAN client forge the IP the per-IP limiters key on. Verified
both directions: trusted source + header gets Secure, plain LAN http
correctly does not, and a spoof from an untrusted host is ignored.
2. DOUBLE GUEST ON REMOUNT — REAL but narrow, left alone. The guestAttempted
ref already covers StrictMode's double-effect (refs survive it). The only
hole is unmounting during the in-flight request, which needs navigating
away and back inside ~200ms and costs one unused row. Not worth
complicating the open door's happy path for.
3. SILENT REDIRECT WHEN RATE-LIMITED — REAL, fixed. A visitor whose guest
provisioning was refused got bounced to /enter with no explanation — and
at 5/hour/IP a household or cafe behind one NAT reaches that easily. The
failure reason (the backend's own in-fiction line) now rides along in
router state and /enter shows it, so nobody is silently handed a login
form they never asked for.
4. RATE LIMITER KEYS NEVER EVICTED — REAL, fixed. defaultdict entries
survived forever even once their hit list emptied. The open door made
this materially worse: every visitor is now a real account, so every
visitor permanently added a key across eleven limiter instances. Added an
opportunistic sweep every 512 admitted calls — no background task, cost
lands on whoever generates the load. Three tests; verified they catch it
by disabling the sweep and watching one fail.
5. SUMMON RACE vs TELEMETRY — REAL, fixed. Nothing serialised summoning.
_handle_anomaly checks `state.entity is None` then awaits a summon
containing a multi-second LLM mint, and the ESP32's HTTP ingestion path
calls _handle_anomaly on the SAME SeanceState — which is the entire point
of the device integration. Both could pass the check: two entities
minted, two essence credits, two item rolls, state.entity clobbered by
whichever finished last. Now guarded by a per-session asyncio.Lock.
6. LEGACY ENTITIES STUCK AT DEFAULT TRAITS — mechanism REAL, zero rows
affected here. The ALTER defaults traits to '{}' with no backfill and
roll_traits only runs at mint, so a pre-migration spirit would read 0.5
for everything — making `trust` always correct and `cross_over`
unreachable. This install has 0 such rows. Added a signature-seeded
backfill anyway, guarded to empty-traits rows so it can never touch a
spirit that already has a real nature.
(A seventh claim from the same batch — that iOS EMF is silently dead — was
refuted earlier and deliberately left untouched.)
34 targeted tests pass; deployed and verified live.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
78 lines
2.9 KiB
Python
78 lines
2.9 KiB
Python
import time
|
|
from collections import defaultdict
|
|
from typing import Mapping
|
|
|
|
|
|
def resolve_client_ip(headers: Mapping[str, str], direct_host: str | None) -> str:
|
|
"""Resolves the real visitor IP for per-IP rate limiting.
|
|
|
|
The App CT sits behind a Cloudflare Tunnel that runs on a separate
|
|
machine (see README Architecture) — every internet-facing connection's
|
|
raw TCP peer is that tunnel machine, not the visitor, which would
|
|
collapse per-IP limiting to a single shared bucket for all remote
|
|
traffic. Cloudflare's edge sets `CF-Connecting-IP` itself, stripping any
|
|
client-supplied value first, so it's safe to trust here. Direct
|
|
LAN/local access (no Cloudflare in front, e.g. local dev) has no such
|
|
header and falls back to the raw socket peer.
|
|
"""
|
|
forwarded = headers.get("cf-connecting-ip")
|
|
if forwarded:
|
|
return forwarded
|
|
return direct_host or "unknown"
|
|
|
|
|
|
class RateLimiter:
|
|
"""Fixed-window limiter keyed by an arbitrary string (user id or client IP).
|
|
|
|
Expired keys are swept periodically rather than left to accumulate.
|
|
Without that, every distinct key ever seen stays in the dict forever —
|
|
and since the open door provisions a real account per visitor, "every
|
|
distinct key" now means every visitor, across all eleven limiter
|
|
instances. That is a slow but genuine leak in a process designed to run
|
|
for months.
|
|
"""
|
|
|
|
# Sweep every N admitted calls rather than on a timer: no background
|
|
# task to own, and the cost lands on whoever is generating the load. At
|
|
# 512 the amortised cost is negligible, and a limiter can hold at most a
|
|
# window's worth of traffic plus 512 stale keys.
|
|
SWEEP_EVERY = 512
|
|
|
|
def __init__(self, max_requests: int, window_seconds: float):
|
|
self.max_requests = max_requests
|
|
self.window_seconds = window_seconds
|
|
self._hits: dict[str, list[float]] = defaultdict(list)
|
|
self._calls_since_sweep = 0
|
|
|
|
def _sweep(self, window_start: float) -> None:
|
|
"""Drop keys whose most recent hit has fallen out of the window."""
|
|
stale = [
|
|
key
|
|
for key, hits in self._hits.items()
|
|
if not hits or hits[-1] < window_start
|
|
]
|
|
for key in stale:
|
|
del self._hits[key]
|
|
|
|
def allow(self, key: str) -> bool:
|
|
now = time.monotonic()
|
|
window_start = now - self.window_seconds
|
|
|
|
self._calls_since_sweep += 1
|
|
if self._calls_since_sweep >= self.SWEEP_EVERY:
|
|
self._calls_since_sweep = 0
|
|
self._sweep(window_start)
|
|
|
|
hits = self._hits[key]
|
|
while hits and hits[0] < window_start:
|
|
hits.pop(0)
|
|
if len(hits) >= self.max_requests:
|
|
return False
|
|
hits.append(now)
|
|
return True
|
|
|
|
@property
|
|
def tracked_keys(self) -> int:
|
|
"""Live key count — exposed so the leak is testable."""
|
|
return len(self._hits)
|