๐ (protect): Tighten bot budgets and meter the human pass
Changes
9 files changed, +170 -61
MODIFY
README.md
+7 -5
@@ -106,11 +106,13 @@
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
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.
109
+ diffs, search) are metered per caller: anonymous visitors by client IP (30/min by default),
110
+ logged-in users by account (120/min). Over budget the request is refused with `429` +
111
+ `Retry-After`, or โ when a **Cloudflare Turnstile / hCaptcha** site is configured โ an anonymous
112
+ visitor is redirected to a `/challenge` page whose solved token mints a signed, self-expiring pass
113
+ cookie that raises them to the user budget for 15 minutes (metered under the pass, so a bought
114
+ captcha solve buys a bigger budget, not an unmetered one). Git transport, `/api/v1`, MCP,
115
+ `runner.v1` and the ActivityPub endpoints are never metered.
114
116
Guides: [for users](docs/users/bot-check.md), [for admins](docs/admins/bot-protection.md),
115
117
[architecture](docs/maintainers/bot-protection.md)
116
118
MODIFY
docs/admins/bot-protection.md
+28 -9
@@ -29,8 +29,15 @@
29
29
30
30
| Caller | Key | Default budget |
31
31
|---|---|---|
32
-| Anonymous | client IP | 60 per window |
33
-| Logged in | user account | 600 per window |
32
+| Anonymous | client IP | 30 per window |
33
+| Anonymous, passed the check | the pass itself | 120 per window |
34
+| Logged in | user account | 120 per window |
35
+
36
+The defaults are deliberately close to what a person browsing quickly can produce and
37
+far below what a scraper wants: 30 per minute is one expensive page every two seconds,
38
+sustained. Raise them if your instance serves an audience that genuinely browses
39
+faster โ but raise them knowingly, because the default that never annoys anyone also
40
+never stops anyone.
34
41
35
42
The client IP is read from the Vert.x remote address, which already honours
36
43
`X-Forwarded-For` because `quarkus.http.proxy.allow-x-forwarded` is enabled. Make
@@ -45,11 +52,18 @@
45
52
46
53
- **No captcha configured** โ `429 Too Many Requests` with a `Retry-After` header
47
54
set to the window length, and a short plain-text body.
48
-- **Captcha configured** โ `303 See Other` to
55
+- **Captcha configured, anonymous visitor** โ `303 See Other` to
49
56
`/challenge?redirect=<original path>`. Solving the widget mints a signed pass
50
57
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.
58
+ raises the visitor to the **user budget** for `pass-duration`, metered against the
59
+ pass itself rather than the client IP.
60
+- **Already on the raised budget** (logged in, or carrying a pass) โ plain `429`. A
61
+ second challenge would only hand out a fresh budget, and challenging a caller who
62
+ cannot improve their standing loops forever.
63
+
64
+A solved check raises the budget; it does not remove it. A bypass would turn one
65
+captcha solve โ a few tenths of a cent at a solving farm โ into a window of entirely
66
+unmetered scraping, which is precisely the traffic the guard exists to stop.
53
67
54
68
The pass is `<expiry-epoch-seconds>.<HMAC-SHA256>`, signed with a key derived from
55
69
the captcha secret key โ no server-side session state, so it survives restarts and
@@ -60,14 +74,14 @@
60
74
| Variable | Default | Meaning |
61
75
|---|---|---|
62
76
| `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 |
77
+| `GITSHARK_PROTECT_ANONYMOUS_LIMIT` | `30` | Expensive pages per window, per client IP |
78
+| `GITSHARK_PROTECT_USER_LIMIT` | `120` | Expensive pages per window, per logged-in account โ and per solved check |
65
79
| `GITSHARK_PROTECT_WINDOW` | `1m` | Window length |
66
80
| `GITSHARK_PROTECT_CAPTCHA_PROVIDER` | `none` | `none`, `turnstile` or `hcaptcha` |
67
81
| `GITSHARK_PROTECT_CAPTCHA_SITE_KEY` | โ | Public widget key |
68
82
| `GITSHARK_PROTECT_CAPTCHA_SECRET_KEY` | โ | Server-side key; also signs the pass cookie |
69
83
| `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 |
84
+| `GITSHARK_PROTECT_CAPTCHA_PASS_DURATION` | `15m` | How long a solved check keeps the visitor on the user budget |
71
85
72
86
An unknown provider value is treated as `none`, and a provider **without both keys**
73
87
also counts as no captcha: `/challenge` answers `404` and refusals stay plain
@@ -81,9 +95,13 @@
81
95
GITSHARK_PROTECT_CAPTCHA_PROVIDER: turnstile
82
96
GITSHARK_PROTECT_CAPTCHA_SITE_KEY: 0x4AAA...
83
97
GITSHARK_PROTECT_CAPTCHA_SECRET_KEY: 0x4AAA...
84
- GITSHARK_PROTECT_ANONYMOUS_LIMIT: "40"
98
+ GITSHARK_PROTECT_ANONYMOUS_LIMIT: "20"
85
99
```
86
100
101
+With a challenge available the anonymous budget can be tighter than the default: a
102
+refused human is one click away from continuing, so `20` costs a visitor almost
103
+nothing while halving what a crawler gets for free.
104
+
87
105
Register the site key for your instance's hostname in the Cloudflare dashboard
88
106
(hCaptcha: in the hCaptcha dashboard) and keep the secret key out of the image โ
89
107
pass it via the environment or a secret, like `GITSHARK_SECRET_KEY`.
@@ -124,6 +142,7 @@
124
142
| Everyone is challenged at once, from one IP | The proxy is not forwarding the real client IP (see above). |
125
143
| `/challenge` returns 404 | No provider selected, or one of the two keys is missing. |
126
144
| 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). |
145
+| A visitor is refused right after solving the check | Expected above `USER_LIMIT`: the pass raises the budget, it does not remove it. Genuine browsing does not reach 120 expensive pages a minute โ raise `GITSHARK_PROTECT_USER_LIMIT` only if it does. |
127
146
| Crawler still hammers one endpoint | Only the four paths above are metered by design; block the rest at the ingress. |
128
147
| Want no metering at all | `GITSHARK_PROTECT_ENABLED=false`. |
129
148
MODIFY
docs/admins/getting-started.md
+3 -3
@@ -371,14 +371,14 @@
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
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 |
374
+| `GITSHARK_PROTECT_ANONYMOUS_LIMIT` | โ | `30` | Expensive renderings per window, per client IP |
375
+| `GITSHARK_PROTECT_USER_LIMIT` | โ | `120` | Expensive renderings per window, per logged-in account โ and per solved bot check |
376
376
| `GITSHARK_PROTECT_WINDOW` | โ | `1m` | Length of the rate-limit window |
377
377
| `GITSHARK_PROTECT_CAPTCHA_PROVIDER` | โ | `none` | `none`, `turnstile` or `hcaptcha`; anything else is treated as `none` |
378
378
| `GITSHARK_PROTECT_CAPTCHA_SITE_KEY` | โ | โ | Public widget key; without both keys there is no challenge page (refusals stay plain `429`s) |
379
379
| `GITSHARK_PROTECT_CAPTCHA_SECRET_KEY` | โ | โ | Server-side `siteverify` key; also signs the `gitshark_human` pass cookie |
380
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 |
381
+| `GITSHARK_PROTECT_CAPTCHA_PASS_DURATION` | โ | `15m` | How long a solved check keeps the visitor on the user budget |
382
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 |
383
383
384
384
### Optional: push mirrors
MODIFY
docs/maintainers/bot-protection.md
+35 -13
@@ -24,12 +24,13 @@
24
24
GET /repos/a/b/commit/<id>
25
25
ExpensiveRequestFilter
26
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
27
+ logged in โ key = "user:<principal>", limit = userLimit
28
+ valid gitshark_human โ key = "pass:<cookie value>", limit = userLimit
29
+ otherwise โ key = "ip:<client-ip>", limit = anonymousLimit
30
+ limiter.tryAcquire(key, limit, window)
31
+ โ within budget: pass through
32
+ โ over budget, anonymous, captcha configured: 303 โ /challenge?redirect=<original>
33
+ โ over budget, otherwise: 429 + Retry-After
33
34
```
34
35
35
36
```
@@ -51,10 +52,24 @@
51
52
to the very requests it is meant to protect. Per-pod counters mean each replica
52
53
enforces its own share of the budget; a global limit belongs at the ingress, not here.
53
54
54
-**Two budgets, two keys.** The threat is an unauthenticated crawler, so anonymous
55
+**Two budgets, three keys.** The threat is an unauthenticated crawler, so anonymous
55
56
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.
57
+larger one. Keying logged-in users by account (not IP) also keeps a shared office IP
58
+from punishing everyone once they sign in. A solved challenge is the third key: the
59
+pass value is stable for its lifetime and unforgeable, so it identifies that visitor
60
+on the larger budget even as their IP changes.
61
+
62
+**A solved challenge raises the budget, it does not lift the limiter.** The pass used
63
+to skip metering entirely, which made one captcha solve โ a few tenths of a cent at a
64
+solving farm, or one headless browser run โ worth a full `pass-duration` of unmetered
65
+scraping at whatever rate the server would answer. Metering the pass keeps the
66
+challenge useful to a person (who never approaches the user budget) while capping what
67
+a solve is worth to a crawler.
68
+
69
+**No second challenge for a caller already on the raised budget.** Logged-in users and
70
+pass holders get a plain `429` when they run out. Re-challenging them would either
71
+loop forever (a logged-in user's pass changes nothing about their key) or hand out a
72
+fresh budget per solve, which is the same bypass through a slower door.
58
73
59
74
**Only four paths, only GET.** `ExpensivePaths` deliberately excludes the git
60
75
transport, `/api/v1`, `runner.v1`, MCP and ActivityPub: those are machine callers with
@@ -80,9 +95,11 @@
80
95
bypass. Conversely a missing/incomplete captcha config does not disable metering: it
81
96
only removes the challenge, and refusals become plain `429`s.
82
97
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.
98
+**Rate limiting on by default, captcha off.** Defaults (30/120 per minute) protect a
99
+fresh instance immediately, while the captcha stays opt-in because it needs
100
+third-party keys and sends visitor IPs to that provider. The numbers sit just above
101
+fast human browsing rather than comfortably above it: a default nobody ever notices is
102
+a default that stops nobody, and an admin who needs more can say so in one variable.
86
103
87
104
## What works today
88
105
@@ -91,7 +108,8 @@
91
108
- `429` + `Retry-After` when no captcha is configured.
92
109
- Turnstile and hCaptcha challenges with server-side `siteverify`, both providers
93
110
driven by the same code path.
94
-- Signed, self-expiring pass cookie that bypasses the limiter for its lifetime.
111
+- Signed, self-expiring pass cookie that moves its holder to the user budget for its
112
+ lifetime, metered under its own key.
95
113
- Open-redirect-safe `?redirect=` handling (server-relative single-slash paths only).
96
114
- Client IP taken from the proxy-aware remote address.
97
115
@@ -99,6 +117,10 @@
99
117
100
118
- **Shared counters across replicas.** Each pod meters independently; a global budget
101
119
would need a shared store (or ingress-level limiting).
120
+- **A cost per solve.** Each solved challenge mints a fresh pass with a fresh budget,
121
+ so a determined crawler can buy more throughput one solve at a time. Bounding it
122
+ would need the pass to carry an identity worth tracking (a mint counter per IP, or a
123
+ proof-of-work cost that rises with repetition).
102
124
- **Per-repository or per-path budgets.** One budget covers all metered paths; a repo
103
125
with a huge history cannot be metered more tightly than a small one.
104
126
- **Configurable path set.** `ExpensivePaths` is compiled in; admins cannot add
MODIFY
docs/users/bot-check.md
+5 -3
@@ -26,15 +26,17 @@
26
26
The check is a Cloudflare Turnstile or hCaptcha widget, depending on what the
27
27
instance's admin configured. Most of the time it solves itself and you are sent
28
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.
29
+while (15 minutes by default), so you are not asked again on every page. It moves
30
+you onto the same, much larger budget signed-in users get โ it is not an unlimited
31
+pass, but normal browsing never comes close to the ceiling.
30
32
31
33
The check needs JavaScript. If you have it turned off, log in instead.
32
34
33
35
## Logging in gives you a much bigger budget
34
36
35
37
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
+per account with a far higher allowance (by default 120 pages per minute instead
39
+of 30). If you keep hitting the check while browsing normally, **log in** โ that
38
40
is the intended fix, not a workaround.
39
41
40
42
## If you keep seeing it
MODIFY
src/main/java/de/workaround/protect/ExpensiveRequestFilter.java
+34 -16
@@ -18,11 +18,19 @@
18
18
* against their own budget, anonymous visitors against a smaller one keyed by client IP โ a crawler
19
19
* without an account is exactly what this defends against.
20
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.
21
+ * <p>Over budget, the response depends on configuration: with a captcha configured an anonymous
22
+ * visitor is sent to {@code /challenge} and can prove they are human, which mints a pass cookie
23
+ * that raises them to the user budget; without one the request is refused with {@code 429} and a
24
+ * {@code Retry-After}. A solved challenge deliberately <em>raises</em> the budget instead of
25
+ * removing it: a bypass would turn one solve โ cheap to buy from a captcha farm โ into a window of
26
+ * completely unmetered scraping.
27
+ *
28
+ * <p>Callers who already spent a raised budget (logged in, or carrying a pass) get the plain
29
+ * {@code 429} rather than another challenge. Re-solving would only hand them a fresh budget, and
30
+ * challenging a caller who cannot improve their standing is an endless loop.
31
+ *
32
+ * <p>Only GETs are metered โ form POSTs already require a session, and a challenge in the middle of
33
+ * one would lose the submitted body.
26
34
*/
27
35
@Provider
28
36
public class ExpensiveRequestFilter implements ContainerRequestFilter
@@ -53,26 +61,36 @@
53
61
{
54
62
return;
55
63
}
56
- if (carriesValidPass(context))
57
- {
58
- return;
59
- }
64
+ String pass = validPass(context);
60
65
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();
66
+ boolean raised = loggedIn || pass != null;
67
+ String key;
68
+ if (loggedIn)
69
+ {
70
+ key = "user:" + identity.getPrincipal().getName();
71
+ }
72
+ else if (pass != null)
73
+ {
74
+ // the pass value is stable for its lifetime, so it identifies this visitor across IPs
75
+ key = "pass:" + pass;
76
+ }
77
+ else
78
+ {
79
+ key = "ip:" + clientAddress.ip();
80
+ }
81
+ int limit = raised ? config.userLimit() : config.anonymousLimit();
65
82
if (limiter.tryAcquire(key, limit, config.window()))
66
83
{
67
84
return;
68
85
}
69
- context.abortWith(config.captchaConfigured() ? challenge(context) : refusal());
86
+ context.abortWith(!raised && config.captchaConfigured() ? challenge(context) : refusal());
70
87
}
71
88
72
- private boolean carriesValidPass(ContainerRequestContext context)
89
+ /** The pass carried by this request, or {@code null} when there is none or it does not verify. */
90
+ private String validPass(ContainerRequestContext context)
73
91
{
74
92
Cookie cookie = context.getCookies().get(HumanPass.COOKIE_NAME);
75
- return cookie != null && humanPass.valid(cookie.getValue());
93
+ return cookie != null && humanPass.valid(cookie.getValue()) ? cookie.getValue() : null;
76
94
}
77
95
78
96
private Response challenge(ContainerRequestContext context)
MODIFY
src/main/java/de/workaround/protect/ProtectionConfig.java
+4 -4
@@ -22,10 +22,10 @@
22
22
@ConfigProperty(name = "gitshark.protect.enabled", defaultValue = "true")
23
23
boolean enabled;
24
24
25
- @ConfigProperty(name = "gitshark.protect.anonymous-limit", defaultValue = "60")
25
+ @ConfigProperty(name = "gitshark.protect.anonymous-limit", defaultValue = "30")
26
26
int anonymousLimit;
27
27
28
- @ConfigProperty(name = "gitshark.protect.user-limit", defaultValue = "600")
28
+ @ConfigProperty(name = "gitshark.protect.user-limit", defaultValue = "120")
29
29
int userLimit;
30
30
31
31
@ConfigProperty(name = "gitshark.protect.window", defaultValue = "1m")
@@ -43,7 +43,7 @@
43
43
@ConfigProperty(name = "gitshark.protect.captcha.verify-url")
44
44
Optional<String> verifyUrl;
45
45
46
- @ConfigProperty(name = "gitshark.protect.captcha.pass-duration", defaultValue = "30m")
46
+ @ConfigProperty(name = "gitshark.protect.captcha.pass-duration", defaultValue = "15m")
47
47
Duration passDuration;
48
48
49
49
public boolean enabled()
@@ -93,7 +93,7 @@
93
93
return selected == CaptchaProvider.NONE ? Optional.empty() : Optional.of(selected.defaultVerifyUrl());
94
94
}
95
95
96
- /** How long a solved challenge keeps lifting the budget for that visitor. */
96
+ /** How long a solved challenge keeps a visitor on the user budget instead of the anonymous one. */
97
97
public Duration passDuration()
98
98
{
99
99
return passDuration;
MODIFY
src/main/resources/application.properties
+10 -8
@@ -148,20 +148,22 @@
148
148
# Bot protection for expensive renderings (commit and diff views, merge-request pages, search).
149
149
# Every caller gets a fixed per-window budget: anonymous visitors keyed by client IP, logged-in
150
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.
151
+# Over budget the request is refused with 429 โ unless a captcha is configured, in which case an
152
+# anonymous visitor is sent to /challenge and a solved check mints a signed pass cookie that raises
153
+# them to the user budget for pass-duration. The pass raises the budget rather than removing it: a
154
+# bypass would turn one bought captcha solve into a window of unmetered scraping.
155
+# provider is none|turnstile|hcaptcha; both keys must be set for the challenge to exist at all (an
156
+# incomplete captcha config just leaves plain 429s). verify-url overrides the provider's own
157
+# siteverify endpoint โ used by tests.
156
158
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.anonymous-limit=${GITSHARK_PROTECT_ANONYMOUS_LIMIT:30}
160
+gitshark.protect.user-limit=${GITSHARK_PROTECT_USER_LIMIT:120}
159
161
gitshark.protect.window=${GITSHARK_PROTECT_WINDOW:1m}
160
162
gitshark.protect.captcha.provider=${GITSHARK_PROTECT_CAPTCHA_PROVIDER:none}
161
163
gitshark.protect.captcha.site-key=${GITSHARK_PROTECT_CAPTCHA_SITE_KEY:}
162
164
gitshark.protect.captcha.secret-key=${GITSHARK_PROTECT_CAPTCHA_SECRET_KEY:}
163
165
gitshark.protect.captcha.verify-url=${GITSHARK_PROTECT_CAPTCHA_VERIFY_URL:}
164
-gitshark.protect.captcha.pass-duration=${GITSHARK_PROTECT_CAPTCHA_PASS_DURATION:30m}
166
+gitshark.protect.captcha.pass-duration=${GITSHARK_PROTECT_CAPTCHA_PASS_DURATION:15m}
165
167
# Tests share one application instance and one limiter across hundreds of requests from 127.0.0.1,
166
168
# so keep the budget effectively unlimited; the tests that exercise the guard set their own tiny
167
169
# limits via a QuarkusTestProfile.
MODIFY
src/test/java/de/workaround/protect/ChallengeFlowTest.java
+44 -0
@@ -23,6 +23,7 @@
23
23
import static org.hamcrest.CoreMatchers.containsString;
24
24
import static org.hamcrest.CoreMatchers.endsWith;
25
25
import static org.hamcrest.CoreMatchers.not;
26
+import static org.hamcrest.CoreMatchers.notNullValue;
26
27
import static org.junit.jupiter.api.Assertions.assertNotNull;
27
28
import static org.junit.jupiter.api.Assertions.assertNull;
28
29
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -44,6 +45,7 @@
44
45
return Map.of(
45
46
"gitshark.protect.enabled", "true",
46
47
"gitshark.protect.anonymous-limit", "1",
48
+ "gitshark.protect.user-limit", "3",
47
49
"gitshark.protect.window", "1m",
48
50
"gitshark.protect.captcha.provider", "turnstile",
49
51
"gitshark.protect.captcha.site-key", "test-site-key",
@@ -112,6 +114,48 @@
112
114
}
113
115
114
116
@Test
117
+ void aPassLiftsTheBudgetToTheUserOneButDoesNotRemoveIt()
118
+ {
119
+ String commit = seedCommitPath("ch-metered");
120
+ String pass = solve(commit);
121
+
122
+ // the pass carries its own budget โ the anonymous one is already spent, this is the user one
123
+ for (int i = 0; i < 3; i++)
124
+ {
125
+ given().cookie("gitshark_human", pass).when().get(commit).then().statusCode(200);
126
+ }
127
+ given().cookie("gitshark_human", pass).redirects().follow(false).when().get(commit)
128
+ .then().statusCode(429);
129
+ }
130
+
131
+ @Test
132
+ void anExhaustedPassIsRefusedRatherThanChallengedAgain()
133
+ {
134
+ String commit = seedCommitPath("ch-noloop");
135
+ String pass = solve(commit);
136
+
137
+ for (int i = 0; i < 3; i++)
138
+ {
139
+ given().cookie("gitshark_human", pass).when().get(commit).then().statusCode(200);
140
+ }
141
+ // re-solving would only reset the budget, so an exhausted pass holder is refused outright
142
+ given().cookie("gitshark_human", pass).redirects().follow(false).when().get(commit)
143
+ .then().statusCode(429)
144
+ .header("Retry-After", notNullValue());
145
+ }
146
+
147
+ private String solve(String redirect)
148
+ {
149
+ return given().redirects().follow(false)
150
+ .formParam("redirect", redirect)
151
+ .formParam("cf-turnstile-response", "good-token")
152
+ .when().post("/challenge")
153
+ .then().statusCode(303)
154
+ .extract().response()
155
+ .getCookie("gitshark_human");
156
+ }
157
+
158
+ @Test
115
159
void aRejectedTokenMintsNoPass()
116
160
{
117
161
Response rejected = given().redirects().follow(false)