Skip to main content

Bot protection and rate limits

Expensive rendered pages — per-commit diffs, history pages, merge-request diffs and search — are metered per caller, and an optional captcha lets a refused human continue. Rate limiting is on by default; the captcha is off by default and needs keys from Cloudflare Turnstile or hCaptcha.

Nothing is persisted: counters and the "already solved" pass live in memory and in a signed cookie, so there are no new tables and no migrations.

What is metered

PathWhy
GET /repos/{owner}/{name}/commit/{id}parses the commit and builds a full tree-to-tree diff
GET /repos/{owner}/{name}/commits[/{ref}]revwalk over the ref
GET /repos/{owner}/{name}/merge-requests/{number}renders the branch diff
GET /searchrepository + people search across the instance

Only GET is metered. Everything else is untouched — notably the git smart-HTTP transport, /api/v1, the runner.v1 CI endpoints, the MCP server and the ActivityPub/ForgeFed endpoints. Those authenticate their own callers and are driven by tools that cannot solve a challenge, so a limit there would break clones, Renovate and federation rather than stop a crawler.

Budgets

Each caller gets a fixed window (default 1 minute) and a budget inside it:

CallerKeyDefault budget
Anonymousclient IP30 per window
Anonymous, passed the checkthe pass itself120 per window
Logged inuser account120 per window

The defaults are deliberately close to what a person browsing quickly can produce and far below what a scraper wants: 30 per minute is one expensive page every two seconds, sustained. Raise them if your instance serves an audience that genuinely browses faster — but raise them knowingly, because the default that never annoys anyone also never stops anyone.

The client IP is read from the Vert.x remote address, which already honours X-Forwarded-For because quarkus.http.proxy.allow-x-forwarded is enabled. Make sure your reverse proxy sets X-Forwarded-For — without it every visitor behind the proxy shares one anonymous budget.

Counters are per instance and not replicated. With several replicas each pod enforces its own share of the budget, which is enough to stop a crawler hammering one node; if you need a global limit, do it at the ingress.

Over budget

  • No captcha configured429 Too Many Requests with a Retry-After header set to the window length, and a short plain-text body.
  • Captcha configured, anonymous visitor303 See Other to /challenge?redirect=<original path>. Solving the widget mints a signed pass cookie (gitshark_human, HttpOnly, SameSite=Lax, Secure over HTTPS) that raises the visitor to the user budget for pass-duration, metered against the pass itself rather than the client IP.
  • Already on the raised budget (logged in, or carrying a pass) → plain 429. A second challenge would only hand out a fresh budget, and challenging a caller who cannot improve their standing loops forever.

A solved check raises the budget; it does not remove it. A bypass would turn one captcha solve — a few tenths of a cent at a solving farm — into a window of entirely unmetered scraping, which is precisely the traffic the guard exists to stop.

The pass is <expiry-epoch-seconds>.<HMAC-SHA256>, signed with a key derived from the captcha secret key — no server-side session state, so it survives restarts and works across pods. A forged, re-signed or expired value is simply ignored.

Configuration

VariableDefaultMeaning
GITSHARK_PROTECT_ENABLEDtrueMaster switch for metering
GITSHARK_PROTECT_ANONYMOUS_LIMIT30Expensive pages per window, per client IP
GITSHARK_PROTECT_USER_LIMIT120Expensive pages per window, per logged-in account — and per solved check
GITSHARK_PROTECT_WINDOW1mWindow length
GITSHARK_PROTECT_CAPTCHA_PROVIDERnonenone, turnstile or hcaptcha
GITSHARK_PROTECT_CAPTCHA_SITE_KEYPublic widget key
GITSHARK_PROTECT_CAPTCHA_SECRET_KEYServer-side key; also signs the pass cookie
GITSHARK_PROTECT_CAPTCHA_VERIFY_URLOverride the provider's siteverify endpoint (testing)
GITSHARK_PROTECT_CAPTCHA_PASS_DURATION15mHow long a solved check keeps the visitor on the user budget

An unknown provider value is treated as none, and a provider without both keys also counts as no captcha: /challenge answers 404 and refusals stay plain 429s. Rate limiting keeps working either way — the guard never depends on a third-party widget being reachable to be able to say no.

Turnstile example

environment:
GITSHARK_PROTECT_CAPTCHA_PROVIDER: turnstile
GITSHARK_PROTECT_CAPTCHA_SITE_KEY: 0x4AAA...
GITSHARK_PROTECT_CAPTCHA_SECRET_KEY: 0x4AAA...
GITSHARK_PROTECT_ANONYMOUS_LIMIT: "20"

With a challenge available the anonymous budget can be tighter than the default: a refused human is one click away from continuing, so 20 costs a visitor almost nothing while halving what a crawler gets for free.

Register the site key for your instance's hostname in the Cloudflare dashboard (hCaptcha: in the hCaptcha dashboard) and keep the secret key out of the image — pass it via the environment or a secret, like GITSHARK_SECRET_KEY.

Outbound network

Token verification is a server-side POST from git-shark to the provider:

  • Turnstile: https://challenges.cloudflare.com/turnstile/v0/siteverify
  • hCaptcha: https://api.hcaptcha.com/siteverify

Allow egress to that host, or challenges can never be solved. Verification fails closed: a timeout, a non-200 or an unparseable body means "not verified", and the visitor is re-shown the check with an error. Connect timeout 5 s, request timeout 10 s.

Browsers additionally load the widget script from https://challenges.cloudflare.com / https://js.hcaptcha.com. If you serve a Content-Security-Policy at the proxy, allow those hosts in script-src and frame-src.

Endpoints

Method & pathAuthPurpose
GET /challenge?redirect=<path>NoneThe check page (404 unless a captcha is fully configured)
POST /challengeNoneVerifies the token, sets the pass cookie, redirects to redirect

redirect is only honoured when it is a single-slash server-relative path; anything else falls back to /, so the challenge cannot be turned into an open redirect.

Tuning and troubleshooting

SymptomCause / fix
Legitimate users hit the check while browsingRaise GITSHARK_PROTECT_ANONYMOUS_LIMIT, or tell users to log in (USER_LIMIT applies then). Check that the proxy forwards X-Forwarded-For — otherwise all visitors share one budget.
Everyone is challenged at once, from one IPThe proxy is not forwarding the real client IP (see above).
/challenge returns 404No provider selected, or one of the two keys is missing.
Check always rejects the tokenWrong secret key, site key not registered for this hostname, or the server cannot reach siteverify (look for captcha siteverify failed / returned HTTP … in the logs).
A visitor is refused right after solving the checkExpected above USER_LIMIT: the pass raises the budget, it does not remove it. Genuine browsing does not reach 120 expensive pages a minute — raise GITSHARK_PROTECT_USER_LIMIT only if it does.
Crawler still hammers one endpointOnly the four paths above are metered by design; block the rest at the ingress.
Want no metering at allGITSHARK_PROTECT_ENABLED=false.

Nothing needs a restart other than the usual config reload: all values are read at startup, so change the environment and recreate the container.