Skip to content

Commit dfcaa22

Browse files
kevincodex1coderabbitai[bot]CodeRabbit
authored
fix(node): rate-limit repo/agent creation per client IP to stop DID-farm spam floods (#180)
* fix(node): add per-IP flood brake to repo/agent creation endpoints The recurring spam-repo floods are not bypassing the iCaptcha gate — they pass it. The enforced level (3) draws only machine-solvable deterministic challenges (arithmetic/algebra/sequence), and the /v1/challenge API lets the caller pin the easy type, so a bot mints a fresh throwaway did:key, solves a real proof bound to that DID, and creates a repo. The per-DID creation limiter never trips because each DID makes a single create call. Add a per-client-IP rate limiter to the creation route group (repos, register, fork, issues, pulls), reusing the same IpRateLimiter the push path already uses against the identical DID-farm bypass. It wraps the auth layer (outermost) so flood traffic is rejected before signature verification. Default 120/hour/IP, GITLAWB_CREATE_RATE_LIMIT overrides, 0 disables. * docs(env): document GITLAWB_CREATE_RATE_LIMIT in .env.example * fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit <noreply@coderabbit.ai> * test(node): unit-test the per-IP creation brake runs before auth The route-level coverage (repo_creation_is_rate_limited_by_ip) is #[sqlx::test] and needs Postgres, so it is skipped in the plain unit-test pass. Add a DB-free middleware test that layers the per-IP creation limiter over an always-401 auth stand-in and asserts an exhausted IP is rejected with 429 before auth runs — the ordering the DID-farm flood defense relies on. --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
1 parent 2109d08 commit dfcaa22

8 files changed

Lines changed: 168 additions & 2 deletions

File tree

‎.env.example‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ GITLAWB_GIT_SERVICE_TIMEOUT_SECS=600
118118
# IP per hour. 0 disables. Default 600.
119119
GITLAWB_PUSH_RATE_LIMIT=600
120120

121+
# ── Creation rate limiting (repo/agent/issue/PR flood brake) ──────────────
122+
# Max creation requests (POST /api/v1/repos, /api/register, fork, issues,
123+
# pulls) per client IP per hour, in addition to the per-DID limit. The per-DID
124+
# limit alone is bypassed by a did:key farm (one throwaway identity per repo),
125+
# which is how spam-repo floods slip past it and the iCaptcha gate; the per-IP
126+
# brake caps a single-source flood. Uses GITLAWB_TRUSTED_PROXY below to resolve
127+
# the client IP. 0 disables. Default 120.
128+
GITLAWB_CREATE_RATE_LIMIT=120
129+
121130
# ── Peer-sync rate limiting (per client IP, uses GITLAWB_TRUSTED_PROXY below) ─
122131
# /api/v1/peers/announce and /api/v1/sync/notify accept unsigned requests from
123132
# known peers and run at higher frequency, so a generous bucket. Separate from

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2578,4 +2578,51 @@ mod tests {
25782578
"receive-pack advertisement must be throttled before the Tigris acquire"
25792579
);
25802580
}
2581+
2582+
/// Repo creation must be throttled by the per-IP creation limiter BEFORE
2583+
/// signature verification — otherwise a DID farm (one throwaway did:key per
2584+
/// repo, each carrying a valid but machine-solved iCaptcha proof) walks past
2585+
/// the per-DID limiter and floods the network, as in the recurring spam-repo
2586+
/// incidents. A 429 (not a 401) on an unsigned request from an exhausted IP
2587+
/// proves the IP brake runs outermost, ahead of auth.
2588+
#[sqlx::test]
2589+
async fn repo_creation_is_rate_limited_by_ip(pool: sqlx::PgPool) {
2590+
use axum::body::Body;
2591+
use axum::extract::ConnectInfo;
2592+
use axum::http::{Method, Request, StatusCode};
2593+
use std::net::SocketAddr;
2594+
use std::time::Duration;
2595+
use tower::ServiceExt;
2596+
2597+
let mut state = crate::test_support::test_state(pool).await;
2598+
// Tiny limit, keyed on the socket peer (no trusted proxy).
2599+
state.create_ip_rate_limiter =
2600+
crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
2601+
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
2602+
2603+
let peer: SocketAddr = "203.0.113.77:7000".parse().unwrap();
2604+
// Exhaust this peer's single-request budget up front.
2605+
assert!(
2606+
state
2607+
.create_ip_rate_limiter
2608+
.check(&peer.ip().to_string())
2609+
.await
2610+
);
2611+
2612+
let router = crate::server::build_router(state);
2613+
let mut req = Request::builder()
2614+
.method(Method::POST)
2615+
.uri("/api/v1/repos")
2616+
.header("content-type", "application/json")
2617+
.body(Body::from(r#"{"name":"flood","is_public":true}"#))
2618+
.unwrap();
2619+
req.extensions_mut().insert(ConnectInfo(peer));
2620+
2621+
let status = router.oneshot(req).await.unwrap().status();
2622+
assert_eq!(
2623+
status,
2624+
StatusCode::TOO_MANY_REQUESTS,
2625+
"repo creation must be IP-throttled before signature verification"
2626+
);
2627+
}
25812628
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,7 @@ 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+
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
517518
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
518519
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
519520
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,26 @@ async fn main() -> Result<()> {
287287
let rate_limiter =
288288
rate_limit::RateLimiter::new_bounded(10, std::time::Duration::from_secs(3600), 200_000);
289289

290+
// Per-client-IP flood brake for the creation endpoints. The per-DID limiter
291+
// above is bypassed by a DID farm (one throwaway did:key per repo), which is
292+
// exactly how the recurring spam-repo floods get past both it and the
293+
// iCaptcha gate. Keyed on the resolved client IP so a single-source flood is
294+
// capped regardless of how many identities it mints. Sized well above any
295+
// legitimate per-IP creation rate; GITLAWB_CREATE_RATE_LIMIT overrides, 0
296+
// disables. Bounded key set — the key is a client-influenced IP.
297+
let create_limit = std::env::var("GITLAWB_CREATE_RATE_LIMIT")
298+
.ok()
299+
.and_then(|v| v.trim().parse::<usize>().ok())
300+
.unwrap_or(120);
301+
let create_ip_rate_limiter = rate_limit::RateLimiter::new_bounded(
302+
create_limit,
303+
std::time::Duration::from_secs(3600),
304+
200_000,
305+
);
306+
if create_limit == 0 {
307+
tracing::warn!("GITLAWB_CREATE_RATE_LIMIT=0 — per-IP creation rate limiting disabled");
308+
}
309+
290310
// Push-path flood brake: max git-receive-pack requests per client IP per
291311
// hour (counts both the info/refs advertisement and the push POST). Sized
292312
// for heavy agent automation while still stopping flood traffic (the June
@@ -352,6 +372,7 @@ async fn main() -> Result<()> {
352372
machine_id,
353373
repo_store,
354374
rate_limiter,
375+
create_ip_rate_limiter,
355376
push_rate_limiter,
356377
push_limiter_trust,
357378
sync_trigger_rate_limiter,
@@ -388,6 +409,7 @@ async fn main() -> Result<()> {
388409
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
389410
{
390411
let rl = state.rate_limiter.clone();
412+
let create_ip_rl = state.create_ip_rate_limiter.clone();
391413
let push_rl = state.push_rate_limiter.clone();
392414
let sync_trigger_rl = state.sync_trigger_rate_limiter.clone();
393415
let peer_write_rl = state.peer_write_rate_limiter.clone();
@@ -398,6 +420,7 @@ async fn main() -> Result<()> {
398420
tokio::select! {
399421
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
400422
rl.cleanup().await;
423+
create_ip_rl.cleanup().await;
401424
push_rl.cleanup().await;
402425
sync_trigger_rl.cleanup().await;
403426
peer_write_rl.cleanup().await;

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,4 +543,66 @@ mod tests {
543543
assert_eq!(post_from(&router, b).await, StatusCode::OK); // independent bucket
544544
assert_eq!(post_from(&router, a).await, StatusCode::TOO_MANY_REQUESTS);
545545
}
546+
547+
// ── creation-route layering: IP brake runs BEFORE auth ──────────────
548+
549+
/// A minimal stand-in for the creation route group: an inner "auth" layer
550+
/// that rejects every request with 401 (as signature verification would for
551+
/// an unsigned caller), wrapped by the per-IP creation limiter as the
552+
/// outermost layer — exactly the ordering `server::build_router` applies to
553+
/// `/api/v1/repos` et al. Lets us assert the security-relevant property
554+
/// (flood traffic is braked before auth burns CPU) without a database, which
555+
/// the full `#[sqlx::test]` route test in `api::repos` cannot cover in the
556+
/// plain unit-test pass.
557+
fn ip_limited_over_auth_router(limiter: IpRateLimiter) -> axum::Router {
558+
async fn always_unauthorized(_req: Request, _next: Next) -> Response {
559+
StatusCode::UNAUTHORIZED.into_response()
560+
}
561+
axum::Router::new()
562+
.route(
563+
"/api/v1/repos",
564+
axum::routing::post(|| async { StatusCode::CREATED }),
565+
)
566+
.layer(axum::middleware::from_fn(always_unauthorized))
567+
.layer(axum::middleware::from_fn(rate_limit_by_ip))
568+
.layer(axum::Extension(limiter))
569+
}
570+
571+
async fn post_repos_from(router: &axum::Router, peer: SocketAddr) -> StatusCode {
572+
use tower::ServiceExt;
573+
let mut req = axum::http::Request::builder()
574+
.method(axum::http::Method::POST)
575+
.uri("/api/v1/repos")
576+
.body(axum::body::Body::empty())
577+
.unwrap();
578+
req.extensions_mut().insert(ConnectInfo(peer));
579+
router.clone().oneshot(req).await.unwrap().status()
580+
}
581+
582+
#[tokio::test]
583+
async fn creation_ip_brake_rejects_before_auth() {
584+
// A DID farm mints a fresh signature per repo, so the per-DID limiter
585+
// never trips; the per-IP brake is what stops the flood. It must run
586+
// outermost, ahead of auth, so a flood is rejected with 429 before
587+
// signature verification — otherwise every request would reach (and pay
588+
// for) auth and merely 401.
589+
let router = ip_limited_over_auth_router(IpRateLimiter {
590+
limiter: RateLimiter::new(1, Duration::from_secs(60)),
591+
trust: TrustedProxy::None,
592+
});
593+
let peer: SocketAddr = "203.0.113.42:7000".parse().unwrap();
594+
595+
// First request passes the IP brake and reaches the (failing) auth layer.
596+
assert_eq!(
597+
post_repos_from(&router, peer).await,
598+
StatusCode::UNAUTHORIZED
599+
);
600+
// Budget now exhausted: the IP brake short-circuits with 429 *before*
601+
// auth — proving the limiter is the outermost layer.
602+
assert_eq!(
603+
post_repos_from(&router, peer).await,
604+
StatusCode::TOO_MANY_REQUESTS,
605+
"an exhausted IP must be braked with 429 before auth runs, not leak to 401"
606+
);
607+
}
546608
}

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,19 @@ pub fn build_router(state: AppState) -> Router {
8181
.route("/api/v1/tasks", get(tasks::list_tasks))
8282
.route("/api/v1/tasks/{id}", get(tasks::get_task));
8383

84-
// ── Rate-limited creation routes — require HTTP Signature + per-DID throttle
84+
// ── Rate-limited creation routes — require HTTP Signature, plus a per-DID
85+
// throttle AND a per-IP flood brake. The per-DID limiter (inner) caps a
86+
// single identity; the per-IP limiter (outer) caps a DID farm that mints a
87+
// fresh throwaway did:key per repo to slip past both the per-DID limit and
88+
// the iCaptcha gate — the mechanism behind the recurring spam-repo floods.
89+
// The per-IP layer wraps the auth layer (outermost = runs first) so flood
90+
// traffic is rejected before signature verification burns CPU, matching the
91+
// push path.
8592
let limiter = state.rate_limiter.clone();
93+
let create_ip_limiter = rate_limit::IpRateLimiter {
94+
limiter: state.create_ip_rate_limiter.clone(),
95+
trust: state.push_limiter_trust,
96+
};
8697
let creation_routes = add_auth_layers(
8798
Router::new()
8899
.route("/api/v1/repos", post(repos::create_repo))
@@ -96,7 +107,9 @@ pub fn build_router(state: AppState) -> Router {
96107
.layer(middleware::from_fn(rate_limit::rate_limit_by_did))
97108
.layer(axum::Extension(limiter)),
98109
state.clone(),
99-
);
110+
)
111+
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
112+
.layer(axum::Extension(create_ip_limiter));
100113

101114
// ── Write routes — require HTTP Signature (no rate limit) ─────────────
102115
let write_routes = add_auth_layers(

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ pub struct AppState {
5151
pub repo_store: RepoStore,
5252
/// Per-DID rate limiter for creation endpoints (repos, issues, PRs)
5353
pub rate_limiter: RateLimiter,
54+
/// Per-client-IP rate limiter for the same creation endpoints. The per-DID
55+
/// limiter above cannot brake a creation flood from a DID farm — one
56+
/// throwaway `did:key` per repo means each DID makes a single create call
57+
/// and never trips its own bucket. A valid iCaptcha proof does not cap this
58+
/// either: the enforced level draws only machine-solvable deterministic
59+
/// challenges (and the caller can pin the easy type), so a bot mints a fresh
60+
/// DID, solves a proof, and creates a repo unthrottled. Braking on the
61+
/// resolved client IP is what actually stops a single-source flood (same
62+
/// rationale as `push_rate_limiter`). Keyed by `push_limiter_trust`.
63+
pub create_ip_rate_limiter: RateLimiter,
5464
/// Per-client-IP rate limiter for git-receive-pack. Per-DID limits cannot
5565
/// brake a push flood from a DID farm (one throwaway DID per repo), so the
5666
/// push path throttles on the resolved client IP instead.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ fn build_state(db: Arc<crate::db::Db>, pool: PgPool) -> AppState {
7676
machine_id: None,
7777
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
7878
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
79+
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
7980
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
8081
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
8182
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),

0 commit comments

Comments
 (0)