Skip to content

Commit 2df6ff9

Browse files
kevincodex1claudecoderabbitai[bot]
authored
fix(node): close two spam-vector root causes (trust upsert + ungated push) (#152)
* fix(node): close two spam-vector root causes (trust upsert + ungated push) The June 2026 spam wave kept re-materializing after cleanup because two paths let an unauthenticated-by-captcha actor keep operating: 1. `update_trust_score` was an upsert. Any authenticated push/issue/PR from a DID with no agent row silently INSERTed one with a fresh `registered_at`, bypassing the iCaptcha gate on /api/register — so deregistered spam DIDs came back the moment they pushed. Make it an UPDATE-only no-op for unregistered DIDs; registration is the sole way into the agents table. Regression test added. 2. `git-receive-pack` had no rate limit. The per-DID limiter on the creation routes can't brake a push flood from a DID farm (one throwaway identity per repo), so the node absorbed several pushes/sec per IP, each a Tigris round-trip + peer-notify fan-out. Add a per- client-IP limiter (Fly-Client-IP / X-Forwarded-For) on the push path, default 600/h, `GITLAWB_PUSH_RATE_LIMIT` override, 0 disables. Fails open without a proxy header so self-hosted nodes aren't broken. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(node): address PR review — advertisement throttle, trusted-proxy IP key, bounded limiter Resolves the review findings on the push rate limiter: P1 — advertisement path bypassed the limiter. A push hits GET info/refs?service=git-receive-pack first, which calls acquire_fresh() (a Tigris download); it sat in git_read_routes under only optional_signature, so the flood was reachable via the cheap GET without ever sending the throttled POST. Now git_info_refs applies the same per-IP brake before the fresh acquire. P1 — client-supplied forwarded headers were trusted. The key came from Fly-Client-IP / the first X-Forwarded-For hop, both client-settable, so a flooder could rotate the header and never fill a bucket. Replaced with a TrustedProxy policy (GITLAWB_TRUSTED_PROXY = fly | x-forwarded-for | unset): trust only the operator's edge header, fall back to the real socket peer (ConnectInfo) otherwise. Fly config trusts Fly-Client-IP; the AWS/Caddy compose trusts the rightmost X-Forwarded-For hop. P2 — unbounded limiter key set / insert-before-reject. Added a max_keys cap (reject-before-insert, inline eviction when full) so a varied key cannot grow the map, and a rejected request never allocates. Applied to both the per-IP and per-DID limiters. P2 — malformed forwarded header disabled the brake. Empty leading XFF hop returned None and skipped the limiter; empty Fly-Client-IP returned Some(""). client_key now takes the rightmost XFF hop and always falls back to the peer, so a malformed header can never turn the limiter off. P2 — missing tests. Added: middleware 429 path, per-peer isolation, key-cap eviction/rejection, trusted-proxy header resolution, and an integration test asserting the receive-pack advertisement is throttled before the acquire. Also serve with into_make_service_with_connect_info so the peer address is available. New env: GITLAWB_PUSH_RATE_LIMIT, GITLAWB_TRUSTED_PROXY (documented in .env.example). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update infra/aws/compose.yaml.tftpl Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix(node): amortize the rate limiter's capacity eviction scan `RateLimiter::check` ran a full O(max_keys) `retain` sweep on every new-key miss once the map was at capacity — which is exactly the distinct-key-flood state the cap defends against, so the limiter serialized all traffic behind a 200k-entry scan per request under the mutex. Gate the inline sweep to run at most once per `sweep_interval` (min of the window and 1s), tracked by `last_sweep`. Between sweeps a new key is rejected without scanning; the background `cleanup()` loop still reclaims independently. The fast path for existing keys is unchanged, the key cap invariant holds (map never exceeds max_keys, rejected keys never allocate), and post-flood self-healing stays bounded to one interval. Added a test asserting a burst of capacity misses does not trigger repeated eviction scans. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 2620e97 commit 2df6ff9

12 files changed

Lines changed: 627 additions & 44 deletions

File tree

‎.env.example‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@ GITLAWB_PUBLIC_READ=true
9090
# Maximum git smart-HTTP pack request size, in bytes.
9191
GITLAWB_MAX_PACK_BYTES=2147483648
9292

93+
# ── Push rate limiting (git-receive-pack flood brake) ─────────────────────
94+
# Max receive-pack requests (info/refs advertisement + push POST) per client
95+
# IP per hour. 0 disables. Default 600.
96+
GITLAWB_PUSH_RATE_LIMIT=600
97+
98+
# Which forwarded header the edge is trusted to set, used to resolve the real
99+
# client IP for the push limiter. One of:
100+
# (unset) — no trusted proxy: key on the socket peer address, ignore headers.
101+
# fly — behind Fly's edge (trust Fly-Client-IP).
102+
# x-forwarded-for — behind a single reverse proxy like Caddy/NGINX (trust the
103+
# rightmost X-Forwarded-For hop).
104+
# Only set this when a proxy you control actually fronts the node; trusting a
105+
# forwarded header on a directly-exposed node lets clients spoof the key.
106+
GITLAWB_TRUSTED_PROXY=
107+
93108
# ── Sync ─────────────────────────────────────────────────────────────────
94109
# Enable automatic background sync from known peers
95110
GITLAWB_AUTO_SYNC=false

‎crates/gitlawb-node/src/api/repos.rs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,8 @@ pub async fn git_info_refs(
492492
State(state): State<AppState>,
493493
Path((owner, repo)): Path<(String, String)>,
494494
Query(query): Query<InfoRefsQuery>,
495+
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
496+
headers: axum::http::HeaderMap,
495497
auth: Option<Extension<AuthenticatedDid>>,
496498
) -> Result<Response> {
497499
let name = repo.trim_end_matches(".git");
@@ -533,6 +535,23 @@ pub async fn git_info_refs(
533535
}
534536
}
535537

538+
// Push flood brake on the advertisement phase. A push always hits this
539+
// GET first, and for receive-pack it forces a fresh Tigris download below;
540+
// throttling only the receive-pack POST would leave the expensive
541+
// fresh-acquire reachable unauthenticated and unlimited. Applied before the
542+
// acquire so a rejected request does no Tigris work. Same per-IP limiter and
543+
// trusted-proxy policy as the POST middleware (shared buckets).
544+
if service == "git-receive-pack" {
545+
if let Some(key) = crate::rate_limit::client_key(&headers, peer, state.push_limiter_trust) {
546+
if !state.push_rate_limiter.check(&key).await {
547+
tracing::warn!(repo = %name, key = %key, "receive-pack advertisement rate limited");
548+
return Err(AppError::TooManyRequests(
549+
"push rate limit exceeded — try again later".into(),
550+
));
551+
}
552+
}
553+
}
554+
536555
// For receive-pack (push), download the latest from Tigris so the client
537556
// sees the same refs that acquire_write() will operate on.
538557
let disk_path = if service == "git-receive-pack" {
@@ -2430,4 +2449,49 @@ mod tests {
24302449
response_non_key.clone_url
24312450
);
24322451
}
2452+
2453+
/// The receive-pack *advertisement* (`GET info/refs?service=git-receive-pack`)
2454+
/// must be throttled by the per-IP push limiter BEFORE it does the fresh
2455+
/// Tigris acquire — otherwise the flood brake on the POST is bypassable via
2456+
/// the cheaper unauthenticated GET (PR #152 review P1). Pre-filling the
2457+
/// bucket makes the assertion deterministic and keeps the test off the
2458+
/// acquire path entirely.
2459+
#[sqlx::test]
2460+
async fn receive_pack_advertisement_is_rate_limited(pool: sqlx::PgPool) {
2461+
use axum::body::Body;
2462+
use axum::extract::ConnectInfo;
2463+
use axum::http::{Method, Request, StatusCode};
2464+
use std::net::SocketAddr;
2465+
use std::time::Duration;
2466+
use tower::ServiceExt;
2467+
2468+
let mut state = crate::test_support::test_state(pool).await;
2469+
// Tiny limit, keyed on the socket peer (no trusted proxy).
2470+
state.push_rate_limiter = crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
2471+
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
2472+
state
2473+
.db
2474+
.upsert_mirror_repo("z6advowner", "adv", "/tmp/adv", None, false)
2475+
.await
2476+
.unwrap();
2477+
2478+
let peer: SocketAddr = "203.0.113.55:6000".parse().unwrap();
2479+
// Exhaust this peer's single-request budget up front.
2480+
assert!(state.push_rate_limiter.check(&peer.ip().to_string()).await);
2481+
2482+
let router = crate::server::build_router(state);
2483+
let mut req = Request::builder()
2484+
.method(Method::GET)
2485+
.uri("/z6advowner/adv/info/refs?service=git-receive-pack")
2486+
.body(Body::empty())
2487+
.unwrap();
2488+
req.extensions_mut().insert(ConnectInfo(peer));
2489+
2490+
let status = router.oneshot(req).await.unwrap().status();
2491+
assert_eq!(
2492+
status,
2493+
StatusCode::TOO_MANY_REQUESTS,
2494+
"receive-pack advertisement must be throttled before the Tigris acquire"
2495+
);
2496+
}
24332497
}

