feat(node): per-IP write-surface rate brake + purge-spam admin tool#196
feat(node): per-IP write-surface rate brake + purge-spam admin tool#196beardthelion wants to merge 7 commits into
Conversation
…utes Introduces a dedicated write_rate_limiter bucket (GITLAWB_WRITE_RATE_LIMIT, default 600/hr, 0 disables), separate from the creation brake so a write flood cannot drain the creation budget (KTD-1). Attaches rate_limit_by_ip to write_routes, mirroring creation_routes. Per-DID deliberately not paired (a DID farm never trips it). Tests (route-level, execution-verified against Postgres): a write_routes sink (PUT /star) is 429'd before auth; an exhausted write bucket does not throttle repo creation (separate-bucket property).
Attaches rate_limit_by_ip (write bucket) to task_write_routes, issue_write_routes, bounty_write_routes, profile_write_routes, and the /graphql POST/GET surface (KTD-5 — an HTTP-layer brake counts every /graphql request; /graphql/ws subscriptions stay unbraked). write_ip_limiter is now built once and shared. Every non-git, non-creation authenticated write group is braked. Tests (execution-verified against Postgres): /graphql POST is 429'd, and a representative REST write sink (issue comment) is 429'd, by the write brake.
…rake Middleware-level tests complementing the client_key unit coverage: a spoofer varying the client-controlled leftmost X-Forwarded-For hop cannot rotate its bucket key (keyed on the trusted rightmost hop); distinct trusted client hops get distinct buckets (no cross-starvation); and a request with no trusted header in a proxy mode falls back to the socket-peer key — the documented collapse, asserted by name so any change is deliberate. Operator-warning on missing-header deferred deliberately: a per-request warning is a log-noise risk and the security-load-bearing fallback (peer key, never skip) is what these tests lock down.
Adds a purge-spam subcommand (clap #[command(subcommand)] split; no-subcommand path still runs the node unchanged). Candidate selection is signature-based, not per-DID: a repo qualifies only if owned by the named burst DID AND verified empty (zero git refs) per repo. A hard exclusion gate runs BEFORE the empty check, so an empty repo owned by the content-bearing user or the mirror-bot DID is never a candidate. Dry-run is the default; deletion needs an explicit --execute flag and operates per-repo, never 'all repos of DID X'. Tests (RED->GREEN, exclusion gate confirmed load-bearing by targeting an excluded DID directly): empty target listed; target-with-refs spared; empty excluded/ intern repos spared (must-assert); dry-run deletes nothing; execute removes only the empty target on disk.
P1 from code review: on_disk_ref_count shelled 'git for-each-ref' with only the repo path as cwd and no --git-dir, so a path that exists but is not a bare repo let git discover a parent .git (repos_dir may sit inside the operator's checkout) and read a DIFFERENT repo's refs — possibly 0, deleting a real repo. Now require the bare-repo markers (HEAD file + objects/ dir) before trusting any count; anything else fails closed (skipped). Tests: a non-git dir under a git ancestor (zero-ref) now fails closed — verified RED without the guard (read the ancestor's 0 refs); target-DID-never-excluded invariant pinned; write_flood test anchored to a genuinely-drained bucket so its assert_ne can't pass vacuously.
…rage Addresses the remaining code-review findings: - purge-spam DID matching now uses did:key normalization on both sides (list_repos_by_owner_did via OWNER_KEY_CASE_SQL, is_excluded and the target scope via normalize_owner_key), so short/full form is consistent and an excluded identity is protected in either form. Adds a short-form exclusion test. - Extract the per-IP write brake into a chainable WriteBraked helper so a future write group can't silently ship unbraked (kept separate from add_auth_layers by design; documented why). - Mark --repos-dir / --database-url global so admin subcommands accept them after the subcommand (was an 'unexpected argument' parse error); verified. - Document GITLAWB_WRITE_RATE_LIMIT in .env.example incl. the per-IP NAT-aggregate caveat so operators can size it. - Coverage: adoption-floor (under-limit write passes), write-limit=0 disables, task/bounty/profile route-level 429s, /graphql/ws stays unbraked. Full suite 927 pass / 0 fail; fmt + clippy -D warnings clean.
- purge-spam execute now re-verifies emptiness immediately before delete (TOCTOU: a push between selection and delete no longer risks deleting a now-non-empty repo) and removes the on-disk bare repo dir so DB and disk stay consistent; a per-repo delete failure warns and continues rather than aborting the batch. - Tests (RED-verified where they cover new behavior): partition_for_delete skips a repo no longer empty; execute removes the on-disk dir (RED without the removal); list_repos_by_owner_did finds a short-form burst row (RED without the SQL normalization); every braked write group passes under-limit (per-group grant path, not just the star representative).
📝 WalkthroughWalkthroughChangesThe pull request adds a Spam purge administration
Authenticated write rate limiting
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant Router
participant WriteLimiter
participant WriteHandler
Client->>Router: authenticated write request
Router->>WriteLimiter: check client-IP bucket
WriteLimiter->>WriteHandler: forward request when permitted
sequenceDiagram
participant AdminCLI
participant PurgeRunner
participant Database
participant RepositoryDirectory
AdminCLI->>PurgeRunner: purge-spam
PurgeRunner->>Database: list target-owned repositories
PurgeRunner->>RepositoryDirectory: verify zero refs
PurgeRunner->>Database: delete repository row
PurgeRunner->>RepositoryDirectory: remove repository directory
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/gitlawb-node/src/main.rs (1)
440-455: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
write_rate_limiteris missing from the periodic cleanup loop.Every other limiter (
rate_limiter,create_ip_rate_limiter,push_rate_limiter,sync_trigger_rate_limiter,peer_write_rate_limiter) is swept here, but the newly addedwrite_rate_limiteris not. It is bounded, so no unbounded leak, yet stale keys are only evicted lazily on a capacity miss instead of proactively like its peers — an inconsistency worth closing.🧹 Proposed fix
let peer_write_rl = state.peer_write_rate_limiter.clone(); + let write_rl = state.write_rate_limiter.clone(); let db = state.db.clone();peer_write_rl.cleanup().await; + write_rl.cleanup().await;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/gitlawb-node/src/main.rs` around lines 440 - 455, Include the state.write_rate_limiter in the periodic cleanup task alongside the other cloned rate limiters. Clone it before spawning the task and invoke its cleanup method in the five-minute tokio::select! interval, preserving the existing cleanup behavior for all other limiters.
🧹 Nitpick comments (1)
crates/gitlawb-node/src/api/repos.rs (1)
2629-2966: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider extracting a shared test-request helper to reduce duplication.
All 9 new tests repeat the same
Request::builder()+ConnectInfoinsertion +oneshot()+ status-assert boilerplate.rate_limit.rsin this same PR already factored this pattern intopost_from/post_withhelpers — a similar helper here (e.g.,send_from(&router, method, uri, body, peer)) would cut most of the repetition acrosswrite_route_is_rate_limited_by_ip,write_flood_does_not_drain_creation_budget,graphql_post_is_rate_limited_by_ip,issue_comment_is_rate_limited_by_ip,under_limit_write_is_not_throttled,write_rate_limit_zero_disables_the_brake,task_bounty_profile_writes_are_rate_limited,graphql_ws_is_not_braked, andevery_write_group_passes_under_limit.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/gitlawb-node/src/api/repos.rs` around lines 2629 - 2966, Reduce duplicated request setup across the listed rate-limit tests by adding a shared test helper such as send_from that accepts the router, HTTP method, URI, body, and peer, inserts ConnectInfo, sends via oneshot, and returns the response or status. Update each named test to use this helper while preserving its existing request bodies, methods, URIs, and status assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@crates/gitlawb-node/src/main.rs`:
- Around line 440-455: Include the state.write_rate_limiter in the periodic
cleanup task alongside the other cloned rate limiters. Clone it before spawning
the task and invoke its cleanup method in the five-minute tokio::select!
interval, preserving the existing cleanup behavior for all other limiters.
---
Nitpick comments:
In `@crates/gitlawb-node/src/api/repos.rs`:
- Around line 2629-2966: Reduce duplicated request setup across the listed
rate-limit tests by adding a shared test helper such as send_from that accepts
the router, HTTP method, URI, body, and peer, inserts ConnectInfo, sends via
oneshot, and returns the response or status. Update each named test to use this
helper while preserving its existing request bodies, methods, URIs, and status
assertions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: e186f361-729a-419c-b633-a90e12b77a44
📒 Files selected for processing (11)
.env.examplecrates/gitlawb-node/src/admin.rscrates/gitlawb-node/src/api/repos.rscrates/gitlawb-node/src/auth/mod.rscrates/gitlawb-node/src/config.rscrates/gitlawb-node/src/db/mod.rscrates/gitlawb-node/src/main.rscrates/gitlawb-node/src/rate_limit.rscrates/gitlawb-node/src/server.rscrates/gitlawb-node/src/state.rscrates/gitlawb-node/src/test_support.rs
jatmn
left a comment
There was a problem hiding this comment.
High — fix before merge
H1. Read/fork handlers run blocking git subprocesses directly on the tokio runtime (no spawn_blocking, no timeout)
fork_repo—crates/gitlawb-node/src/api/repos.rs:1567-1575invokesstd::process::Command::new("git").args(["clone","--mirror",…]).output()inside the async handler.output()blocks the calling worker thread;git clone --mirrorof a non-trivial repo can take minutes and there is no timeout, unlike every other git walk in this file which correctly usestokio::task::spawn_blocking(repos.rs:67, 110, 661, 1143) ortokio::process::Command(info/refs, upload-pack, receive-pack).- Read metadata handlers —
list_commits/get_tree/get_blobcallstore::{resolve_head, log, ls_tree, read_file}(api/repos.rs:360-361, 397, 443, 481) which are synchronousstd::process::Command::output()calls (crates/gitlawb-node/src/git/store.rs:92-231). A slow disk or large repo blocks a worker thread for seconds; with a finite worker pool a handful of concurrent/commits,/tree,/blob, or a single/forkcan starve the whole runtime, including/healthand/ready(same runtime inbuild_router). - Impact: availability/reliability regression, not just latency.
git_service_timeout_secsis bypassed on these paths. - Fix: wrap the blocking
storecalls (and the fork clone) intokio::task::spawn_blockingand bound withtokio::time::timeout(git_service_timeout_secs, …), returningAppError::Timeout.
H2. write_rate_limiter is never reaped by the background cleanup loop
crates/gitlawb-node/src/main.rs:440-455— the periodic task clones exactly five limiters (rate_limiter,create_ip_rate_limiter,push_rate_limiter,sync_trigger_rate_limiter,peer_write_rate_limiter) and calls.cleanup()on each.state.write_rate_limiter(declared atmain.rs:329-333, wired intoAppStateat:404) is omitted, even though it protects the entire authenticated non-creation write surface +/graphql(server.rs:84-98, 110, 154+).- The
RateLimiterdesign makes the inline capacity sweep amortized and explicitly relies on the background loop for full reclamation (rate_limit.rs:36-39, 94-114). With no background sweep,write_rate_limiteronly reclaims expired keys once its map hits the 200,000-key cap. Until then every distinct client IP persists as a staleWindowfor the full 1-hour window and beyond → steady memory growth (bounded at the cap) and a latent correctness cliff: once at cap, the amortized inline sweep becomes the only reclaimer and a distinct-IP write flood serializes behind O(max_keys) scans while legitimate new IPs get spuriously 429'd between sweeps. - Fix: add
let write_rl = state.write_rate_limiter.clone();andwrite_rl.cleanup().await;inside the cleanup tick beside the other five.
Medium — should fix
M1. fork_repo / create_repo orphan on-disk repos (and a spent proof / uploaded object) on late DB failure
fork_repoordering (api/repos.rs:1555 → 1567 → 1585 → 1605): spend proof →git clone --mirrortodisk_path→release_after_write(Tigris upload) →db.create_repo. Ifcreate_repofails, the on-disk mirror and the already-uploaded Tigris object are left with no DB row, and the single-use iCaptcha proof is already consumed.create_repo(api/repos.rs:208 → 210-219 → 236):proof.consumethenrepo_store.init(blockinggit init) happen beforedb.create_repo. A DB failure orphans the freshly-initialized directory.inititself is also a blocking git call on the async runtime (same class as H1).- Fix: make disk materialization and proof spend adjacent to a guaranteed-successful DB write, and on
db.create_repofailure best-effortremove_dir_all(disk_path)(and prefer deferring the Tigris upload until after the row commits).
M2. TrustedProxy IP trust is config-asserted, not network-enforced — header rotation defeats every per-IP brake
crates/gitlawb-node/src/rate_limit.rs:198-218(client_key): inFly/XForwardedFormodes the limiter key is taken entirely fromFly-Client-IPor the rightmostX-Forwarded-Forhop, and only falls back to the socket peer when the header is absent. There is no check that the socket peer (ConnectInfo) is actually the operator's proxy. IfGITLAWB_TRUSTED_PROXYis set but the node's HTTP port is reachable directly (misconfigured firewall, exposed Fly port, second ingress, internal attacker), a client can setFly-Client-IP: <random>/ append any rightmostX-Forwarded-Forhop and get a fresh bucket per request, evading the creation/write/push/sync brakes (the only thing stopping a single-source spam/push flood). The unit tests cover header parsing but never assert "peer not the proxy → key on peer."- Fix: honor the forwarded header only when
ConnectInfopeer is in a configured proxy CIDR/IP allowlist; otherwise key on the socket peer (fail closed). Add a test for "trust mode set, request from a non-proxy IP." - Note (validation): exploitability is deployment-dependent (Fly's edge does overwrite
Fly-Client-IP), so rated Medium not Blocker.
M3. purge-spam --execute deletes via remove_dir_all on an unsanitized repo_name (path traversal → arbitrary repo deletion)
crates/gitlawb-node/src/git/store.rs:450-453—repo_disk_pathsanitizesowner_did(replace([':', '/'], "_")) but joinsrepo_nameverbatim viaformat!("{repo_name}.git")with no../slash/dot validation.crates/gitlawb-node/src/admin.rs:243-244callsstd::fs::remove_dir_all(store::repo_disk_path(&repos_dir, &c.owner_did, &c.name))on that path. The fail-closed marker guard (admin.rs:128) only requires aHEADfile +objects/dir — i.e. any existing legitimate bare repo satisfies it. API names are validated (api/repos.rs:189-197), butsmart_http_repo_nameexplicitly notes repos are "creatable via the peer mirror path, which skips API name validation" (api/repos.rs:490-494), so a burst-DID row with a../name can be planted, then deleted outsiderepos_dir.- Fix: validate
repo_name(and re-validateowner_did) the same way the serving paths do (repo_store::validate_repo_name/validate_path_components), and assert the resolved path is canonically insiderepos_dirbefore anyremove_dir_all. Ideally makerepo_disk_pathitself reject unsafe names. - Note: a destructive
remove_dir_allshould never run on a DB-sourced, unsanitized name. End-to-end exploitability is narrow (requires the burst DID + marker check at the traversed path), so rated Low/Medium — but it is a clear defense-in-depth defect.
M4. purge-spam TOCTOU: emptiness re-checked once as a batch snapshot, not immediately before each delete — races a live push
crates/gitlawb-node/src/admin.rs:221-235—partition_for_delete(:224-226) re-verifies emptiness for all candidates up front, producingto_delete; the actualdelete_repo_by_idruns later in a loop (:235). Between the snapshot and a given delete, a concurrentgit-receive-packon the running node can add a ref, making the repo non-empty — yet it is still deleted (silent data loss). The comment claims it "re-verify[s] emptiness immediately before deleting," but the recheck is a single batch pass, not a per-candidate check immediately beforedelete_repo_by_id.- The execute path also does not acquire the node's
acquire_writeadvisory lock (api/repos.rs:927), so it races a live push: a push can leave orphaned Tigris objects / ref-certificate rows referencing a now-deletedrepos.id, andremove_dir_allcan race an in-flightreceive-packwritingobjects/. - Fix: re-verify
ref_count_on_diskimmediately before eachdelete_repo_by_id(or wrap recheck+delete of each repo in arepo_store.acquire_writeguard / DB transaction re-checking a ref signal). Document that the tool must be run against a quiesced node for the target DID.
M5. iCaptcha gate fails open in enforce mode when the public key cannot load
crates/gitlawb-node/src/icaptcha.rs:302-304— insidedecide,if v.key.is_none() { return Decision::Allow; }runs before the mode match, so inMode::Enforcea missing key (iCaptcha unreachable at startup, DNS/BGP block, badICAPTCHA_PUBKEY) allows every create/registration request with no proof.init()(icaptcha.rs:160-166) leaveskey = Noneand only warns. Since the gate defaults tooff, the default deployment already has no captcha; enforce-mode is now trivially bypassable by denying the key fetch, directly weakening the abuse protection this PR leans on after PoW removal.- Fix: in enforce mode fail closed (reject) when
key.is_none(); or refuse to start in enforce mode without a key. At minimum document that enforce+no-key == fail-open.
M6. purge-spam deletes the DB row, then removes the dir; a disk failure is swallowed while the deleted counter is already incremented
crates/gitlawb-node/src/admin.rs:235-248— onOk(n)the row is counted asdeletedbeforeremove_dir_all, which on error onlywarn!s. The final "deleted N repo row(s)" summary therefore reports success even when the on-disk repo survives → DB/disk drift and a misleading operator-facing count. No retry / non-zero exit signal.- Fix: remove the on-disk dir first (or track disk-failure separately from the DB-deleted count) and surface "N deleted, M failed."
M7. delete_repo_by_id (and purge-spam) leave dependent rows orphaned
crates/gitlawb-node/src/db/mod.rs:1288-1294—DELETE FROM repos WHERE id = $1with no cascade. Child tables keyed onrepo_id(branch_cids,ref_certificates,visibility_rules,repo_stars,encrypted_blobs,received_ref_updates,repo_replicas,arweave_anchors,webhooks,pull_requests) keep orphan rows that accumulate and can be re-joined if a repo with the same id is later created.- Fix: add
ON DELETE CASCADE(or explicit child deletes) for therepo_idFKs, ideally within the same transaction as the row delete.
Low / Info — nice to have
- L1. Rate-limiter bounded-map DoS.
rate_limit.rs:96-114rejects new keys when at the 200k cap; a distinct-key flood (IPv6 variation or the M2 header rotation) then 429s legitimate new clients for up to a full window. Consider evicting expired entries to admit new keys, or a metric on cap-full rejections. - L2. Limiter state is per-process / in-memory (
rate_limit.rs:24-41). In multi-machine Fly deployments the effective per-IP/per-DID limit isN × configuredand a restart wipes buckets. Known tradeoff — document explicitly; consider shared counters for the spam-relevant paths. - L3. Non-atomic
create_repo.api/repos.rs:203-236check-then-insert with noUNIQUE(owner_did, name); theidx_repos_owner_key_nameis a plain index (db/mod.rs:474-490). A concurrent create race surfaces a rawUNIQUE(disk_path)violation mapped to a generic 500 instead ofRepoExists(409). - L4. Read handlers mask git errors as empty
200 OK.api/repos.rs:361(store::log(...).unwrap_or_default())and theget_treesiblings coerce real backend failures into empty lists, unlikeget_blob(api/repos.rs:398-410) which classifies errors. MapErrtoAppError::Git/NotFound. - L5.
parse_ref_updatessilently tolerates malformed/truncated bodies (api/repos.rs:1651-1699) → emptyVec<RefUpdate>; receive-pack then proceeds with zero ref updates (skipping branch-protection/owner-push checks). Return400on a non-empty body that parses short. - L6. Push bookkeeping errors swallowed (
api/repos.rs:959, 977, 982, 999-1005let _ =/ only-warn!) — transient DB blips silently loserecord_push/update_trust_score. At least raise log severity / add a durable outbox. - L7.
git_info_refsreceive-pack brake skipped whenclient_keyreturnsNone(api/repos.rs:556/rate_limit.rs:198): if no trusted header and noConnectInfopeer, theif let Some(key)guard is skipped and the request proceeds unthrottled. Unreachable in prod (socket peer always present) but a divergent contract fromrate_limit_by_ip; document or reject instead of skip. - L8. Malformed rate-limit env vars silently fall back to defaults.
main.rs:304-307, 325-328, 343-346parseGITLAWB_CREATE/WRITE/PUSH_RATE_LIMITwith.ok().and_then(parse).unwrap_or(default); a fat-fingered value silently disables the intended (stricter) brake with no warning, unlike the clap-validated siblings. Warn (or fail fast) on present-but-unparseable values. - L9.
.env.exampledocs drift. MissingGITLAWB_IPFS_API,GITLAWB_TIGRIS_BUCKET,GITLAWB_METRICS_ADDR,GITLAWB_SHUTDOWN_GRACE_SECS(parsed inconfig.rs:88,161,188,194). Reverse direction is clean. Add with defaults. - L10. Weak dry-run safety test.
admin.rs:471-492(dry_run_deletes_nothing) seeds repos with an emptyrepos_dir, sorun_purge_spamhits the no-candidate early return before any delete logic — it does not exercise theif !execute { return }guard when candidates exist. Add a dry-run test with a real empty on-disk target repo so a regression that deletes in dry-run is caught. - L11. Unauthenticated
/metrics(main.rs:828-875,config.rs:187warns it is unauthenticated). If bound to a non-loopback addr it leaks peer/repo/push counts. Operator-controlled; note in hardening guidance. - L12. Git error strings returned to clients (
AppError::Git(e.to_string())sites, e.g.api/repos.rs:398-410). Can surface internal paths; return opaque messages, keep detail in server-side logs.
What
Two related pieces of the creation-surface abuse-resistance work:
A per-IP write-surface rate brake. Every authenticated non-creation write sink now sits behind a per-client-IP flood brake on its own bucket: pull/issue comments, labels, stars, merges, protect/unprotect, replicas, visibility, agent deregister, tasks, bounties, profile, and GraphQL mutations. Previously these route groups mounted with no rate limit at all, so a single-source flood on any of them was uncounted (only repo/agent creation, git-push, and peer-sync were braked). The bucket is configured by
GITLAWB_WRITE_RATE_LIMIT(default 600/hr,0disables), keyed on the resolved client IP via the existingGITLAWB_TRUSTED_PROXYboundary.A
purge-spamadmin subcommand for cleaning up a specific empty spam-burst that predates the mirror bot. Dry-run by default;--executedeletes.Key decisions
sync_triggervspeer_writesplit). A shared bucket would also self-throttle a legitimate session doing repo + issues + comments.did:keyfarm never trips a per-DID limit (the same reasongit-receive-packdeliberately omits it), and a per-DID brake would false-positive busy legitimate agents./graphqlrequest;/graphql/wssubscriptions stay unbraked.MutationRootwas the largest previously-uncounted write vector.Verification
cargo test --workspacegreen;cargo fmt --all --checkandcargo clippy --workspace --all-targets -D warningsclean.TrustedProxycoverage: a spoofed leftmostX-Forwarded-Forhop cannot rotate the bucket key, distinct trusted hops get distinct buckets, and an absent header falls back to the socket peer.Scope
This is the write-surface brake coverage plus the burst cleanup. iCaptcha enforcement, storage/size quotas, and moving the gate toward enforce-by-default are separate follow-ups, not in this PR.
Summary by CodeRabbit
New Features
GITLAWB_WRITE_RATE_LIMIT.purge-spamadministrator command with safe dry-run behavior and optional execution.Bug Fixes