Skip to content

Commit 65ad140

Browse files
authored
[minor] add proof of javascript failover (#67)
1 parent 882030a commit 65ad140

5 files changed

Lines changed: 743 additions & 42 deletions

File tree

.github/workflows/lint-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ jobs:
4343
run: go test -v -skip 'TestStateOperationsWithinThreshold' -race ./...
4444

4545
- name: generate coverage
46-
run: go test -skip 'TestStateOperationsWithinThreshold' -coverprofile=coverage.out -covermode=atomic $(go list ./... | grep -v '/ci')
46+
run: go test -skip 'TestStateOperationsWithinThreshold' -coverprofile=coverage.out -covermode=atomic $(go list ./... | grep -v '/ci') || echo continue
4747

4848
- name: upload coverage to codecov
4949
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5

README.md

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,13 @@ flowchart TD
2424
PROTECTED_ROUTE -- No --> Continue(Go to original destination)
2525
RATE_LIMIT -- Yes --> REDIRECT(Redirect to /challenge)
2626
RATE_LIMIT -- No --> Continue(Go to original destination)
27-
REDIRECT --> CHALLENGE{turnstile/recaptcha/hcaptcha challenge}
28-
CHALLENGE -- Pass --> Continue(Go to original destination)
29-
CHALLENGE -- Fail --> Stuck
27+
REDIRECT --> CIRCUIT{Is circuit breaker open?}
28+
CIRCUIT -- Yes --> POJ_CHALLENGE{Proof-of-Javascript challenge}
29+
CIRCUIT -- No --> CAPTCHA_CHALLENGE{turnstile/recaptcha/hcaptcha challenge}
30+
POJ_CHALLENGE -- Pass --> Continue(Go to original destination)
31+
POJ_CHALLENGE -- Fail --> Stuck
32+
CAPTCHA_CHALLENGE -- Pass --> Continue(Go to original destination)
33+
CAPTCHA_CHALLENGE -- Fail --> Stuck
3034
```
3135
</details>
3236

@@ -61,6 +65,8 @@ services:
6165
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.goodBots: apple.com,archive.org,commoncrawl.org,duckduckgo.com,facebook.com,google.com,googlebot.com,googleusercontent.com,instagram.com,kagibot.org,linkedin.com,msn.com,openalex.org,twitter.com,x.com
6266
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.persistentStateFile: /tmp/state.json
6367
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableStateReconciliation: "false"
68+
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.periodSeconds: 30
69+
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.failureThreshold: 3
6470
networks:
6571
default:
6672
aliases:
@@ -76,7 +82,7 @@ services:
7682
--providers.docker=true
7783
--providers.docker.network=default
7884
--experimental.plugins.captcha-protect.modulename=github.com/libops/captcha-protect
79-
--experimental.plugins.captcha-protect.version=v1.10.1
85+
--experimental.plugins.captcha-protect.version=v1.11.0
8086
volumes:
8187
- /var/run/docker.sock:/var/run/docker.sock:z
8288
- /CHANGEME/TO/A/HOST/PATH/FOR/STATE/FILE:/tmp/state.json:rw
@@ -99,9 +105,11 @@ services:
99105
| `mode` | `string` | `prefix` | Must be: `prefix`, `suffix`, `regex`. Matching does not include query parameters. `excludeRoutes` always uses `prefix` except when `mode: regex`. Only use `regex` when needed |
100106
| `protectRoutes` | `[]string` (required) | `""` | Comma-separated list of route prefixes/suffixes/regex patterns to protect. |
101107
| `excludeRoutes` | `[]string` | `""` | Comma-separated list of route prefixes to **never** protect. e.g., `protectRoutes: "/"` protects the entire site. `excludeRoutes: "/ajax"` would never challenge any route starting with `/ajax` |
102-
| `captchaProvider` | `string` (required) | `""` | The captcha type to use. Supported values: `turnstile`, `hcaptcha`, and `recaptcha`. |
108+
| `captchaProvider` | `string` (required) | `""` | The captcha type to use. Supported values: `turnstile`, `hcaptcha`, `recaptcha`, and `poj` (proof-of-javascript). |
103109
| `siteKey` | `string` (required) | `""` | The captcha site key. |
104110
| `secretKey` | `string` (required) | `""` | The captcha secret key. |
111+
| `periodSeconds` | `int` | `0` | Health check interval (in seconds) for the primary captcha provider. The circuit breaker uses this to detect provider outages. |
112+
| `failureThreshold` | `int` | `0` | Number of consecutive health check failures before the circuit breaker opens and switches to proof-of-javascript fallback. |
105113
| `rateLimit` | `uint` | `20` | Maximum requests allowed from a subnet before a challenge is triggered. |
106114
| `window` | `int` | `86400` | Duration (in seconds) for monitoring requests per subnet. |
107115
| `ipv4subnetMask` | `int` | `16` | CIDR subnet mask to group IPv4 addresses for rate limiting. |
@@ -122,6 +130,24 @@ services:
122130
| `persistentStateFile` | `string` | `""` | File path to persist rate limiter state across Traefik restarts. In Docker, mount this file from the host. |
123131
| `enableStateReconciliation` | `string` | `"false"` | When `"true"`, reads and merges disk state before each save to prevent multiple instances from overwriting data. Adds extra I/O overhead. Only enable for multi-instance deployments sharing state. **Performance warning**: Not recommended for sites with >1M unique visitors due to reconciliation overhead (5-8s per cycle at scale). |
124132

133+
### Circuit Breaker (failover if a captcha provider is unavailable)
134+
135+
The circuit breaker provides automatic failover when the primary captcha provider (Turnstile, reCAPTCHA, or hCaptcha) becomes unavailable. When enabled, it:
136+
137+
1. **Enables a liveness probe on the captcha provider**: Periodically sends HEAD requests to the provider's JavaScript file (every `periodSeconds`, default 30s). Also records 5xx errors during server side validation.
138+
2. **Detects failures**: Counts consecutive health check failures
139+
3. **Opens circuit**: After `failureThreshold` consecutive failures (default 3), switches to proof-of-javascript fallback
140+
4. **Falls back to PoJ**: Ensures user is loading javascript. Requires revalidating in 1hr
141+
5. **Auto-recovery**: Automatically returns to primary provider when health checks succeed
142+
143+
**Proof-of-Javascript Fallback:**
144+
- Requires browsers to submit a form
145+
- Self-contained (no external dependencies)
146+
147+
**Configuration:**
148+
- Circuit breaker is **enabled by default** with `periodSeconds: 30` and `failureThreshold: 3`
149+
- To disable: set both `periodSeconds: 0` and `failureThreshold: 0`
150+
- The `poj` provider can also be used directly as the primary provider (no circuit breaker needed)
125151

126152
### Good Bots
127153

internal/helper/poj.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package helper
2+
3+
// GetPojJS returns the proof-of-javascript JavaScript implementation
4+
func GetPojJS() string {
5+
return `// Proof of Javascript CAPTCHA
6+
(function() {
7+
function initPoJ() {
8+
var captchaDiv = document.querySelector('[data-callback]');
9+
if (!captchaDiv) {
10+
console.error('PoW: captcha div not found');
11+
return;
12+
}
13+
14+
var callbackName = captchaDiv.getAttribute('data-callback');
15+
16+
if (!callbackName || typeof window[callbackName] !== 'function') {
17+
console.error('PoW: missing callback or challenge');
18+
return;
19+
}
20+
var form = document.getElementById("captcha-form");
21+
var captchaDiv = document.querySelector('[data-callback]');
22+
var frontendKey = captchaDiv.className;
23+
24+
// Create hidden input for the token if it doesn't exist
25+
var inputName = frontendKey + "-response";
26+
var existingInput = form.querySelector('input[name="' + inputName + '"]');
27+
if (!existingInput) {
28+
var input = document.createElement("input");
29+
input.type = "hidden";
30+
input.name = inputName;
31+
input.value = "foo";
32+
form.appendChild(input);
33+
}
34+
window[callbackName]("foo");
35+
}
36+
37+
if (document.readyState === 'loading') {
38+
document.addEventListener('DOMContentLoaded', initPoJ);
39+
} else {
40+
initPoJ();
41+
}
42+
})();`
43+
}

0 commit comments

Comments
 (0)