‎crates/gitlawb-node/src/auth/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,8 @@ mod tests {
514514
machine_id: None,
515515
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
516516
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
517+
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
518+
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
517519
shutdown_tx: tokio::sync::watch::channel(false).0,
518520
}
519521
}

‎crates/gitlawb-node/src/db/mod.rs‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,18 +1372,18 @@ impl Db {
13721372
Ok(row.map(|r| r.get::<f64, _>("trust_score")).unwrap_or(0.0))
13731373
}
13741374

1375+
/// Update an EXISTING agent's trust score; a no-op for unregistered DIDs.
1376+
/// Deliberately never inserts: the only path into the agents table is
1377+
/// `register_agent`, which sits behind the iCaptcha gate on /api/register.
1378+
/// This used to be an upsert, which let any authenticated push/issue/PR
1379+
/// re-create a deregistered DID's row with a fresh `registered_at`,
1380+
/// bypassing the registration gate entirely.
13751381
pub async fn update_trust_score(&self, agent_did: &str, score: f64) -> Result<()> {
1376-
let now = Utc::now().to_rfc3339();
1377-
sqlx::query(
1378-
"INSERT INTO agents (did, trust_score, capabilities, registered_at)
1379-
VALUES ($1, $2, '[]', $3)
1380-
ON CONFLICT(did) DO UPDATE SET trust_score = $2",
1381-
)
1382-
.bind(agent_did)
1383-
.bind(score)
1384-
.bind(&now)
1385-
.execute(&self.pool)
1386-
.await?;
1382+
sqlx::query("UPDATE agents SET trust_score = $2 WHERE did = $1")
1383+
.bind(agent_did)
1384+
.bind(score)
1385+
.execute(&self.pool)
1386+
.await?;
13871387
Ok(())
13881388
}
13891389

