gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

Merge branch 'issue-16-bot-captcha' into main (!12)

d91e47ad824d5e3bfcb114b9a0edc3398390fd7a · Miggi · 2026-08-13T11:13:32Z

Changes

25 files changed, +1705 -1

MODIFY README.md +8 -0
diff --git a/README.md b/README.md
index 073b120..43bf5da 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/README.md b/docs/README.md
index 033bfcd..29f2fc5 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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
diff --git a/docs/admins/bot-protection.md b/docs/admins/bot-protection.md
new file mode 100644
index 0000000..ad258b4
--- /dev/null
+++ b/docs/admins/bot-protection.md
@@ -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
diff --git a/docs/admins/getting-started.md b/docs/admins/getting-started.md
index 2b21d1a..4abb86d 100644
--- a/docs/admins/getting-started.md
+++ b/docs/admins/getting-started.md
@@ -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
diff --git a/docs/maintainers/bot-protection.md b/docs/maintainers/bot-protection.md
new file mode 100644
index 0000000..87735e0
--- /dev/null
+++ b/docs/maintainers/bot-protection.md
@@ -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
diff --git a/docs/users/bot-check.md b/docs/users/bot-check.md
new file mode 100644
index 0000000..949cd39
--- /dev/null
+++ b/docs/users/bot-check.md
@@ -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
diff --git a/docs/users/commits.md b/docs/users/commits.md
index 22832d3..f09bc9a 100644
--- a/docs/users/commits.md
+++ b/docs/users/commits.md
@@ -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
diff --git a/docs/users/search.md b/docs/users/search.md
index e3ac61f..13e60f1 100644
--- a/docs/users/search.md
+++ b/docs/users/search.md
@@ -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
diff --git a/src/main/java/de/workaround/protect/CaptchaProvider.java b/src/main/java/de/workaround/protect/CaptchaProvider.java
new file mode 100644
index 0000000..8951280
--- /dev/null
+++ b/src/main/java/de/workaround/protect/CaptchaProvider.java
@@ -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
diff --git a/src/main/java/de/workaround/protect/CaptchaVerifier.java b/src/main/java/de/workaround/protect/CaptchaVerifier.java
new file mode 100644
index 0000000..9be648e
--- /dev/null
+++ b/src/main/java/de/workaround/protect/CaptchaVerifier.java
@@ -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
diff --git a/src/main/java/de/workaround/protect/ChallengeResource.java b/src/main/java/de/workaround/protect/ChallengeResource.java
new file mode 100644
index 0000000..055c1b0
--- /dev/null
+++ b/src/main/java/de/workaround/protect/ChallengeResource.java
@@ -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
diff --git a/src/main/java/de/workaround/protect/ClientAddress.java b/src/main/java/de/workaround/protect/ClientAddress.java
new file mode 100644
index 0000000..cc7ddda
--- /dev/null
+++ b/src/main/java/de/workaround/protect/ClientAddress.java
@@ -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
diff --git a/src/main/java/de/workaround/protect/ExpensivePaths.java b/src/main/java/de/workaround/protect/ExpensivePaths.java
new file mode 100644
index 0000000..7c4554a
--- /dev/null
+++ b/src/main/java/de/workaround/protect/ExpensivePaths.java
@@ -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
diff --git a/src/main/java/de/workaround/protect/ExpensiveRequestFilter.java b/src/main/java/de/workaround/protect/ExpensiveRequestFilter.java
new file mode 100644
index 0000000..e3c54e4
--- /dev/null
+++ b/src/main/java/de/workaround/protect/ExpensiveRequestFilter.java
@@ -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
diff --git a/src/main/java/de/workaround/protect/HumanPass.java b/src/main/java/de/workaround/protect/HumanPass.java
new file mode 100644
index 0000000..fb9311e
--- /dev/null
+++ b/src/main/java/de/workaround/protect/HumanPass.java
@@ -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
diff --git a/src/main/java/de/workaround/protect/ProtectionConfig.java b/src/main/java/de/workaround/protect/ProtectionConfig.java
new file mode 100644
index 0000000..f914f54
--- /dev/null
+++ b/src/main/java/de/workaround/protect/ProtectionConfig.java
@@ -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
diff --git a/src/main/java/de/workaround/protect/RateLimiter.java b/src/main/java/de/workaround/protect/RateLimiter.java
new file mode 100644
index 0000000..9e8f7e4
--- /dev/null
+++ b/src/main/java/de/workaround/protect/RateLimiter.java
@@ -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
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 451a9a8..95b12f0 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -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
diff --git a/src/main/resources/templates/ChallengeResource/challenge.html b/src/main/resources/templates/ChallengeResource/challenge.html
new file mode 100644
index 0000000..c9e0742
--- /dev/null
+++ b/src/main/resources/templates/ChallengeResource/challenge.html
@@ -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
diff --git a/src/test/java/de/workaround/protect/ChallengeFlowTest.java b/src/test/java/de/workaround/protect/ChallengeFlowTest.java
new file mode 100644
index 0000000..f2cf168
--- /dev/null
+++ b/src/test/java/de/workaround/protect/ChallengeFlowTest.java
@@ -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
diff --git a/src/test/java/de/workaround/protect/ExpensivePathsTest.java b/src/test/java/de/workaround/protect/ExpensivePathsTest.java
new file mode 100644
index 0000000..a6596f5
--- /dev/null
+++ b/src/test/java/de/workaround/protect/ExpensivePathsTest.java
@@ -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
diff --git a/src/test/java/de/workaround/protect/ExpensiveRequestLimitTest.java b/src/test/java/de/workaround/protect/ExpensiveRequestLimitTest.java
new file mode 100644
index 0000000..cd2ba93
--- /dev/null
+++ b/src/test/java/de/workaround/protect/ExpensiveRequestLimitTest.java
@@ -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
diff --git a/src/test/java/de/workaround/protect/HumanPassTest.java b/src/test/java/de/workaround/protect/HumanPassTest.java
new file mode 100644
index 0000000..7ce7993
--- /dev/null
+++ b/src/test/java/de/workaround/protect/HumanPassTest.java
@@ -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
diff --git a/src/test/java/de/workaround/protect/RateLimiterTest.java b/src/test/java/de/workaround/protect/RateLimiterTest.java
new file mode 100644
index 0000000..cb51e05
--- /dev/null
+++ b/src/test/java/de/workaround/protect/RateLimiterTest.java
@@ -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
diff --git a/src/test/java/de/workaround/protect/StubSiteverifyResource.java b/src/test/java/de/workaround/protect/StubSiteverifyResource.java
new file mode 100644
index 0000000..87d29b3
--- /dev/null
+++ b/src/test/java/de/workaround/protect/StubSiteverifyResource.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog