Merge branch 'issue-16-bot-captcha' into main (!12)
Changes
25 files changed, +1705 -1
MODIFY
README.md
+8 -0
@@ -105,6 +105,14 @@
105
105
[architecture](docs/maintainers/ci-runners.md)
106
106
activities from; local users can in turn follow a remote repository — or a whole remote user, whose
107
107
public repositories are then followed and shown grouped — and read their pushes (see below)
108
+- **Bot protection** — the expensive renderings (per-commit diffs, history pages, merge-request
109
+ diffs, search) are metered per caller: anonymous visitors by client IP (60/min by default),
110
+ logged-in users by account (600/min). Over budget the request is refused with `429` +
111
+ `Retry-After`, or — when a **Cloudflare Turnstile / hCaptcha** site is configured — redirected to a
112
+ `/challenge` page whose solved token mints a signed, self-expiring pass cookie that lifts the
113
+ budget. Git transport, `/api/v1`, MCP, `runner.v1` and the ActivityPub endpoints are never metered.
114
+ Guides: [for users](docs/users/bot-check.md), [for admins](docs/admins/bot-protection.md),
115
+ [architecture](docs/maintainers/bot-protection.md)
108
116
109
117
## Federation (ForgeFed)
110
118
MODIFY
docs/README.md
+9 -0
@@ -40,6 +40,9 @@
40
40
tools.
41
41
- **[CI/CD runners](users/ci-runners.md)** — what runners are, what admins can do
42
42
today (register runners), and what workflow execution is still coming.
43
+- **[The "Quick check" page](users/bot-check.md)** — why an expensive page
44
+ sometimes asks you to confirm you are a person, what counts against the budget,
45
+ and why logging in avoids it.
43
46
44
47
### For admins
45
48
@@ -68,6 +71,9 @@
68
71
- **[CI/CD runners](admins/ci-runners.md)** — register Forgejo/Gitea runners via
69
72
the `runner.v1` Connect endpoints: admin handles, registration tokens, the
70
73
`/api/actions` paths, reverse-proxy notes, and the runner tables.
74
+- **[Bot protection and rate limits](admins/bot-protection.md)** — meter the
75
+ expensive renderings per caller, optionally challenge with Turnstile/hCaptcha:
76
+ configuration, proxy requirements, outbound network, troubleshooting.
71
77
- **[Renovate](admins/renovate.md)** — point Renovate's `gitea` platform driver
72
78
at git-shark's Gitea-compatible `/api/v1` for automated dependency-update PRs:
73
79
token, config, and current limitations.
@@ -100,6 +106,9 @@
100
106
- **[CI/CD runner protocol](maintainers/ci-runners.md)** — how the Forgejo/Gitea
101
107
`runner.v1` server side is built (Connect-over-JAX-RS, protobuf codegen), the
102
108
decisions behind it, and the works/gaps list toward full workflow execution.
109
+- **[Bot protection architecture](maintainers/bot-protection.md)** — the request
110
+ filter, fixed-window limiter, signed pass cookie and challenge page, the
111
+ decisions behind them, and the works/gaps list.
103
112
- **[Gitea-compatible REST API](maintainers/gitea-api.md)** — the migration of
104
113
`/api/v1` to the Gitea contract (so Renovate/`tea` can drive git-shark), the
105
114
component map, key decisions, and the works/gaps list.
ADD
docs/admins/bot-protection.md
+131 -0
@@ -0,0 +1,131 @@
1
+# Bot protection and rate limits
2
+
3
+Expensive rendered pages — per-commit diffs, history pages, merge-request diffs and
4
+search — are metered per caller, and an optional captcha lets a refused human
5
+continue. Rate limiting is **on by default**; the captcha is **off by default** and
6
+needs keys from Cloudflare Turnstile or hCaptcha.
7
+
8
+Nothing is persisted: counters and the "already solved" pass live in memory and in a
9
+signed cookie, so there are no new tables and no migrations.
10
+
11
+## What is metered
12
+
13
+| Path | Why |
14
+|---|---|
15
+| `GET /repos/{owner}/{name}/commit/{id}` | parses the commit and builds a full tree-to-tree diff |
16
+| `GET /repos/{owner}/{name}/commits[/{ref}]` | revwalk over the ref |
17
+| `GET /repos/{owner}/{name}/merge-requests/{number}` | renders the branch diff |
18
+| `GET /search` | repository + people search across the instance |
19
+
20
+Only `GET` is metered. Everything else is untouched — notably the git smart-HTTP
21
+transport, `/api/v1`, the `runner.v1` CI endpoints, the MCP server and the
22
+ActivityPub/ForgeFed endpoints. Those authenticate their own callers and are driven
23
+by tools that cannot solve a challenge, so a limit there would break clones,
24
+Renovate and federation rather than stop a crawler.
25
+
26
+## Budgets
27
+
28
+Each caller gets a fixed window (default 1 minute) and a budget inside it:
29
+
30
+| Caller | Key | Default budget |
31
+|---|---|---|
32
+| Anonymous | client IP | 60 per window |
33
+| Logged in | user account | 600 per window |
34
+
35
+The client IP is read from the Vert.x remote address, which already honours
36
+`X-Forwarded-For` because `quarkus.http.proxy.allow-x-forwarded` is enabled. Make
37
+sure your reverse proxy sets `X-Forwarded-For` — without it every visitor behind
38
+the proxy shares one anonymous budget.
39
+
40
+Counters are **per instance and not replicated**. With several replicas each pod
41
+enforces its own share of the budget, which is enough to stop a crawler hammering
42
+one node; if you need a global limit, do it at the ingress.
43
+
44
+## Over budget
45
+
46
+- **No captcha configured** → `429 Too Many Requests` with a `Retry-After` header
47
+ set to the window length, and a short plain-text body.
48
+- **Captcha configured** → `303 See Other` to
49
+ `/challenge?redirect=<original path>`. Solving the widget mints a signed pass
50
+ cookie (`gitshark_human`, `HttpOnly`, `SameSite=Lax`, `Secure` over HTTPS) that
51
+ lifts the budget for `pass-duration`. A request carrying a valid pass skips the
52
+ limiter entirely.
53
+
54
+The pass is `<expiry-epoch-seconds>.<HMAC-SHA256>`, signed with a key derived from
55
+the captcha secret key — no server-side session state, so it survives restarts and
56
+works across pods. A forged, re-signed or expired value is simply ignored.
57
+
58
+## Configuration
59
+
60
+| Variable | Default | Meaning |
61
+|---|---|---|
62
+| `GITSHARK_PROTECT_ENABLED` | `true` | Master switch for metering |
63
+| `GITSHARK_PROTECT_ANONYMOUS_LIMIT` | `60` | Expensive pages per window, per client IP |
64
+| `GITSHARK_PROTECT_USER_LIMIT` | `600` | Expensive pages per window, per logged-in account |
65
+| `GITSHARK_PROTECT_WINDOW` | `1m` | Window length |
66
+| `GITSHARK_PROTECT_CAPTCHA_PROVIDER` | `none` | `none`, `turnstile` or `hcaptcha` |
67
+| `GITSHARK_PROTECT_CAPTCHA_SITE_KEY` | — | Public widget key |
68
+| `GITSHARK_PROTECT_CAPTCHA_SECRET_KEY` | — | Server-side key; also signs the pass cookie |
69
+| `GITSHARK_PROTECT_CAPTCHA_VERIFY_URL` | — | Override the provider's `siteverify` endpoint (testing) |
70
+| `GITSHARK_PROTECT_CAPTCHA_PASS_DURATION` | `30m` | How long a solved check keeps lifting the budget |
71
+
72
+An unknown provider value is treated as `none`, and a provider **without both keys**
73
+also counts as no captcha: `/challenge` answers `404` and refusals stay plain
74
+`429`s. Rate limiting keeps working either way — the guard never depends on a
75
+third-party widget being reachable to be able to say no.
76
+
77
+### Turnstile example
78
+
79
+```yaml
80
+ environment:
81
+ GITSHARK_PROTECT_CAPTCHA_PROVIDER: turnstile
82
+ GITSHARK_PROTECT_CAPTCHA_SITE_KEY: 0x4AAA...
83
+ GITSHARK_PROTECT_CAPTCHA_SECRET_KEY: 0x4AAA...
84
+ GITSHARK_PROTECT_ANONYMOUS_LIMIT: "40"
85
+```
86
+
87
+Register the site key for your instance's hostname in the Cloudflare dashboard
88
+(hCaptcha: in the hCaptcha dashboard) and keep the secret key out of the image —
89
+pass it via the environment or a secret, like `GITSHARK_SECRET_KEY`.
90
+
91
+### Outbound network
92
+
93
+Token verification is a server-side `POST` from git-shark to the provider:
94
+
95
+- Turnstile: `https://challenges.cloudflare.com/turnstile/v0/siteverify`
96
+- hCaptcha: `https://api.hcaptcha.com/siteverify`
97
+
98
+Allow egress to that host, or challenges can never be solved. Verification fails
99
+**closed**: a timeout, a non-`200` or an unparseable body means "not verified", and
100
+the visitor is re-shown the check with an error. Connect timeout 5 s, request
101
+timeout 10 s.
102
+
103
+Browsers additionally load the widget script from
104
+`https://challenges.cloudflare.com` / `https://js.hcaptcha.com`. If you serve a
105
+Content-Security-Policy at the proxy, allow those hosts in `script-src` and
106
+`frame-src`.
107
+
108
+## Endpoints
109
+
110
+| Method & path | Auth | Purpose |
111
+|---|---|---|
112
+| `GET /challenge?redirect=<path>` | None | The check page (404 unless a captcha is fully configured) |
113
+| `POST /challenge` | None | Verifies the token, sets the pass cookie, redirects to `redirect` |
114
+
115
+`redirect` is only honoured when it is a single-slash server-relative path;
116
+anything else falls back to `/`, so the challenge cannot be turned into an open
117
+redirect.
118
+
119
+## Tuning and troubleshooting
120
+
121
+| Symptom | Cause / fix |
122
+|---|---|
123
+| Legitimate users hit the check while browsing | Raise `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. |
124
+| Everyone is challenged at once, from one IP | The proxy is not forwarding the real client IP (see above). |
125
+| `/challenge` returns 404 | No provider selected, or one of the two keys is missing. |
126
+| Check always rejects the token | Wrong 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). |
127
+| Crawler still hammers one endpoint | Only the four paths above are metered by design; block the rest at the ingress. |
128
+| Want no metering at all | `GITSHARK_PROTECT_ENABLED=false`. |
129
+
130
+Nothing needs a restart other than the usual config reload: all values are read at
131
+startup, so change the environment and recreate the container.
MODIFY
docs/admins/getting-started.md
+9 -0
@@ -370,6 +370,15 @@
370
370
| `GITSHARK_ADMIN_HANDLES` | — | — | Comma-separated handles allowed into `/admin/*` (CI runner management); empty means no admins (see [CI runners](ci-runners.md)) |
371
371
| `GITSHARK_CI_TASK_TIMEOUT` | — | `1h` | How long a claimed CI task may run before it is reclaimed as a zombie (see [CI runners](ci-runners.md)) |
372
372
| `GITSHARK_CI_ZOMBIE_RECLAIM_INTERVAL` | — | `1m` | How often the sweep that fails timed-out CI tasks runs |
373
+| `GITSHARK_PROTECT_ENABLED` | — | `true` | Meter expensive renderings (commit/history/merge-request diffs, search) per caller (see [Bot protection](bot-protection.md)) |
374
+| `GITSHARK_PROTECT_ANONYMOUS_LIMIT` | — | `60` | Expensive renderings per window, per client IP |
375
+| `GITSHARK_PROTECT_USER_LIMIT` | — | `600` | Expensive renderings per window, per logged-in account |
376
+| `GITSHARK_PROTECT_WINDOW` | — | `1m` | Length of the rate-limit window |
377
+| `GITSHARK_PROTECT_CAPTCHA_PROVIDER` | — | `none` | `none`, `turnstile` or `hcaptcha`; anything else is treated as `none` |
378
+| `GITSHARK_PROTECT_CAPTCHA_SITE_KEY` | — | — | Public widget key; without both keys there is no challenge page (refusals stay plain `429`s) |
379
+| `GITSHARK_PROTECT_CAPTCHA_SECRET_KEY` | — | — | Server-side `siteverify` key; also signs the `gitshark_human` pass cookie |
380
+| `GITSHARK_PROTECT_CAPTCHA_VERIFY_URL` | — | — | Override the provider's `siteverify` endpoint (testing) |
381
+| `GITSHARK_PROTECT_CAPTCHA_PASS_DURATION` | — | `30m` | How long a solved check keeps lifting the budget |
373
382
| `GITSHARK_GITEA_API_VERSION` | — | `1.13.0` | Version string reported by `GET /api/v1/version`. The `/api/v1` surface is Gitea-compatible; Gitea clients (Renovate, `tea`) gate features on this. Kept below `1.14.0` so they only call implemented endpoints — raise it as reviewer/label/status support lands |
374
383
375
384
### Optional: push mirrors
ADD
docs/maintainers/bot-protection.md
+112 -0
@@ -0,0 +1,112 @@
1
+# Bot protection architecture
2
+
3
+Metering for the expensive rendered pages plus an optional captcha challenge, all in
4
+`de.workaround.protect`. No tables, no migrations, no scheduler: the whole subsystem
5
+is a request filter, an in-memory counter map, an HMAC-signed cookie and one page.
6
+
7
+## Component map
8
+
9
+| Class | Role |
10
+|---|---|
11
+| `ProtectionConfig` | All `gitshark.protect.*` values; `captchaConfigured()` is the single "can we challenge?" predicate |
12
+| `CaptchaProvider` | Enum of the four strings that differ between Turnstile and hCaptcha (script URL, widget CSS class, form field, `siteverify` URL) |
13
+| `ExpensivePaths` | The metered path patterns; the only place that decides what counts as expensive |
14
+| `RateLimiter` | Fixed-window counter map, keyed by caller |
15
+| `ClientAddress` | Request-scoped client IP for the anonymous key |
16
+| `HumanPass` | Mints and validates the signed `gitshark_human` pass |
17
+| `ExpensiveRequestFilter` | `ContainerRequestFilter` that ties the above together |
18
+| `CaptchaVerifier` | `siteverify` POST via `java.net.http`, fails closed |
19
+| `ChallengeResource` | `GET`/`POST /challenge` plus the Qute page |
20
+
21
+## Request flow
22
+
23
+```
24
+GET /repos/a/b/commit/<id>
25
+ ExpensiveRequestFilter
26
+ enabled? GET? ExpensivePaths.isExpensive(path)? → no: pass through
27
+ gitshark_human cookie valid? → yes: pass through (no counting)
28
+ key = "user:<principal>" | "ip:<client-ip>"
29
+ limiter.tryAcquire(key, userLimit | anonymousLimit, window)
30
+ → within budget: pass through
31
+ → over budget, captcha configured: 303 → /challenge?redirect=<original>
32
+ → over budget, no captcha: 429 + Retry-After
33
+```
34
+
35
+```
36
+POST /challenge (redirect, <provider response field>)
37
+ CaptchaVerifier.verify(token, clientIp) → siteverify POST, {"success": bool}
38
+ false → 403, re-render the page with an error
39
+ true → Set-Cookie gitshark_human=<expiry>.<hmac>; 303 → safeRedirect(redirect)
40
+```
41
+
42
+## Decisions
43
+
44
+**Fixed windows, not sliding.** One map entry per caller (`start`, `count`) instead of
45
+a timestamp list. The worst case — up to twice the budget across a window boundary —
46
+does not matter for an abuse guard, and the memory profile stays flat. The map is
47
+pruned of elapsed windows once it passes 50 000 keys so an IP spray cannot grow it
48
+without bound.
49
+
50
+**In memory, not in the database.** A limiter that writes rows would add database load
51
+to the very requests it is meant to protect. Per-pod counters mean each replica
52
+enforces its own share of the budget; a global limit belongs at the ingress, not here.
53
+
54
+**Two budgets, two keys.** The threat is an unauthenticated crawler, so anonymous
55
+callers are keyed by IP with a small budget and logged-in users by account with a
56
+large one. Keying logged-in users by account (not IP) also keeps a shared office IP
57
+from punishing everyone once they sign in.
58
+
59
+**Only four paths, only GET.** `ExpensivePaths` deliberately excludes the git
60
+transport, `/api/v1`, `runner.v1`, MCP and ActivityPub: those are machine callers with
61
+their own auth that would break on a challenge (a clone cannot solve a captcha), and
62
+they are already rate-limitable at the ingress. POSTs are excluded because a challenge
63
+mid-form would discard the submitted body.
64
+
65
+**Redirect to `/challenge` instead of rendering the challenge inside the filter.**
66
+Rendering Qute from a `ContainerRequestFilter` risks blocking the event loop and
67
+duplicates the page's model in two places. A `303` keeps `ChallengeResource` the only
68
+renderer, and the original target rides along in `?redirect=` so the visitor lands
69
+where they meant to.
70
+
71
+**Signed cookie, no session store.** `<expiry-epoch-seconds>.<HMAC-SHA256>` keyed on
72
+SHA-256 of the captcha secret key. That secret is necessarily present whenever
73
+challenges can be issued, so the pass needs no extra configuration, and being
74
+stateless it survives restarts and works across pods. Comparison uses
75
+`MessageDigest.isEqual`. Reusing the captcha secret rather than `GITSHARK_SECRET_KEY`
76
+keeps the feature independent of the mirror/CI secret setup.
77
+
78
+**Fail closed on verification, fail open on configuration.** An unreachable or
79
+unparseable `siteverify` reply is "not verified" — a broken provider must not become a
80
+bypass. Conversely a missing/incomplete captcha config does not disable metering: it
81
+only removes the challenge, and refusals become plain `429`s.
82
+
83
+**Rate limiting on by default, captcha off.** Sensible defaults (60/600 per minute)
84
+protect a fresh instance immediately, while the captcha stays opt-in because it needs
85
+third-party keys and sends visitor IPs to that provider.
86
+
87
+## What works today
88
+
89
+- Fixed-window metering of commit, history, merge-request and search renderings, with
90
+ separate anonymous (per-IP) and logged-in (per-account) budgets.
91
+- `429` + `Retry-After` when no captcha is configured.
92
+- Turnstile and hCaptcha challenges with server-side `siteverify`, both providers
93
+ driven by the same code path.
94
+- Signed, self-expiring pass cookie that bypasses the limiter for its lifetime.
95
+- Open-redirect-safe `?redirect=` handling (server-relative single-slash paths only).
96
+- Client IP taken from the proxy-aware remote address.
97
+
98
+## What still needs to be implemented
99
+
100
+- **Shared counters across replicas.** Each pod meters independently; a global budget
101
+ would need a shared store (or ingress-level limiting).
102
+- **Per-repository or per-path budgets.** One budget covers all metered paths; a repo
103
+ with a huge history cannot be metered more tightly than a small one.
104
+- **Configurable path set.** `ExpensivePaths` is compiled in; admins cannot add
105
+ (say) tree or blob views without a code change.
106
+- **Response caching.** The cheapest fix for expensive renderings is not to rebuild
107
+ them — a rendered-diff cache keyed by commit id would reduce the need for metering
108
+ in the first place.
109
+- **Admin visibility.** No metrics or admin page for current counters, refusal counts
110
+ or challenge solve rate.
111
+- **Proof-of-work alternative.** A self-hosted challenge (Anubis-style) would avoid
112
+ sending visitor IPs to a third party for admins who cannot use Turnstile/hCaptcha.
ADD
docs/users/bot-check.md
+46 -0
@@ -0,0 +1,46 @@
1
+# The "Quick check" page
2
+
3
+Some pages are expensive to build: a commit's diff, a page of history, a merge
4
+request's changes, and search all read git objects live on every request. To keep
5
+crawlers and bots from burning the instance's capacity, git-shark gives every
6
+visitor a budget of those pages per minute. Go over it and you land on a **Quick
7
+check** page (or, if your instance has no bot check configured, you get a plain
8
+*Too many requests* response and can simply retry a moment later).
9
+
10
+## What counts against the budget
11
+
12
+Only these pages, and only when you open them:
13
+
14
+- a commit's detail page (`…/commit/<id>`)
15
+- a page of the **Commits** list (`…/commits/<branch>`)
16
+- a merge request's page, which renders its diff
17
+- the search results page
18
+
19
+Everything else — repository overviews, branch and tag lists, issues, profiles,
20
+your settings — is not metered. Neither is `git clone`, `git fetch` or `git push`:
21
+git traffic and the REST API are not affected at all, so your tooling never sees a
22
+check.
23
+
24
+## Solving the check
25
+
26
+The check is a Cloudflare Turnstile or hCaptcha widget, depending on what the
27
+instance's admin configured. Most of the time it solves itself and you are sent
28
+straight back to the page you wanted. Once solved, the confirmation lasts for a
29
+while (30 minutes by default), so you are not asked again on every page.
30
+
31
+The check needs JavaScript. If you have it turned off, log in instead.
32
+
33
+## Logging in gives you a much bigger budget
34
+
35
+Anonymous visitors share one budget per IP address; signed-in users are metered
36
+per account with a far higher allowance (by default 600 pages per minute instead
37
+of 60). If you keep hitting the check while browsing normally, **log in** — that
38
+is the intended fix, not a workaround.
39
+
40
+## If you keep seeing it
41
+
42
+- Behind a shared or corporate IP address, other people's browsing counts against
43
+ the same anonymous budget. Logging in gives you your own.
44
+- If the check itself fails to load or keeps rejecting you, tell your instance
45
+ admin: the widget keys may be misconfigured, or the provider unreachable from
46
+ the server.
MODIFY
docs/users/commits.md
+4 -1
@@ -16,7 +16,10 @@
16
16
*root* commit, which has no parent) every file shows as an addition, because there
17
17
was nothing before it.
18
18
19
-The diff is always computed live from git; nothing is stored in the database.
19
+The diff is always computed live from git; nothing is stored in the database. Because
20
+that makes the page expensive to build, it is rate-limited per visitor — open a lot of
21
+them quickly and you may be asked to confirm you are a person, or told to slow down.
22
+See [The "Quick check" page](bot-check.md); logging in raises the allowance a lot.
20
23
21
24
## Getting there
22
25
MODIFY
docs/users/search.md
+5 -0
@@ -35,6 +35,11 @@
35
35
An empty or blank query is not an error: the page simply prompts you to type
36
36
something and shows no results.
37
37
38
+Each search runs live over the whole instance, so the results page is rate-limited
39
+per visitor: a burst of searches can land you on a
40
+[quick "are you a person?" check](bot-check.md) (logging in raises the allowance a
41
+lot). The JSON API is not affected.
42
+
38
43
## Searching from the API
39
44
40
45
The same search is available as JSON for scripts and tools — see the
ADD
src/main/java/de/workaround/protect/CaptchaProvider.java
+78 -0
@@ -0,0 +1,78 @@
1
+package de.workaround.protect;
2
+
3
+import java.util.Locale;
4
+
5
+/**
6
+ * The supported bot-check widgets. Both Cloudflare Turnstile and hCaptcha follow the same shape —
7
+ * a script tag, a div carrying the site key, a form field holding the solved token, and a
8
+ * server-side {@code siteverify} POST — so only these four strings differ between them.
9
+ */
10
+public enum CaptchaProvider
11
+{
12
+ NONE("", "", "", ""),
13
+
14
+ TURNSTILE(
15
+ "https://challenges.cloudflare.com/turnstile/v0/api.js",
16
+ "cf-turnstile",
17
+ "cf-turnstile-response",
18
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify"),
19
+
20
+ HCAPTCHA(
21
+ "https://js.hcaptcha.com/1/api.js",
22
+ "h-captcha",
23
+ "h-captcha-response",
24
+ "https://api.hcaptcha.com/siteverify");
25
+
26
+ private final String scriptUrl;
27
+
28
+ private final String widgetClass;
29
+
30
+ private final String responseField;
31
+
32
+ private final String defaultVerifyUrl;
33
+
34
+ CaptchaProvider(String scriptUrl, String widgetClass, String responseField, String defaultVerifyUrl)
35
+ {
36
+ this.scriptUrl = scriptUrl;
37
+ this.widgetClass = widgetClass;
38
+ this.responseField = responseField;
39
+ this.defaultVerifyUrl = defaultVerifyUrl;
40
+ }
41
+
42
+ /** Unknown or blank values fall back to {@link #NONE} — a typo must not silently disable metering. */
43
+ public static CaptchaProvider parse(String value)
44
+ {
45
+ if (value == null || value.isBlank())
46
+ {
47
+ return NONE;
48
+ }
49
+ try
50
+ {
51
+ return valueOf(value.trim().toUpperCase(Locale.ROOT));
52
+ }
53
+ catch (IllegalArgumentException e)
54
+ {
55
+ return NONE;
56
+ }
57
+ }
58
+
59
+ public String scriptUrl()
60
+ {
61
+ return scriptUrl;
62
+ }
63
+
64
+ public String widgetClass()
65
+ {
66
+ return widgetClass;
67
+ }
68
+
69
+ public String responseField()
70
+ {
71
+ return responseField;
72
+ }
73
+
74
+ public String defaultVerifyUrl()
75
+ {
76
+ return defaultVerifyUrl;
77
+ }
78
+}
ADD
src/main/java/de/workaround/protect/CaptchaVerifier.java
+102 -0
@@ -0,0 +1,102 @@
1
+package de.workaround.protect;
2
+
3
+import java.net.URI;
4
+import java.net.URLEncoder;
5
+import java.net.http.HttpClient;
6
+import java.net.http.HttpRequest;
7
+import java.net.http.HttpResponse;
8
+import java.nio.charset.StandardCharsets;
9
+import java.time.Duration;
10
+import java.util.Optional;
11
+import java.util.logging.Level;
12
+import java.util.logging.Logger;
13
+
14
+import com.fasterxml.jackson.databind.JsonNode;
15
+import com.fasterxml.jackson.databind.ObjectMapper;
16
+
17
+import jakarta.enterprise.context.ApplicationScoped;
18
+import jakarta.inject.Inject;
19
+
20
+/**
21
+ * Server-side token check against the provider's {@code siteverify} endpoint. Turnstile and hCaptcha
22
+ * share the request contract ({@code secret}, {@code response}, optional {@code remoteip}) and the
23
+ * {@code {"success": bool}} reply, so one implementation serves both.
24
+ *
25
+ * <p>Anything unexpected — timeout, non-200, unparseable body — counts as "not verified": a captcha
26
+ * that cannot be checked must never grant a pass.
27
+ */
28
+@ApplicationScoped
29
+public class CaptchaVerifier
30
+{
31
+ private static final Logger LOG = Logger.getLogger(CaptchaVerifier.class.getName());
32
+
33
+ // instance field, not static: a build-time-initialized client would be baked into the native image
34
+ private final HttpClient http = HttpClient.newBuilder()
35
+ .connectTimeout(Duration.ofSeconds(5))
36
+ .followRedirects(HttpClient.Redirect.NEVER)
37
+ .build();
38
+
39
+ @Inject
40
+ ProtectionConfig config;
41
+
42
+ @Inject
43
+ ObjectMapper mapper;
44
+
45
+ public boolean verify(String token, String remoteIp)
46
+ {
47
+ if (token == null || token.isBlank())
48
+ {
49
+ return false;
50
+ }
51
+ Optional<String> secret = config.secretKey();
52
+ Optional<String> endpoint = config.verifyUrl();
53
+ if (secret.isEmpty() || endpoint.isEmpty())
54
+ {
55
+ return false;
56
+ }
57
+ try
58
+ {
59
+ HttpRequest request = HttpRequest.newBuilder(URI.create(endpoint.get()))
60
+ .timeout(Duration.ofSeconds(10))
61
+ .header("Content-Type", "application/x-www-form-urlencoded")
62
+ .header("Accept", "application/json")
63
+ .POST(HttpRequest.BodyPublishers.ofString(form(secret.get(), token, remoteIp)))
64
+ .build();
65
+ HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
66
+ if (response.statusCode() != 200)
67
+ {
68
+ LOG.log(Level.WARNING, "captcha siteverify returned HTTP {0}", response.statusCode());
69
+ return false;
70
+ }
71
+ JsonNode body = mapper.readTree(response.body());
72
+ return body.path("success").asBoolean(false);
73
+ }
74
+ catch (InterruptedException e)
75
+ {
76
+ Thread.currentThread().interrupt();
77
+ return false;
78
+ }
79
+ catch (Exception e)
80
+ {
81
+ LOG.log(Level.WARNING, "captcha siteverify failed: " + e.getMessage(), e);
82
+ return false;
83
+ }
84
+ }
85
+
86
+ private static String form(String secret, String token, String remoteIp)
87
+ {
88
+ StringBuilder body = new StringBuilder()
89
+ .append("secret=").append(encode(secret))
90
+ .append("&response=").append(encode(token));
91
+ if (remoteIp != null && !remoteIp.isBlank() && !remoteIp.equals("unknown"))
92
+ {
93
+ body.append("&remoteip=").append(encode(remoteIp));
94
+ }
95
+ return body.toString();
96
+ }
97
+
98
+ private static String encode(String value)
99
+ {
100
+ return URLEncoder.encode(value, StandardCharsets.UTF_8);
101
+ }
102
+}
ADD
src/main/java/de/workaround/protect/ChallengeResource.java
+112 -0
@@ -0,0 +1,112 @@
1
+package de.workaround.protect;
2
+
3
+import java.net.URI;
4
+
5
+import io.quarkus.qute.CheckedTemplate;
6
+import io.quarkus.qute.TemplateInstance;
7
+import jakarta.inject.Inject;
8
+import jakarta.ws.rs.Consumes;
9
+import jakarta.ws.rs.GET;
10
+import jakarta.ws.rs.NotFoundException;
11
+import jakarta.ws.rs.POST;
12
+import jakarta.ws.rs.Path;
13
+import jakarta.ws.rs.Produces;
14
+import jakarta.ws.rs.QueryParam;
15
+import jakarta.ws.rs.core.Context;
16
+import jakarta.ws.rs.core.MediaType;
17
+import jakarta.ws.rs.core.MultivaluedMap;
18
+import jakarta.ws.rs.core.NewCookie;
19
+import jakarta.ws.rs.core.Response;
20
+import jakarta.ws.rs.core.UriInfo;
21
+
22
+/**
23
+ * The bot check a rate-limited visitor lands on. Renders the configured widget (Turnstile or
24
+ * hCaptcha); on a token the provider confirms, mints a {@link HumanPass} cookie and returns the
25
+ * visitor to the page they wanted.
26
+ *
27
+ * <p>The page exists only while a captcha is fully configured — otherwise there is nothing to solve
28
+ * and the route answers 404 instead of showing an empty form.
29
+ */
30
+@Path("/challenge")
31
+public class ChallengeResource
32
+{
33
+ @CheckedTemplate
34
+ static class Templates
35
+ {
36
+ static native TemplateInstance challenge(String siteKey, String scriptUrl, String widgetClass,
37
+ String responseField, String redirect, String error);
38
+ }
39
+
40
+ @Inject
41
+ ProtectionConfig config;
42
+
43
+ @Inject
44
+ CaptchaVerifier verifier;
45
+
46
+ @Inject
47
+ HumanPass humanPass;
48
+
49
+ @Inject
50
+ ClientAddress clientAddress;
51
+
52
+ @GET
53
+ @Produces(MediaType.TEXT_HTML)
54
+ public TemplateInstance page(@QueryParam("redirect") String redirect)
55
+ {
56
+ requireCaptcha();
57
+ return render(safeRedirect(redirect), null);
58
+ }
59
+
60
+ @POST
61
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
62
+ @Produces(MediaType.TEXT_HTML)
63
+ public Response solve(MultivaluedMap<String, String> form, @Context UriInfo uriInfo)
64
+ {
65
+ requireCaptcha();
66
+ String redirect = safeRedirect(form.getFirst("redirect"));
67
+ String token = form.getFirst(config.provider().responseField());
68
+ if (!verifier.verify(token, clientAddress.ip()))
69
+ {
70
+ return Response.status(Response.Status.FORBIDDEN)
71
+ .entity(render(redirect, "That check did not go through. Please try again."))
72
+ .build();
73
+ }
74
+ NewCookie pass = new NewCookie.Builder(HumanPass.COOKIE_NAME)
75
+ .value(humanPass.issue())
76
+ .path("/")
77
+ .maxAge(humanPass.cookieMaxAge())
78
+ .httpOnly(true)
79
+ .sameSite(NewCookie.SameSite.LAX)
80
+ .secure("https".equalsIgnoreCase(uriInfo.getRequestUri().getScheme()))
81
+ .build();
82
+ return Response.seeOther(URI.create(redirect)).cookie(pass).build();
83
+ }
84
+
85
+ private TemplateInstance render(String redirect, String error)
86
+ {
87
+ CaptchaProvider provider = config.provider();
88
+ return Templates.challenge(config.siteKey().orElse(""), provider.scriptUrl(), provider.widgetClass(),
89
+ provider.responseField(), redirect, error);
90
+ }
91
+
92
+ private void requireCaptcha()
93
+ {
94
+ if (!config.captchaConfigured())
95
+ {
96
+ throw new NotFoundException();
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Only server-relative single-slash paths are accepted, so a crafted {@code ?redirect=} cannot
102
+ * turn the challenge into an open redirect towards another host.
103
+ */
104
+ private static String safeRedirect(String redirect)
105
+ {
106
+ if (redirect == null || !redirect.startsWith("/") || redirect.startsWith("//"))
107
+ {
108
+ return "/";
109
+ }
110
+ return redirect;
111
+ }
112
+}
ADD
src/main/java/de/workaround/protect/ClientAddress.java
+30 -0
@@ -0,0 +1,30 @@
1
+package de.workaround.protect;
2
+
3
+import io.vertx.core.net.SocketAddress;
4
+import io.vertx.ext.web.RoutingContext;
5
+import jakarta.enterprise.context.RequestScoped;
6
+import jakarta.inject.Inject;
7
+
8
+/**
9
+ * The caller's IP address, used as the rate-limit key for anonymous visitors. Read from the Vert.x
10
+ * remote address, which already reflects {@code X-Forwarded-For} because
11
+ * {@code quarkus.http.proxy.allow-x-forwarded} is on — behind the ingress the peer address would
12
+ * otherwise be the proxy and every visitor would share one budget.
13
+ */
14
+@RequestScoped
15
+public class ClientAddress
16
+{
17
+ @Inject
18
+ RoutingContext routingContext;
19
+
20
+ public String ip()
21
+ {
22
+ SocketAddress remote = routingContext.request().remoteAddress();
23
+ if (remote == null)
24
+ {
25
+ return "unknown";
26
+ }
27
+ String host = remote.hostAddress() != null ? remote.hostAddress() : remote.host();
28
+ return host == null || host.isBlank() ? "unknown" : host;
29
+ }
30
+}
ADD
src/main/java/de/workaround/protect/ExpensivePaths.java
+43 -0
@@ -0,0 +1,43 @@
1
+package de.workaround.protect;
2
+
3
+import java.util.List;
4
+import java.util.regex.Pattern;
5
+
6
+/**
7
+ * The rendered pages worth metering: those that walk git history or build a diff, plus search. Cheap
8
+ * pages (repository overview, branch and tag lists, issue pages) are left alone, and so are the
9
+ * machine surfaces — git transport, {@code /api/v1}, the ActivityPub endpoints and the MCP server —
10
+ * which authenticate their own callers and are consumed by tools that would break on a challenge.
11
+ */
12
+final class ExpensivePaths
13
+{
14
+ private static final List<Pattern> PATTERNS = List.of(
15
+ // a single commit: parse the commit plus a full tree-to-tree diff
16
+ Pattern.compile("^repos/[^/]+/[^/]+/commit/.+$"),
17
+ // a page of history: revwalk over the ref
18
+ Pattern.compile("^repos/[^/]+/[^/]+/commits(/.*)?$"),
19
+ // a merge request page renders the branch diff
20
+ Pattern.compile("^repos/[^/]+/[^/]+/merge-requests/\\d+$"),
21
+ // repository + people search across the instance
22
+ Pattern.compile("^search$"));
23
+
24
+ private ExpensivePaths()
25
+ {
26
+ }
27
+
28
+ static boolean isExpensive(String path)
29
+ {
30
+ String normalized = normalize(path);
31
+ return PATTERNS.stream().anyMatch(pattern -> pattern.matcher(normalized).matches());
32
+ }
33
+
34
+ private static String normalize(String path)
35
+ {
36
+ if (path == null)
37
+ {
38
+ return "";
39
+ }
40
+ String normalized = path.startsWith("/") ? path.substring(1) : path;
41
+ return normalized.endsWith("/") ? normalized.substring(0, normalized.length() - 1) : normalized;
42
+ }
43
+}
ADD
src/main/java/de/workaround/protect/ExpensiveRequestFilter.java
+98 -0
@@ -0,0 +1,98 @@
1
+package de.workaround.protect;
2
+
3
+import java.net.URI;
4
+import java.net.URLEncoder;
5
+import java.nio.charset.StandardCharsets;
6
+
7
+import io.quarkus.security.identity.SecurityIdentity;
8
+import jakarta.inject.Inject;
9
+import jakarta.ws.rs.container.ContainerRequestContext;
10
+import jakarta.ws.rs.container.ContainerRequestFilter;
11
+import jakarta.ws.rs.core.Cookie;
12
+import jakarta.ws.rs.core.MediaType;
13
+import jakarta.ws.rs.core.Response;
14
+import jakarta.ws.rs.ext.Provider;
15
+
16
+/**
17
+ * Meters the expensive rendered pages (see {@link ExpensivePaths}) per caller: logged-in users
18
+ * against their own budget, anonymous visitors against a smaller one keyed by client IP — a crawler
19
+ * without an account is exactly what this defends against.
20
+ *
21
+ * <p>Over budget, the response depends on configuration: with a captcha configured the visitor is
22
+ * sent to {@code /challenge} and can prove they are human (which mints a pass cookie that lifts the
23
+ * budget); without one the request is refused with {@code 429} and a {@code Retry-After}. Only GETs
24
+ * are metered — form POSTs already require a session, and a challenge in the middle of one would
25
+ * lose the submitted body.
26
+ */
27
+@Provider
28
+public class ExpensiveRequestFilter implements ContainerRequestFilter
29
+{
30
+ @Inject
31
+ ProtectionConfig config;
32
+
33
+ @Inject
34
+ RateLimiter limiter;
35
+
36
+ @Inject
37
+ HumanPass humanPass;
38
+
39
+ @Inject
40
+ ClientAddress clientAddress;
41
+
42
+ @Inject
43
+ SecurityIdentity identity;
44
+
45
+ @Override
46
+ public void filter(ContainerRequestContext context)
47
+ {
48
+ if (!config.enabled() || !"GET".equals(context.getMethod()))
49
+ {
50
+ return;
51
+ }
52
+ if (!ExpensivePaths.isExpensive(context.getUriInfo().getPath()))
53
+ {
54
+ return;
55
+ }
56
+ if (carriesValidPass(context))
57
+ {
58
+ return;
59
+ }
60
+ boolean loggedIn = !identity.isAnonymous();
61
+ String key = loggedIn
62
+ ? "user:" + identity.getPrincipal().getName()
63
+ : "ip:" + clientAddress.ip();
64
+ int limit = loggedIn ? config.userLimit() : config.anonymousLimit();
65
+ if (limiter.tryAcquire(key, limit, config.window()))
66
+ {
67
+ return;
68
+ }
69
+ context.abortWith(config.captchaConfigured() ? challenge(context) : refusal());
70
+ }
71
+
72
+ private boolean carriesValidPass(ContainerRequestContext context)
73
+ {
74
+ Cookie cookie = context.getCookies().get(HumanPass.COOKIE_NAME);
75
+ return cookie != null && humanPass.valid(cookie.getValue());
76
+ }
77
+
78
+ private Response challenge(ContainerRequestContext context)
79
+ {
80
+ String target = context.getUriInfo().getRequestUri().getPath();
81
+ String query = context.getUriInfo().getRequestUri().getRawQuery();
82
+ if (query != null && !query.isEmpty())
83
+ {
84
+ target = target + "?" + query;
85
+ }
86
+ String location = "/challenge?redirect=" + URLEncoder.encode(target, StandardCharsets.UTF_8);
87
+ return Response.seeOther(URI.create(location)).build();
88
+ }
89
+
90
+ private Response refusal()
91
+ {
92
+ return Response.status(Response.Status.TOO_MANY_REQUESTS)
93
+ .header("Retry-After", Math.max(1, config.window().toSeconds()))
94
+ .type(MediaType.TEXT_PLAIN)
95
+ .entity("Too many expensive requests. Please slow down and try again shortly.\n")
96
+ .build();
97
+ }
98
+}
ADD
src/main/java/de/workaround/protect/HumanPass.java
+130 -0
@@ -0,0 +1,130 @@
1
+package de.workaround.protect;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.security.InvalidKeyException;
5
+import java.security.MessageDigest;
6
+import java.security.NoSuchAlgorithmException;
7
+import java.time.Duration;
8
+import java.util.Base64;
9
+import java.util.function.LongSupplier;
10
+
11
+import javax.crypto.Mac;
12
+import javax.crypto.spec.SecretKeySpec;
13
+
14
+import jakarta.enterprise.context.ApplicationScoped;
15
+import jakarta.inject.Inject;
16
+
17
+/**
18
+ * The "this visitor already solved a captcha" pass: {@code <expiry-epoch-seconds>.<hmac>}, carried in
19
+ * a cookie. Self-contained and self-expiring, so no server-side session table is needed and the
20
+ * value survives a restart or a hop to another pod.
21
+ *
22
+ * <p>The HMAC key is derived from the captcha secret key — the one secret that is necessarily present
23
+ * whenever challenges can be issued at all — so no extra configuration is required. Without it, no
24
+ * pass can be minted or accepted (fail closed).
25
+ */
26
+@ApplicationScoped
27
+public class HumanPass
28
+{
29
+ public static final String COOKIE_NAME = "gitshark_human";
30
+
31
+ private static final String ALGORITHM = "HmacSHA256";
32
+
33
+ private final SecretKeySpec key;
34
+
35
+ private final Duration duration;
36
+
37
+ private final LongSupplier clock;
38
+
39
+ @Inject
40
+ HumanPass(ProtectionConfig config)
41
+ {
42
+ this(config.secretKey().orElse(null), config.passDuration(), System::currentTimeMillis);
43
+ }
44
+
45
+ HumanPass(String secret, Duration duration, LongSupplier clock)
46
+ {
47
+ this.key = secret == null || secret.isBlank() ? null : new SecretKeySpec(sha256(secret), ALGORITHM);
48
+ this.duration = duration;
49
+ this.clock = clock;
50
+ }
51
+
52
+ public boolean available()
53
+ {
54
+ return key != null;
55
+ }
56
+
57
+ /** Mints a pass valid for the configured duration. */
58
+ public String issue()
59
+ {
60
+ if (key == null)
61
+ {
62
+ throw new IllegalStateException("no captcha secret key configured — cannot issue a human pass");
63
+ }
64
+ long expiry = clock.getAsLong() / 1000 + Math.max(1, duration.toSeconds());
65
+ return expiry + "." + sign(expiry);
66
+ }
67
+
68
+ /** True for an unexpired value signed by this instance. */
69
+ public boolean valid(String value)
70
+ {
71
+ if (key == null || value == null)
72
+ {
73
+ return false;
74
+ }
75
+ int separator = value.lastIndexOf('.');
76
+ if (separator <= 0 || separator == value.length() - 1)
77
+ {
78
+ return false;
79
+ }
80
+ long expiry;
81
+ try
82
+ {
83
+ expiry = Long.parseLong(value.substring(0, separator));
84
+ }
85
+ catch (NumberFormatException e)
86
+ {
87
+ return false;
88
+ }
89
+ if (expiry <= clock.getAsLong() / 1000)
90
+ {
91
+ return false;
92
+ }
93
+ byte[] presented = value.substring(separator + 1).getBytes(StandardCharsets.UTF_8);
94
+ byte[] expected = sign(expiry).getBytes(StandardCharsets.UTF_8);
95
+ return MessageDigest.isEqual(presented, expected);
96
+ }
97
+
98
+ /** Cookie lifetime in seconds, matching the pass's own expiry. */
99
+ public int cookieMaxAge()
100
+ {
101
+ return (int) Math.max(1, duration.toSeconds());
102
+ }
103
+
104
+ private String sign(long expiry)
105
+ {
106
+ try
107
+ {
108
+ Mac mac = Mac.getInstance(ALGORITHM);
109
+ mac.init(key);
110
+ byte[] signature = mac.doFinal(("gitshark-human:" + expiry).getBytes(StandardCharsets.UTF_8));
111
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(signature);
112
+ }
113
+ catch (NoSuchAlgorithmException | InvalidKeyException e)
114
+ {
115
+ throw new IllegalStateException("HMAC-SHA256 unavailable", e);
116
+ }
117
+ }
118
+
119
+ private static byte[] sha256(String value)
120
+ {
121
+ try
122
+ {
123
+ return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
124
+ }
125
+ catch (NoSuchAlgorithmException e)
126
+ {
127
+ throw new IllegalStateException("SHA-256 unavailable", e);
128
+ }
129
+ }
130
+}
ADD
src/main/java/de/workaround/protect/ProtectionConfig.java
+112 -0
@@ -0,0 +1,112 @@
1
+package de.workaround.protect;
2
+
3
+import java.time.Duration;
4
+import java.util.Optional;
5
+
6
+import org.eclipse.microprofile.config.inject.ConfigProperty;
7
+
8
+import jakarta.enterprise.context.ApplicationScoped;
9
+
10
+/**
11
+ * Configuration for the abuse guard on expensive renderings (commit and diff views, search). Two
12
+ * budgets per window — one for anonymous callers keyed by client IP, one for logged-in users keyed
13
+ * by their account — plus an optional captcha that lets a refused human continue.
14
+ *
15
+ * <p>The captcha is opt-in and fails <em>open</em> only in the sense that no captcha means no
16
+ * challenge page; the rate limit itself still applies and refusals become plain 429s. An
17
+ * incompletely configured captcha (provider set, keys missing) counts as no captcha.
18
+ */
19
+@ApplicationScoped
20
+public class ProtectionConfig
21
+{
22
+ @ConfigProperty(name = "gitshark.protect.enabled", defaultValue = "true")
23
+ boolean enabled;
24
+
25
+ @ConfigProperty(name = "gitshark.protect.anonymous-limit", defaultValue = "60")
26
+ int anonymousLimit;
27
+
28
+ @ConfigProperty(name = "gitshark.protect.user-limit", defaultValue = "600")
29
+ int userLimit;
30
+
31
+ @ConfigProperty(name = "gitshark.protect.window", defaultValue = "1m")
32
+ Duration window;
33
+
34
+ @ConfigProperty(name = "gitshark.protect.captcha.provider", defaultValue = "none")
35
+ String provider;
36
+
37
+ @ConfigProperty(name = "gitshark.protect.captcha.site-key")
38
+ Optional<String> siteKey;
39
+
40
+ @ConfigProperty(name = "gitshark.protect.captcha.secret-key")
41
+ Optional<String> secretKey;
42
+
43
+ @ConfigProperty(name = "gitshark.protect.captcha.verify-url")
44
+ Optional<String> verifyUrl;
45
+
46
+ @ConfigProperty(name = "gitshark.protect.captcha.pass-duration", defaultValue = "30m")
47
+ Duration passDuration;
48
+
49
+ public boolean enabled()
50
+ {
51
+ return enabled;
52
+ }
53
+
54
+ public int anonymousLimit()
55
+ {
56
+ return anonymousLimit;
57
+ }
58
+
59
+ public int userLimit()
60
+ {
61
+ return userLimit;
62
+ }
63
+
64
+ public Duration window()
65
+ {
66
+ return window;
67
+ }
68
+
69
+ public CaptchaProvider provider()
70
+ {
71
+ return CaptchaProvider.parse(provider);
72
+ }
73
+
74
+ public Optional<String> siteKey()
75
+ {
76
+ return nonBlank(siteKey);
77
+ }
78
+
79
+ public Optional<String> secretKey()
80
+ {
81
+ return nonBlank(secretKey);
82
+ }
83
+
84
+ /** The configured override, or the provider's own endpoint. Empty when no provider is selected. */
85
+ public Optional<String> verifyUrl()
86
+ {
87
+ Optional<String> configured = nonBlank(verifyUrl);
88
+ if (configured.isPresent())
89
+ {
90
+ return configured;
91
+ }
92
+ CaptchaProvider selected = provider();
93
+ return selected == CaptchaProvider.NONE ? Optional.empty() : Optional.of(selected.defaultVerifyUrl());
94
+ }
95
+
96
+ /** How long a solved challenge keeps lifting the budget for that visitor. */
97
+ public Duration passDuration()
98
+ {
99
+ return passDuration;
100
+ }
101
+
102
+ /** True only when a provider is selected AND both of its keys are present. */
103
+ public boolean captchaConfigured()
104
+ {
105
+ return provider() != CaptchaProvider.NONE && siteKey().isPresent() && secretKey().isPresent();
106
+ }
107
+
108
+ private static Optional<String> nonBlank(Optional<String> value)
109
+ {
110
+ return value.map(String::trim).filter(trimmed -> !trimmed.isEmpty());
111
+ }
112
+}
ADD
src/main/java/de/workaround/protect/RateLimiter.java
+94 -0
@@ -0,0 +1,94 @@
1
+package de.workaround.protect;
2
+
3
+import java.time.Duration;
4
+import java.util.concurrent.ConcurrentHashMap;
5
+import java.util.function.LongSupplier;
6
+
7
+import jakarta.enterprise.context.ApplicationScoped;
8
+
9
+/**
10
+ * In-memory fixed-window request counter, keyed by caller (user account or client IP). Fixed windows
11
+ * over sliding ones on purpose: one map entry per caller, no per-request timestamp lists, and the
12
+ * worst case — twice the budget across a window boundary — is irrelevant for an abuse guard.
13
+ *
14
+ * <p>State is per instance and deliberately not replicated: with several pods each enforces its own
15
+ * share of the budget, which is enough to stop a crawler hammering one node. The map is pruned of
16
+ * elapsed windows once it grows past {@link #MAX_KEYS} so a spray of unique IPs cannot grow it
17
+ * without bound.
18
+ */
19
+@ApplicationScoped
20
+public class RateLimiter
21
+{
22
+ private static final int MAX_KEYS = 50_000;
23
+
24
+ private final ConcurrentHashMap<String, Window> windows = new ConcurrentHashMap<>();
25
+
26
+ private final LongSupplier clock;
27
+
28
+ RateLimiter()
29
+ {
30
+ this(System::currentTimeMillis);
31
+ }
32
+
33
+ private RateLimiter(LongSupplier clock)
34
+ {
35
+ this.clock = clock;
36
+ }
37
+
38
+ /** Test entry point: a limiter driven by a controllable millisecond clock. */
39
+ public static RateLimiter withClock(LongSupplier clock)
40
+ {
41
+ return new RateLimiter(clock);
42
+ }
43
+
44
+ /**
45
+ * Counts one request against {@code key} and reports whether it is still within {@code limit}
46
+ * for the current window.
47
+ */
48
+ public boolean tryAcquire(String key, int limit, Duration window)
49
+ {
50
+ if (limit <= 0)
51
+ {
52
+ return false;
53
+ }
54
+ long now = clock.getAsLong();
55
+ long windowMillis = Math.max(1, window.toMillis());
56
+ if (windows.size() >= MAX_KEYS)
57
+ {
58
+ pruneExpired(window);
59
+ }
60
+ Window counted = windows.compute(key, (ignored, current) ->
61
+ {
62
+ if (current == null || now - current.start() >= windowMillis)
63
+ {
64
+ return new Window(now, 1);
65
+ }
66
+ return new Window(current.start(), current.count() + 1);
67
+ });
68
+ return counted.count() <= limit;
69
+ }
70
+
71
+ /** Drops entries whose window has elapsed; safe to call at any time. */
72
+ public void pruneExpired(Duration window)
73
+ {
74
+ long now = clock.getAsLong();
75
+ long windowMillis = Math.max(1, window.toMillis());
76
+ windows.values().removeIf(entry -> now - entry.start() >= windowMillis);
77
+ }
78
+
79
+ /** Forgets every budget — used by tests to isolate one scenario from the next. */
80
+ public void reset()
81
+ {
82
+ windows.clear();
83
+ }
84
+
85
+ /** Number of callers currently tracked. */
86
+ public int tracked()
87
+ {
88
+ return windows.size();
89
+ }
90
+
91
+ private record Window(long start, int count)
92
+ {
93
+ }
94
+}
MODIFY
src/main/resources/application.properties
+23 -0
@@ -145,6 +145,29 @@
145
145
gitshark.ci.zombie-reclaim-interval=${GITSHARK_CI_ZOMBIE_RECLAIM_INTERVAL:1m}
146
146
%test.gitshark.ci.zombie-reclaim-interval=1h
147
147
148
+# Bot protection for expensive renderings (commit and diff views, merge-request pages, search).
149
+# Every caller gets a fixed per-window budget: anonymous visitors keyed by client IP, logged-in
150
+# users keyed by their account (a crawler without an account is what this defends against).
151
+# Over budget the request is refused with 429 — unless a captcha is configured, in which case the
152
+# visitor is sent to /challenge and a solved check mints a signed pass cookie that lifts the budget
153
+# for pass-duration. provider is none|turnstile|hcaptcha; both keys must be set for the challenge to
154
+# exist at all (an incomplete captcha config just leaves plain 429s). verify-url overrides the
155
+# provider's own siteverify endpoint — used by tests.
156
+gitshark.protect.enabled=${GITSHARK_PROTECT_ENABLED:true}
157
+gitshark.protect.anonymous-limit=${GITSHARK_PROTECT_ANONYMOUS_LIMIT:60}
158
+gitshark.protect.user-limit=${GITSHARK_PROTECT_USER_LIMIT:600}
159
+gitshark.protect.window=${GITSHARK_PROTECT_WINDOW:1m}
160
+gitshark.protect.captcha.provider=${GITSHARK_PROTECT_CAPTCHA_PROVIDER:none}
161
+gitshark.protect.captcha.site-key=${GITSHARK_PROTECT_CAPTCHA_SITE_KEY:}
162
+gitshark.protect.captcha.secret-key=${GITSHARK_PROTECT_CAPTCHA_SECRET_KEY:}
163
+gitshark.protect.captcha.verify-url=${GITSHARK_PROTECT_CAPTCHA_VERIFY_URL:}
164
+gitshark.protect.captcha.pass-duration=${GITSHARK_PROTECT_CAPTCHA_PASS_DURATION:30m}
165
+# Tests share one application instance and one limiter across hundreds of requests from 127.0.0.1,
166
+# so keep the budget effectively unlimited; the tests that exercise the guard set their own tiny
167
+# limits via a QuarkusTestProfile.
168
+%test.gitshark.protect.anonymous-limit=100000
169
+%test.gitshark.protect.user-limit=100000
170
+
148
171
# Federation (ActivityPub / ForgeFed) — disabled by default.
149
172
# base-url is the public origin of this instance (e.g. https://shark.example); actor IDs are
150
173
# absolute and permanent once published, so it must be a real, non-loopback URL when enabled.
ADD
src/main/resources/templates/ChallengeResource/challenge.html
+31 -0
@@ -0,0 +1,31 @@
1
+{#include layout}
2
+{#title}Quick check – git-shark{/title}
3
+<h1>Quick check</h1>
4
+<p>This page is expensive to build, so we ask unrecognised visitors to confirm they are a person.
5
+ Solve the check below and you will be sent straight on — the confirmation lasts for a while, so
6
+ you will not be asked again on every page.</p>
7
+{#if error}
8
+<p class="error">{error}</p>
9
+{/if}
10
+<form method="post" action="/challenge">
11
+ <input type="hidden" name="redirect" value="{redirect}">
12
+ <div class="{widgetClass}" data-sitekey="{siteKey}" data-callback="sharkChallengeSolved"></div>
13
+ <noscript>
14
+ <p class="error">This check needs JavaScript. Alternatively, <a href="/login">log in</a> — signed-in
15
+ users get a much larger budget and are not challenged.</p>
16
+ </noscript>
17
+ <p><button class="btn btn-primary" type="submit">Continue</button></p>
18
+</form>
19
+<p>Logging in avoids this check: signed-in requests are metered per account, with a far higher
20
+ allowance. <a href="/login">Log in</a></p>
21
+{#scripts}
22
+<script src="{scriptUrl}" async defer></script>
23
+<script>
24
+ // the widget calls this with the solved token; submit immediately so the visitor needs no second click
25
+ function sharkChallengeSolved()
26
+ {
27
+ document.querySelector('form[action="/challenge"]').submit();
28
+ }
29
+</script>
30
+{/scripts}
31
+{/include}
ADD
src/test/java/de/workaround/protect/ChallengeFlowTest.java
+170 -0
@@ -0,0 +1,170 @@
1
+package de.workaround.protect;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.nio.file.Path;
5
+import java.util.Map;
6
+
7
+import org.junit.jupiter.api.BeforeEach;
8
+import org.junit.jupiter.api.Test;
9
+
10
+import de.workaround.git.GitBrowseService;
11
+import de.workaround.git.GitRepositoryService;
12
+import de.workaround.git.GitTestSeeder;
13
+import de.workaround.model.Repository;
14
+import de.workaround.model.User;
15
+import io.quarkus.test.junit.QuarkusTest;
16
+import io.quarkus.test.junit.QuarkusTestProfile;
17
+import io.quarkus.test.junit.TestProfile;
18
+import io.restassured.response.Response;
19
+import jakarta.inject.Inject;
20
+import jakarta.transaction.Transactional;
21
+
22
+import static io.restassured.RestAssured.given;
23
+import static org.hamcrest.CoreMatchers.containsString;
24
+import static org.hamcrest.CoreMatchers.endsWith;
25
+import static org.hamcrest.CoreMatchers.not;
26
+import static org.junit.jupiter.api.Assertions.assertNotNull;
27
+import static org.junit.jupiter.api.Assertions.assertNull;
28
+import static org.junit.jupiter.api.Assertions.assertTrue;
29
+
30
+/**
31
+ * With a captcha configured, an over-budget anonymous visitor is sent to {@code /challenge} instead
32
+ * of a dead end; solving it mints a signed pass cookie that lifts the budget for its lifetime. A
33
+ * rejected token mints nothing, and the post-solve redirect can never leave the instance.
34
+ */
35
+@QuarkusTest
36
+@TestProfile(ChallengeFlowTest.CaptchaProfile.class)
37
+class ChallengeFlowTest
38
+{
39
+ public static class CaptchaProfile implements QuarkusTestProfile
40
+ {
41
+ @Override
42
+ public Map<String, String> getConfigOverrides()
43
+ {
44
+ return Map.of(
45
+ "gitshark.protect.enabled", "true",
46
+ "gitshark.protect.anonymous-limit", "1",
47
+ "gitshark.protect.window", "1m",
48
+ "gitshark.protect.captcha.provider", "turnstile",
49
+ "gitshark.protect.captcha.site-key", "test-site-key",
50
+ "gitshark.protect.captcha.secret-key", "test-secret-key",
51
+ "gitshark.protect.captcha.verify-url", "http://localhost:8081/test/siteverify",
52
+ "gitshark.protect.captcha.pass-duration", "30m");
53
+ }
54
+ }
55
+
56
+ @Inject
57
+ GitRepositoryService service;
58
+
59
+ @Inject
60
+ GitBrowseService browse;
61
+
62
+ @Inject
63
+ RateLimiter limiter;
64
+
65
+ @Inject
66
+ User.Repo userRepo;
67
+
68
+ @BeforeEach
69
+ void clearBudgets()
70
+ {
71
+ limiter.reset();
72
+ }
73
+
74
+ @Test
75
+ void overBudgetVisitorsAreSentToTheChallengePage()
76
+ {
77
+ String commit = seedCommitPath("ch-anon");
78
+
79
+ given().when().get(commit).then().statusCode(200);
80
+
81
+ String location = given().redirects().follow(false).when().get(commit)
82
+ .then().statusCode(303)
83
+ .extract().header("Location");
84
+ assertTrue(location.contains("/challenge?redirect="), "expected a challenge redirect, got " + location);
85
+
86
+ given().when().get(location)
87
+ .then().statusCode(200)
88
+ .body(containsString("cf-turnstile"))
89
+ .body(containsString("test-site-key"));
90
+ }
91
+
92
+ @Test
93
+ void solvingTheChallengeMintsAPassThatLiftsTheBudget()
94
+ {
95
+ String commit = seedCommitPath("ch-solve");
96
+ given().when().get(commit).then().statusCode(200);
97
+
98
+ Response solved = given().redirects().follow(false)
99
+ .formParam("redirect", commit)
100
+ .formParam("cf-turnstile-response", "good-token")
101
+ .when().post("/challenge")
102
+ .then().statusCode(303)
103
+ // JAX-RS absolutizes Location; only the target path is interesting here
104
+ .header("Location", endsWith(commit))
105
+ .extract().response();
106
+
107
+ String pass = solved.getCookie("gitshark_human");
108
+ assertNotNull(pass, "solving the challenge must set the pass cookie");
109
+
110
+ given().cookie("gitshark_human", pass).when().get(commit).then().statusCode(200);
111
+ given().cookie("gitshark_human", pass).when().get(commit).then().statusCode(200);
112
+ }
113
+
114
+ @Test
115
+ void aRejectedTokenMintsNoPass()
116
+ {
117
+ Response rejected = given().redirects().follow(false)
118
+ .formParam("redirect", "/repos/ch-reject/board")
119
+ .formParam("cf-turnstile-response", "bad-token")
120
+ .when().post("/challenge")
121
+ .then().statusCode(403)
122
+ .extract().response();
123
+
124
+ assertNull(rejected.getCookie("gitshark_human"));
125
+ }
126
+
127
+ @Test
128
+ void theRedirectAfterSolvingStaysOnThisInstance()
129
+ {
130
+ given().redirects().follow(false)
131
+ .formParam("redirect", "https://evil.example/phish")
132
+ .formParam("cf-turnstile-response", "good-token")
133
+ .when().post("/challenge")
134
+ .then().statusCode(303)
135
+ .header("Location", endsWith("/"))
136
+ .header("Location", not(containsString("evil.example")));
137
+ }
138
+
139
+ private String seedCommitPath(String handle)
140
+ {
141
+ User owner = persistUser(handle);
142
+ Repository repo = service.create(owner, "board", Repository.Visibility.PUBLIC, null);
143
+ Path bare = service.repositoryPath(repo);
144
+ try
145
+ {
146
+ GitTestSeeder.seed(bare, Map.of("base.txt", "base\n".getBytes(StandardCharsets.UTF_8)), 2);
147
+ }
148
+ catch (Exception e)
149
+ {
150
+ throw new IllegalStateException(e);
151
+ }
152
+ String head = browse.commits(bare, "main", 0, 10).orElseThrow().commits().get(0).id();
153
+ return "/repos/" + handle + "/board/commit/" + head;
154
+ }
155
+
156
+ @Transactional
157
+ User persistUser(String name)
158
+ {
159
+ User existing = userRepo.findByOidcSubOptional(name).orElse(null);
160
+ if (existing != null)
161
+ {
162
+ return existing;
163
+ }
164
+ User user = new User();
165
+ user.oidcSub = name;
166
+ user.username = name;
167
+ user.persist();
168
+ return user;
169
+ }
170
+}
ADD
src/test/java/de/workaround/protect/ExpensivePathsTest.java
+41 -0
@@ -0,0 +1,41 @@
1
+package de.workaround.protect;
2
+
3
+import org.junit.jupiter.api.Test;
4
+
5
+import static org.junit.jupiter.api.Assertions.assertFalse;
6
+import static org.junit.jupiter.api.Assertions.assertTrue;
7
+
8
+/**
9
+ * Only the renderings that walk git history or build diffs are metered. Cheap pages, the git
10
+ * transport, the REST API and the federation endpoints must stay untouched.
11
+ */
12
+class ExpensivePathsTest
13
+{
14
+ @Test
15
+ void commitAndDiffRenderingsAreExpensive()
16
+ {
17
+ assertTrue(ExpensivePaths.isExpensive("/repos/alice/board/commit/0123456789abcdef"));
18
+ assertTrue(ExpensivePaths.isExpensive("repos/alice/board/commits/main"));
19
+ assertTrue(ExpensivePaths.isExpensive("/repos/alice/board/merge-requests/7"));
20
+ assertTrue(ExpensivePaths.isExpensive("/search"));
21
+ }
22
+
23
+ @Test
24
+ void cheapPagesAreNotMetered()
25
+ {
26
+ assertFalse(ExpensivePaths.isExpensive("/repos/alice/board"));
27
+ assertFalse(ExpensivePaths.isExpensive("/repos/alice/board/branches"));
28
+ assertFalse(ExpensivePaths.isExpensive("/repos/alice/board/merge-requests"));
29
+ assertFalse(ExpensivePaths.isExpensive("/"));
30
+ assertFalse(ExpensivePaths.isExpensive("/alice"));
31
+ }
32
+
33
+ @Test
34
+ void machineSurfacesAreNeverMetered()
35
+ {
36
+ assertFalse(ExpensivePaths.isExpensive("/repos/alice/board/info/refs"));
37
+ assertFalse(ExpensivePaths.isExpensive("/repos/alice/board/git-upload-pack"));
38
+ assertFalse(ExpensivePaths.isExpensive("/api/v1/repos/alice/board/commits"));
39
+ assertFalse(ExpensivePaths.isExpensive("/ap/users/alice/outbox"));
40
+ }
41
+}
ADD
src/test/java/de/workaround/protect/ExpensiveRequestLimitTest.java
+135 -0
@@ -0,0 +1,135 @@
1
+package de.workaround.protect;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.nio.file.Path;
5
+import java.util.Map;
6
+
7
+import org.junit.jupiter.api.BeforeEach;
8
+import org.junit.jupiter.api.Test;
9
+
10
+import de.workaround.git.GitBrowseService;
11
+import de.workaround.git.GitRepositoryService;
12
+import de.workaround.git.GitTestSeeder;
13
+import de.workaround.model.Repository;
14
+import de.workaround.model.User;
15
+import io.quarkus.test.junit.QuarkusTest;
16
+import io.quarkus.test.junit.QuarkusTestProfile;
17
+import io.quarkus.test.junit.TestProfile;
18
+import io.quarkus.test.security.TestSecurity;
19
+import jakarta.inject.Inject;
20
+import jakarta.transaction.Transactional;
21
+
22
+import static io.restassured.RestAssured.given;
23
+import static org.hamcrest.CoreMatchers.notNullValue;
24
+
25
+/**
26
+ * Anonymous visitors get a small budget of expensive renderings per window; logged-in users get the
27
+ * larger one. With no captcha configured the refusal is a plain 429 — the instance never depends on
28
+ * a third-party widget to be able to say no.
29
+ */
30
+@QuarkusTest
31
+@TestProfile(ExpensiveRequestLimitTest.TightLimitProfile.class)
32
+class ExpensiveRequestLimitTest
33
+{
34
+ public static class TightLimitProfile implements QuarkusTestProfile
35
+ {
36
+ @Override
37
+ public Map<String, String> getConfigOverrides()
38
+ {
39
+ return Map.of(
40
+ "gitshark.protect.enabled", "true",
41
+ "gitshark.protect.anonymous-limit", "2",
42
+ "gitshark.protect.user-limit", "50",
43
+ "gitshark.protect.window", "1m",
44
+ "gitshark.protect.captcha.provider", "none");
45
+ }
46
+ }
47
+
48
+ @Inject
49
+ GitRepositoryService service;
50
+
51
+ @Inject
52
+ GitBrowseService browse;
53
+
54
+ @Inject
55
+ RateLimiter limiter;
56
+
57
+ @Inject
58
+ User.Repo userRepo;
59
+
60
+ @BeforeEach
61
+ void clearBudgets()
62
+ {
63
+ limiter.reset();
64
+ }
65
+
66
+ @Test
67
+ void anonymousRequestsBeyondTheLimitAreRefusedWith429()
68
+ {
69
+ String commit = seedCommitPath("rl-anon");
70
+
71
+ given().when().get(commit).then().statusCode(200);
72
+ given().when().get(commit).then().statusCode(200);
73
+ given().when().get(commit)
74
+ .then().statusCode(429)
75
+ .header("Retry-After", notNullValue());
76
+ }
77
+
78
+ @Test
79
+ @TestSecurity(user = "rl-user")
80
+ void loggedInUsersGetTheLargerBudget()
81
+ {
82
+ String commit = seedCommitPath("rl-user");
83
+
84
+ for (int i = 0; i < 5; i++)
85
+ {
86
+ given().when().get(commit).then().statusCode(200);
87
+ }
88
+ }
89
+
90
+ @Test
91
+ void cheapPagesStayReachableAfterTheBudgetIsGone()
92
+ {
93
+ String commit = seedCommitPath("rl-cheap");
94
+ String overview = "/repos/rl-cheap/board";
95
+
96
+ given().when().get(commit).then().statusCode(200);
97
+ given().when().get(commit).then().statusCode(200);
98
+ given().when().get(commit).then().statusCode(429);
99
+
100
+ given().when().get(overview).then().statusCode(200);
101
+ given().when().get(overview + "/branches").then().statusCode(200);
102
+ }
103
+
104
+ private String seedCommitPath(String handle)
105
+ {
106
+ User owner = persistUser(handle);
107
+ Repository repo = service.create(owner, "board", Repository.Visibility.PUBLIC, null);
108
+ Path bare = service.repositoryPath(repo);
109
+ try
110
+ {
111
+ GitTestSeeder.seed(bare, Map.of("base.txt", "base\n".getBytes(StandardCharsets.UTF_8)), 2);
112
+ }
113
+ catch (Exception e)
114
+ {
115
+ throw new IllegalStateException(e);
116
+ }
117
+ String head = browse.commits(bare, "main", 0, 10).orElseThrow().commits().get(0).id();
118
+ return "/repos/" + handle + "/board/commit/" + head;
119
+ }
120
+
121
+ @Transactional
122
+ User persistUser(String name)
123
+ {
124
+ User existing = userRepo.findByOidcSubOptional(name).orElse(null);
125
+ if (existing != null)
126
+ {
127
+ return existing;
128
+ }
129
+ User user = new User();
130
+ user.oidcSub = name;
131
+ user.username = name;
132
+ user.persist();
133
+ return user;
134
+ }
135
+}
ADD
src/test/java/de/workaround/protect/HumanPassTest.java
+70 -0
@@ -0,0 +1,70 @@
1
+package de.workaround.protect;
2
+
3
+import java.time.Duration;
4
+import java.util.concurrent.atomic.AtomicLong;
5
+
6
+import org.junit.jupiter.api.Test;
7
+
8
+import static org.junit.jupiter.api.Assertions.assertFalse;
9
+import static org.junit.jupiter.api.Assertions.assertThrows;
10
+import static org.junit.jupiter.api.Assertions.assertTrue;
11
+
12
+/**
13
+ * The "you already solved a captcha" pass is a signed, self-expiring cookie value: no server-side
14
+ * session state, and a forged or stale value must never grant the bypass.
15
+ */
16
+class HumanPassTest
17
+{
18
+ private static final Duration PASS = Duration.ofMinutes(30);
19
+
20
+ @Test
21
+ void anIssuedPassIsAccepted()
22
+ {
23
+ HumanPass pass = new HumanPass("captcha-secret", PASS, () -> 0L);
24
+
25
+ assertTrue(pass.valid(pass.issue()));
26
+ }
27
+
28
+ @Test
29
+ void aTamperedSignatureIsRejected()
30
+ {
31
+ HumanPass pass = new HumanPass("captcha-secret", PASS, () -> 0L);
32
+ String issued = pass.issue();
33
+
34
+ String tampered = issued.substring(0, issued.lastIndexOf('.') + 1) + "AAAAAAAAAAAAAAAAAAAAAA";
35
+
36
+ assertFalse(pass.valid(tampered));
37
+ assertFalse(pass.valid("garbage"));
38
+ assertFalse(pass.valid(null));
39
+ }
40
+
41
+ @Test
42
+ void aPassSignedWithAnotherSecretIsRejected()
43
+ {
44
+ String issued = new HumanPass("captcha-secret", PASS, () -> 0L).issue();
45
+
46
+ assertFalse(new HumanPass("other-secret", PASS, () -> 0L).valid(issued));
47
+ }
48
+
49
+ @Test
50
+ void anExpiredPassIsRejected()
51
+ {
52
+ AtomicLong now = new AtomicLong(0);
53
+ HumanPass pass = new HumanPass("captcha-secret", PASS, now::get);
54
+ String issued = pass.issue();
55
+
56
+ now.set(PASS.toMillis() + 1_000);
57
+
58
+ assertFalse(pass.valid(issued));
59
+ }
60
+
61
+ @Test
62
+ void withoutASecretNoPassCanBeIssuedOrAccepted()
63
+ {
64
+ HumanPass pass = new HumanPass(null, PASS, () -> 0L);
65
+
66
+ assertFalse(pass.available());
67
+ assertFalse(pass.valid("0.whatever"));
68
+ assertThrows(IllegalStateException.class, pass::issue);
69
+ }
70
+}
ADD
src/test/java/de/workaround/protect/RateLimiterTest.java
+86 -0
@@ -0,0 +1,86 @@
1
+package de.workaround.protect;
2
+
3
+import java.time.Duration;
4
+import java.util.concurrent.atomic.AtomicLong;
5
+
6
+import org.junit.jupiter.api.Test;
7
+
8
+import static org.junit.jupiter.api.Assertions.assertFalse;
9
+import static org.junit.jupiter.api.Assertions.assertTrue;
10
+
11
+/**
12
+ * Fixed-window counting: a key may spend its budget inside one window, is refused once the budget
13
+ * is gone, and starts over when the window rolls. Keys never share a budget.
14
+ */
15
+class RateLimiterTest
16
+{
17
+ private static final Duration WINDOW = Duration.ofMinutes(1);
18
+
19
+ @Test
20
+ void requestsUpToTheLimitPassAndTheNextOneIsRefused()
21
+ {
22
+ RateLimiter limiter = RateLimiter.withClock(() -> 0L);
23
+
24
+ assertTrue(limiter.tryAcquire("ip:1.2.3.4", 2, WINDOW));
25
+ assertTrue(limiter.tryAcquire("ip:1.2.3.4", 2, WINDOW));
26
+ assertFalse(limiter.tryAcquire("ip:1.2.3.4", 2, WINDOW));
27
+ }
28
+
29
+ @Test
30
+ void theBudgetIsRestoredWhenTheWindowRolls()
31
+ {
32
+ AtomicLong now = new AtomicLong(0);
33
+ RateLimiter limiter = RateLimiter.withClock(now::get);
34
+
35
+ assertTrue(limiter.tryAcquire("ip:1.2.3.4", 1, WINDOW));
36
+ assertFalse(limiter.tryAcquire("ip:1.2.3.4", 1, WINDOW));
37
+
38
+ now.set(WINDOW.toMillis());
39
+ assertTrue(limiter.tryAcquire("ip:1.2.3.4", 1, WINDOW));
40
+ }
41
+
42
+ @Test
43
+ void keysAreCountedIndependently()
44
+ {
45
+ RateLimiter limiter = RateLimiter.withClock(() -> 0L);
46
+
47
+ assertTrue(limiter.tryAcquire("ip:1.2.3.4", 1, WINDOW));
48
+ assertFalse(limiter.tryAcquire("ip:1.2.3.4", 1, WINDOW));
49
+ assertTrue(limiter.tryAcquire("user:alice", 1, WINDOW));
50
+ }
51
+
52
+ @Test
53
+ void aZeroLimitRefusesEverything()
54
+ {
55
+ RateLimiter limiter = RateLimiter.withClock(() -> 0L);
56
+
57
+ assertFalse(limiter.tryAcquire("ip:1.2.3.4", 0, WINDOW));
58
+ }
59
+
60
+ @Test
61
+ void resetClearsEveryTrackedKey()
62
+ {
63
+ RateLimiter limiter = RateLimiter.withClock(() -> 0L);
64
+ limiter.tryAcquire("ip:1.2.3.4", 1, WINDOW);
65
+
66
+ limiter.reset();
67
+
68
+ assertTrue(limiter.tryAcquire("ip:1.2.3.4", 1, WINDOW));
69
+ }
70
+
71
+ @Test
72
+ void expiredKeysAreEvictedSoTheMapDoesNotGrowForever()
73
+ {
74
+ AtomicLong now = new AtomicLong(0);
75
+ RateLimiter limiter = RateLimiter.withClock(now::get);
76
+ for (int i = 0; i < 100; i++)
77
+ {
78
+ limiter.tryAcquire("ip:10.0.0." + i, 5, WINDOW);
79
+ }
80
+
81
+ now.set(WINDOW.toMillis() * 2);
82
+ limiter.pruneExpired(WINDOW);
83
+
84
+ assertTrue(limiter.tracked() == 0, "expired windows must be dropped, tracked=" + limiter.tracked());
85
+ }
86
+}
ADD
src/test/java/de/workaround/protect/StubSiteverifyResource.java
+26 -0
@@ -0,0 +1,26 @@
1
+package de.workaround.protect;
2
+
3
+import jakarta.ws.rs.Consumes;
4
+import jakarta.ws.rs.FormParam;
5
+import jakarta.ws.rs.POST;
6
+import jakarta.ws.rs.Path;
7
+import jakarta.ws.rs.Produces;
8
+import jakarta.ws.rs.core.MediaType;
9
+
10
+/**
11
+ * Test-only stand-in for the Turnstile/hCaptcha {@code siteverify} endpoint: the token
12
+ * {@code good-token} verifies, everything else does not. Lets the challenge flow be exercised
13
+ * end-to-end over the real HTTP client without talking to Cloudflare.
14
+ */
15
+@Path("/test/siteverify")
16
+public class StubSiteverifyResource
17
+{
18
+ @POST
19
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
20
+ @Produces(MediaType.APPLICATION_JSON)
21
+ public String verify(@FormParam("secret") String secret, @FormParam("response") String response)
22
+ {
23
+ boolean ok = "test-secret-key".equals(secret) && "good-token".equals(response);
24
+ return "{\"success\":" + ok + ",\"error-codes\":[]}";
25
+ }
26
+}