@@ -4302,6 +4302,27 @@ mod icaptcha_quarantine_tests {
43024302
let with_stars = db.list_all_repos_deduped_with_stars(None).await.unwrap();
43034303
assert!(with_stars.iter().all(|(r, _)| r.name != "spam"));
43044304
}
4305+
4306+
/// `update_trust_score` must never create an agent row. Registration (behind
4307+
/// the iCaptcha gate) is the only way in; otherwise a push/issue/PR from a
4308+
/// deregistered DID would silently re-register it and bypass the gate.
4309+
#[sqlx::test]
4310+
async fn update_trust_score_never_creates_agent(pool: PgPool) {
4311+
let db = db(pool).await;
4312+
let did = "did:key:zNeverRegistered";
4313+
4314+
// Unregistered DID: updating its score is a no-op, not an insert.
4315+
db.update_trust_score(did, 0.9).await.unwrap();
4316+
assert!(
4317+
db.get_agent(did).await.unwrap().is_none(),
4318+
"update_trust_score must not resurrect an unregistered DID"
4319+
);
4320+
4321+
// Once genuinely registered, the score updates in place.
4322+
db.register_agent(did, &[]).await.unwrap();
4323+
db.update_trust_score(did, 0.9).await.unwrap();
4324+
assert_eq!(db.get_trust_score(did).await.unwrap(), 0.9);
4325+
}
43054326
}
43064327

43074328
#[cfg(test)]

