Round 2 of review on the same branch:
- The fail-closed capacity guard was itself a DoS lever. MAX_RATE_KEYS was
global, and the limiter runs before auth, so a flood of cheap /v1/repos
keys could exhaust the table and 429 never-seen /v1/link clients until
live windows expired. Budgets are now per route.
- Rule values accepted booleans: bool subclasses int, so isinstance let
[5, true] through as a 1-SECOND window (5/hour -> ~5/sec) and false in the
limit slot disabled the endpoint. Now `type(x) is int`.
- trusted_proxies was unvalidated: a bare string would be iterated character
by character, malformed entries only surfaced as a per-request log line,
and 0.0.0.0/0 or ::/0 restored "trust XFF from any peer" — the exact hole
the setting closes. Now parsed and validated once at startup, wildcards
refused, and _ip_in_any takes pre-parsed networks so nothing can degrade
to a silent per-request skip.
Tests 99 -> 108: cross-route flood isolation, per-route reclamation windows,
every bool-in-rule position, bare-string and wildcard proxies, and a v4/v6
mismatch case.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
All four were real bypass or fail-open paths on an endpoint about to be
publicly exposed:
- X-Forwarded-For was trusted from ANY peer. The origin also listens on the
tailnet, so anyone reaching it directly could pick -- and rotate -- their
own rate-limit key by sending a header. Now honored only when the socket
peer is in a configured trusted_proxies list, and the last hop must parse
as a real IP. trust_forwarded_for without trusted_proxies REFUSES startup.
- Capacity eviction was fail-open and exploitable: an attacker able to mint
many distinct keys could evict their own live window and start fresh. Now
reclaims only EXPIRED windows and refuses the new key when all are live.
Fail closed -- /v1/link is invite-only, so hitting the cap is an attack.
- The clock was read outside the lock, so racing threads could append out of
order; both retry_after (hits[0]) and reclamation (v[-1]) assume the list
is chronological. Moved inside.
- Config types were unvalidated: `"enabled": null` or `0` silently disabled
limiting, and the string "false" enabled XFF trust (non-empty strings are
truthy). Booleans must now be real JSON booleans; rate_limit must be an
object.
Codex confirmed no path-variant bypass (dispatch is exact-match) and no
keep-alive/pipelining bypass (rejects set close_connection).
Tests 87 -> 99: capacity fail-closed with the victim's window proven
untouched through a 40-key flood, 200-thread chronological-order check,
untrusted-peer spoof, junk XFF, and every config-type trap.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
Last unbuilt item on the promotion-window hardening checklist. /v1/link
round-trips Zitadel, can CREATE a forge account and always mints a token, so
it is the endpoint that must not be free to hammer.
Sliding window per (route, client), ON by default -- unlimited has to be a
deliberate config act, not an omission. Defaults 5/hour and 60/hour; /health
never limited. 429 + Retry-After, decided BEFORE the body is read so an
abusive caller costs nothing.
Decisions worth naming:
- State is an in-process dict behind a lock. granthi-link is ONE
ThreadingHTTPServer, so that IS the store -- no redis. Kept behind a class
so a future multi-process move has one thing to change.
- Denied requests are NOT recorded. Recording them lets a hammering client
push its own window forward and lock itself out forever.
- Key store is capped; at capacity it drops least-recent windows and logs
loudly. Fail-open under key pressure, chosen over an unbounded dict that
is a memory DoS.
- trust_forwarded_for OFF by default. Behind cloudflared every request comes
from the tunnel, so limiting on the socket peer starves everyone; but XFF
is client-controlled. A caller can PREPEND, a trusted proxy APPENDS what it
actually saw -- so we read the LAST entry, never the first.
- A malformed rule refuses startup instead of silently meaning unlimited.
Tests 69 -> 87, including a 40-thread race proving the lock holds, the
self-lockout case, XFF spoof-resistance, and a real 429 on the wire.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
The client is downloadable from central now, but no document took an invited
user from clone to working state. Quickstart covers link -> list -> get/add
-> watch, plus the two things people get wrong: an expired device code
creates nothing (just re-run link), and merging is deliberately a forge
action because watch refuses to merge or force.
Also re-probed the token-minting mechanics the README pinned to 1.27.1,
since both forges now run 1.27.2. All three results still hold:
token-auth + Sudo header -> 401
token-auth + ?sudo= -> 401
admin basic auth + Sudo -> 201
Probe minted a token on the granthi-sync-e2e machine user and deleted it
(204 under basic auth). /v1/link's mint path is unaffected by the bump.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
`add` pushed a local folder up; nothing pulled a cloud repo down, so the
"show me my repos -> download -> start working" half of onboarding had no
implementation. Both new commands read the forge directly with the scoped
user token the link already handed us, so neither needs a granthi-link
endpoint, a service restart, or a VPS config edit.
- list: GET /api/v1/user/repos, pagination followed to a short page, with a
FORGE_MAX_PAGES guard whose trip is REPORTED — a bounded page must never
read as "that is all of them". Shows which repos are already local.
- get: clones with --origin granthi (the remote name watch looks for) and
-c credential.helper (the repo does not exist yet, so the helper cannot be
installed first), then registers the folder in the same shape `add` writes
— without that, watch silently ignores everything cloned.
- require_linked(): one failure mode for every forge-touching command.
- VERSION 1.0.0 -> 1.1.0, matching the README and the 1.1.0 hardening.
Tests 55 -> 65.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
- main() exits 2 on permissive config (not just the predicate)
- 413 proven with an actual over-limit wire body, not header-only
- README: machine-user accounts need a seeded state.json mapping; only
granthi-sync-e2e required it in beta
Co-Authored-By: Claude Fable 5 <[email protected]>