‎crates/gitlawb-node/src/error.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ pub enum AppError {
2929
#[error("invalid request: {0}")]
3030
BadRequest(String),
3131

32+
#[error("too many requests: {0}")]
33+
TooManyRequests(String),
34+
3235
#[error("git error: {0}")]
3336
Git(String),
3437

@@ -65,6 +68,9 @@ impl IntoResponse for AppError {
6568
msg.clone(),
6669
),
6770
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
71+
AppError::TooManyRequests(msg) => {
72+
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg.clone())
73+
}
6874
AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()),
6975
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
7076
AppError::Internal(e) => (

‎crates/gitlawb-node/src/main.rs‎

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,36 @@ async fn main() -> Result<()> {
182182
let repo_store =
183183
git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, db.pool().clone());
184184

185-
let rate_limiter = rate_limit::RateLimiter::new(10, std::time::Duration::from_secs(3600));
185+
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
186+
// DID (attacker-varied), so bound its key set to cap memory.
187+
let rate_limiter =
188+
rate_limit::RateLimiter::new_bounded(10, std::time::Duration::from_secs(3600), 200_000);
189+
190+
// Push-path flood brake: max git-receive-pack requests per client IP per
191+
// hour (counts both the info/refs advertisement and the push POST). Sized
192+
// for heavy agent automation while still stopping flood traffic (the June
193+
// 2026 attack pushed several times per second per IP). GITLAWB_PUSH_RATE_LIMIT
194+
// overrides; 0 disables. Bounded key set — the key is a client-influenced IP.
195+
let push_limit = std::env::var("GITLAWB_PUSH_RATE_LIMIT")
196+
.ok()
197+
.and_then(|v| v.trim().parse::<usize>().ok())
198+
.unwrap_or(600);
199+
let push_rate_limiter = rate_limit::RateLimiter::new_bounded(
200+
push_limit,
201+
std::time::Duration::from_secs(3600),
202+
200_000,
203+
);
204+
if push_limit == 0 {
205+
tracing::warn!("GITLAWB_PUSH_RATE_LIMIT=0 — per-IP push rate limiting disabled");
206+
}
207+
208+
// Which forwarded header the edge is trusted to set. Default None (trust
209+
// nothing, key on the socket peer). Fly nodes set GITLAWB_TRUSTED_PROXY=fly;
210+
// a node behind Caddy/NGINX sets it to x-forwarded-for.
211+
let push_limiter_trust = rate_limit::TrustedProxy::from_env_value(
212+
&std::env::var("GITLAWB_TRUSTED_PROXY").unwrap_or_default(),
213+
);
214+
tracing::info!(trust = ?push_limiter_trust, push_limit, "push rate limiter configured");
186215

187216
// Initialize the iCaptcha proof gate (inert unless ICAPTCHA_MODE is set).
188217
icaptcha::init().await;
@@ -200,6 +229,8 @@ async fn main() -> Result<()> {
200229
machine_id,
201230
repo_store,
202231
rate_limiter,
232+
push_rate_limiter,
233+
push_limiter_trust,
203234
shutdown_tx: shutdown_tx.clone(),
204235
};
205236

@@ -260,13 +291,15 @@ async fn main() -> Result<()> {
260291
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
261292
{
262293
let rl = state.rate_limiter.clone();
294+
let push_rl = state.push_rate_limiter.clone();
263295
let db = state.db.clone();
264296
let mut shutdown_rx = state.subscribe_shutdown();
265297
tokio::spawn(async move {
266298
loop {
267299
tokio::select! {
268300
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
269301
rl.cleanup().await;
302+
push_rl.cleanup().await;
270303
let now = std::time::SystemTime::now()
271304
.duration_since(std::time::UNIX_EPOCH)
272305
.map(|d| d.as_secs() as i64)
@@ -391,19 +424,25 @@ async fn main() -> Result<()> {
391424
let grace = std::time::Duration::from_secs(config.shutdown_grace_secs);
392425
info!(grace_secs = config.shutdown_grace_secs, "axum server ready");
393426

394-
let serve_result = axum::serve(listener, router)
395-
.with_graceful_shutdown(async move {
396-
let mut rx = shutdown_signal_for_axum;
397-
// Wait until the watcher flips to true, then return so axum
398-
// can begin draining.
399-
while !*rx.borrow_and_update() {
400-
if rx.changed().await.is_err() {
401-
// Sender dropped — treat as shutdown.
402-
break;
403-
}
427+
// `into_make_service_with_connect_info` exposes the socket peer address as
428+
// `ConnectInfo<SocketAddr>` so the push limiter can key on the real client
429+
// when no trusted proxy header applies (see `rate_limit::client_key`).
430+
let serve_result = axum::serve(
431+
listener,
432+
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
433+
)
434+
.with_graceful_shutdown(async move {
435+
let mut rx = shutdown_signal_for_axum;
436+
// Wait until the watcher flips to true, then return so axum
437+
// can begin draining.
438+
while !*rx.borrow_and_update() {
439+
if rx.changed().await.is_err() {
440+
// Sender dropped — treat as shutdown.
441+
break;
404442
}
405-
})
406-
.await;
443+
}
444+
})
445+
.await;
407446

408447
// Server has stopped accepting new connections and drained in-flight
409448
// requests. Tear the rest of the system down.

0 commit comments

Comments
 (0)