Skip to content

Commit 418c0f4

Browse files
committed
[minor] add proof of work failover
1 parent 882030a commit 418c0f4

7 files changed

Lines changed: 1066 additions & 44 deletions

File tree

README.md

Lines changed: 31 additions & 4 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 --> POW_CHALLENGE{Proof-of-Work challenge}
29+
CIRCUIT -- No --> CAPTCHA_CHALLENGE{turnstile/recaptcha/hcaptcha challenge}
30+
POW_CHALLENGE -- Pass --> Continue(Go to original destination)
31+
POW_CHALLENGE -- Fail --> Stuck
32+
CAPTCHA_CHALLENGE -- Pass --> Continue(Go to original destination)
33+
CAPTCHA_CHALLENGE -- Fail --> Stuck
3034
```
3135
</details>
3236

@@ -99,9 +103,11 @@ services:
99103
| `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 |
100104
| `protectRoutes` | `[]string` (required) | `""` | Comma-separated list of route prefixes/suffixes/regex patterns to protect. |
101105
| `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`. |
106+
| `captchaProvider` | `string` (required) | `""` | The captcha type to use. Supported values: `turnstile`, `hcaptcha`, `recaptcha`, and `pow` (proof-of-work). |
103107
| `siteKey` | `string` (required) | `""` | The captcha site key. |
104108
| `secretKey` | `string` (required) | `""` | The captcha secret key. |
109+
| `periodSeconds` | `int` | `30` | Health check interval (in seconds) for the primary captcha provider. The circuit breaker uses this to detect provider outages. |
110+
| `failureThreshold` | `int` | `3` | Number of consecutive health check failures before the circuit breaker opens and switches to proof-of-work fallback. |
105111
| `rateLimit` | `uint` | `20` | Maximum requests allowed from a subnet before a challenge is triggered. |
106112
| `window` | `int` | `86400` | Duration (in seconds) for monitoring requests per subnet. |
107113
| `ipv4subnetMask` | `int` | `16` | CIDR subnet mask to group IPv4 addresses for rate limiting. |
@@ -122,6 +128,27 @@ services:
122128
| `persistentStateFile` | `string` | `""` | File path to persist rate limiter state across Traefik restarts. In Docker, mount this file from the host. |
123129
| `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). |
124130

131+
### Circuit Breaker
132+
133+
The circuit breaker provides automatic failover when the primary captcha provider (Turnstile, reCAPTCHA, or hCaptcha) becomes unavailable. When enabled, it:
134+
135+
1. **Monitors provider health**: Periodically sends HEAD requests to the provider's JavaScript file (every `periodSeconds`, default 30s)
136+
2. **Detects failures**: Counts consecutive health check failures
137+
3. **Opens circuit**: After `failureThreshold` consecutive failures (default 3), switches to proof-of-work fallback
138+
4. **Falls back to PoW**: Uses browser-based SHA-256 proof-of-work instead of external provider
139+
5. **Auto-recovery**: Automatically returns to primary provider when health checks succeed
140+
141+
**Proof-of-Work Fallback:**
142+
- Requires browsers to compute SHA-256 hashes with 4 leading zeros
143+
- Self-contained (no external dependencies)
144+
- Typical solve time: <1 second
145+
- Progress shown to users during computation
146+
- Server validates solutions to prevent cheating
147+
148+
**Configuration:**
149+
- Circuit breaker is **enabled by default** with `periodSeconds: 30` and `failureThreshold: 3`
150+
- To disable: set both `periodSeconds: 0` and `failureThreshold: 0`
151+
- The `pow` provider can also be used directly as the primary provider (no circuit breaker needed)
125152

126153
### Good Bots
127154

challenge.tmpl.html

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,37 @@ <h1>Verifying connection</h1>
1616
data-language="auto"
1717
data-retry="auto"
1818
interval="8000"
19-
data-appearance="always">
20-
</div>
19+
data-appearance="always"
20+
{{- if .Challenge }}
21+
data-challenge="{{ .Challenge }}"
22+
{{- end }}
23+
{{- if .Difficulty }}
24+
data-difficulty="{{ .Difficulty }}"
25+
{{- end }}
26+
></div>
2127
<input type="hidden" name="destination" value="{{ .Destination }}">
2228
</form>
2329
<script type="text/javascript">
2430
function captchaCallback(token) {
31+
var form = document.getElementById("captcha-form");
32+
var captchaDiv = document.querySelector('[data-callback]');
33+
var frontendKey = captchaDiv.className;
34+
35+
// Create hidden input for the token if it doesn't exist
36+
var inputName = frontendKey + "-response";
37+
var existingInput = form.querySelector('input[name="' + inputName + '"]');
38+
if (!existingInput) {
39+
var input = document.createElement("input");
40+
input.type = "hidden";
41+
input.name = inputName;
42+
input.value = token;
43+
form.appendChild(input);
44+
} else {
45+
existingInput.value = token;
46+
}
47+
2548
setTimeout(function() {
26-
document.getElementById("captcha-form").submit();
49+
form.submit();
2750
}, 1000);
2851
}
2952
</script>

internal/helper/pow.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package helper
2+
3+
import (
4+
"crypto/rand"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"fmt"
8+
"strconv"
9+
"strings"
10+
"time"
11+
)
12+
13+
const (
14+
// DefaultPowDifficulty is the default number of leading zeros required in the hash
15+
DefaultPowDifficulty = 4
16+
)
17+
18+
// GeneratePowChallenge generates a random challenge string for proof-of-work
19+
func GeneratePowChallenge() string {
20+
// Use timestamp + random bytes for uniqueness
21+
timestamp := time.Now().UnixNano()
22+
randomBytes := make([]byte, 16)
23+
_, _ = rand.Read(randomBytes)
24+
return fmt.Sprintf("%d-%s", timestamp, hex.EncodeToString(randomBytes))
25+
}
26+
27+
// VerifyPowSolution verifies that a nonce produces a valid proof-of-work for the given challenge
28+
func VerifyPowSolution(challenge string, nonce int, difficulty int) bool {
29+
// Compute SHA-256(challenge + nonce)
30+
input := challenge + strconv.Itoa(nonce)
31+
hash := sha256.Sum256([]byte(input))
32+
hashHex := hex.EncodeToString(hash[:])
33+
34+
// Check if hash has required number of leading zeros
35+
target := strings.Repeat("0", difficulty)
36+
return strings.HasPrefix(hashHex, target)
37+
}
38+
39+
// GetPowJS returns the proof-of-work JavaScript implementation
40+
func GetPowJS() string {
41+
return `// Proof of Work CAPTCHA
42+
(function() {
43+
function initPoW() {
44+
var captchaDiv = document.querySelector('[data-callback]');
45+
if (!captchaDiv) {
46+
console.error('PoW: captcha div not found');
47+
return;
48+
}
49+
50+
var callbackName = captchaDiv.getAttribute('data-callback');
51+
var challenge = captchaDiv.getAttribute('data-challenge');
52+
var difficulty = parseInt(captchaDiv.getAttribute('data-difficulty') || '4', 10);
53+
54+
if (!callbackName || !challenge) {
55+
console.error('PoW: missing callback or challenge');
56+
return;
57+
}
58+
59+
var progressDiv = document.createElement('div');
60+
progressDiv.id = 'pow-progress';
61+
progressDiv.style.marginTop = '20px';
62+
progressDiv.style.fontFamily = 'monospace';
63+
progressDiv.textContent = 'Computing proof of work...';
64+
captchaDiv.parentNode.insertBefore(progressDiv, captchaDiv.nextSibling);
65+
66+
// Create worker from function
67+
var worker = createWorker(powWorker);
68+
69+
worker.onmessage = function(e) {
70+
if (e.data.nonce !== undefined) {
71+
progressDiv.textContent = 'Proof of work completed in ' + (e.data.duration / 1000).toFixed(2) + 's (tried ' + e.data.nonce + ' nonces)';
72+
var token = challenge + ':' + e.data.nonce;
73+
if (typeof window[callbackName] === 'function') {
74+
window[callbackName](token);
75+
}
76+
worker.terminate();
77+
} else if (e.data.progress !== undefined) {
78+
progressDiv.textContent = 'Computing proof of work... (' + e.data.progress + ' attempts, ' + (e.data.duration / 1000).toFixed(1) + 's)';
79+
}
80+
};
81+
82+
worker.onerror = function(error) {
83+
console.error('PoW worker error:', error);
84+
progressDiv.textContent = 'Error computing proof of work';
85+
};
86+
87+
worker.postMessage({
88+
challenge: challenge,
89+
difficulty: difficulty
90+
});
91+
}
92+
93+
// Helper to create worker from function
94+
function createWorker(fn) {
95+
var blob = new Blob(['(' + fn.toString() + ')()'], { type: 'application/javascript' });
96+
return new Worker(URL.createObjectURL(blob));
97+
}
98+
99+
// Worker function (will be serialized and run in worker context)
100+
function powWorker() {
101+
self.onmessage = function(e) {
102+
var challenge = e.data.challenge;
103+
var difficulty = e.data.difficulty;
104+
var target = "0".repeat(difficulty);
105+
var nonce = 0;
106+
var startTime = Date.now();
107+
108+
while (true) {
109+
var hash = sha256(challenge + nonce);
110+
if (hash.substring(0, difficulty) === target) {
111+
self.postMessage({ nonce: nonce, hash: hash, duration: Date.now() - startTime });
112+
return;
113+
}
114+
nonce++;
115+
if (nonce % 10000 === 0) {
116+
self.postMessage({ progress: nonce, duration: Date.now() - startTime });
117+
}
118+
}
119+
};
120+
121+
function sha256(ascii) {
122+
function rightRotate(value, amount) { return (value >>> amount) | (value << (32 - amount)); }
123+
var mathPow = Math.pow, maxWord = mathPow(2, 32), lengthProperty = "length", i, j, result = "";
124+
var words = [], asciiBitLength = ascii[lengthProperty] * 8;
125+
var hash = sha256.h = sha256.h || [], k = sha256.k = sha256.k || [], primeCounter = k[lengthProperty];
126+
var isComposite = {};
127+
for (var candidate = 2; primeCounter < 64; candidate++) {
128+
if (!isComposite[candidate]) {
129+
for (i = 0; i < 313; i += candidate) { isComposite[i] = candidate; }
130+
hash[primeCounter] = (mathPow(candidate, .5) * maxWord) | 0;
131+
k[primeCounter++] = (mathPow(candidate, 1 / 3) * maxWord) | 0;
132+
}
133+
}
134+
ascii += "\x80";
135+
while (ascii[lengthProperty] % 64 - 56) ascii += "\x00";
136+
for (i = 0; i < ascii[lengthProperty]; i++) {
137+
j = ascii.charCodeAt(i);
138+
if (j >> 8) return;
139+
words[i >> 2] |= j << ((3 - i) % 4) * 8;
140+
}
141+
words[words[lengthProperty]] = ((asciiBitLength / maxWord) | 0);
142+
words[words[lengthProperty]] = (asciiBitLength);
143+
for (j = 0; j < words[lengthProperty];) {
144+
var w = words.slice(j, j += 16), oldHash = hash;
145+
hash = hash.slice(0, 8);
146+
for (i = 0; i < 64; i++) {
147+
var w15 = w[i - 15], w2 = w[i - 2], a = hash[0], e = hash[4];
148+
var temp1 = hash[7] + (rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25)) + ((e & hash[5]) ^ ((~e) & hash[6])) + k[i] + (w[i] = (i < 16) ? w[i] : (w[i - 16] + (rightRotate(w15, 7) ^ rightRotate(w15, 18) ^ (w15 >>> 3)) + w[i - 7] + (rightRotate(w2, 17) ^ rightRotate(w2, 19) ^ (w2 >>> 10))) | 0);
149+
var temp2 = (rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22)) + ((a & hash[1]) ^ (a & hash[2]) ^ (hash[1] & hash[2]));
150+
hash = [(temp1 + temp2) | 0].concat(hash);
151+
hash[4] = (hash[4] + temp1) | 0;
152+
}
153+
for (i = 0; i < 8; i++) { hash[i] = (hash[i] + oldHash[i]) | 0; }
154+
}
155+
for (i = 0; i < 8; i++) {
156+
for (j = 3; j + 1; j--) {
157+
var b = (hash[i] >> (j * 8)) & 255;
158+
result += ((b < 16) ? 0 : "") + b.toString(16);
159+
}
160+
}
161+
return result;
162+
}
163+
}
164+
165+
if (document.readyState === 'loading') {
166+
document.addEventListener('DOMContentLoaded', initPoW);
167+
} else {
168+
initPoW();
169+
}
170+
})();`
171+
}

internal/helper/pow_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package helper
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestGeneratePowChallenge(t *testing.T) {
9+
challenge1 := GeneratePowChallenge()
10+
challenge2 := GeneratePowChallenge()
11+
12+
// Challenges should be unique
13+
if challenge1 == challenge2 {
14+
t.Errorf("Expected unique challenges, got same: %s", challenge1)
15+
}
16+
17+
// Challenge should contain a hyphen separator
18+
if !strings.Contains(challenge1, "-") {
19+
t.Errorf("Expected challenge to contain hyphen separator, got: %s", challenge1)
20+
}
21+
22+
// Challenge should be non-empty
23+
if len(challenge1) == 0 {
24+
t.Error("Expected non-empty challenge")
25+
}
26+
}
27+
28+
func TestVerifyPowSolution(t *testing.T) {
29+
tests := []struct {
30+
name string
31+
challenge string
32+
nonce int
33+
difficulty int
34+
want bool
35+
}{
36+
{
37+
name: "Valid solution difficulty 1",
38+
challenge: "test-challenge",
39+
nonce: 8, // This nonce produces a hash starting with 0 for "test-challenge8"
40+
difficulty: 1,
41+
want: true,
42+
},
43+
{
44+
name: "Invalid solution - wrong nonce",
45+
challenge: "test-challenge",
46+
nonce: 0,
47+
difficulty: 4,
48+
want: false,
49+
},
50+
{
51+
name: "Difficulty 0 always passes",
52+
challenge: "any-challenge",
53+
nonce: 123,
54+
difficulty: 0,
55+
want: true,
56+
},
57+
}
58+
59+
for _, tt := range tests {
60+
t.Run(tt.name, func(t *testing.T) {
61+
got := VerifyPowSolution(tt.challenge, tt.nonce, tt.difficulty)
62+
if got != tt.want {
63+
t.Errorf("VerifyPowSolution() = %v, want %v", got, tt.want)
64+
}
65+
})
66+
}
67+
}
68+
69+
func TestVerifyPowSolutionFindValid(t *testing.T) {
70+
// Test that we can find a valid nonce for a given challenge
71+
challenge := "test-find-valid"
72+
difficulty := 2
73+
74+
// Try to find a valid nonce (limit search to prevent infinite loop)
75+
found := false
76+
for nonce := 0; nonce < 10000; nonce++ {
77+
if VerifyPowSolution(challenge, nonce, difficulty) {
78+
found = true
79+
t.Logf("Found valid nonce %d for challenge %s with difficulty %d", nonce, challenge, difficulty)
80+
break
81+
}
82+
}
83+
84+
if !found {
85+
t.Errorf("Could not find valid nonce within 10000 attempts for difficulty %d", difficulty)
86+
}
87+
}
88+
89+
func TestGetPowJS(t *testing.T) {
90+
js := GetPowJS()
91+
92+
// Check that JS is not empty
93+
if len(js) == 0 {
94+
t.Error("Expected non-empty JavaScript")
95+
}
96+
97+
// Check that JS contains key components
98+
expectedComponents := []string{
99+
"data-callback",
100+
"data-challenge",
101+
"data-difficulty",
102+
"sha256",
103+
"nonce",
104+
"challenge",
105+
}
106+
107+
for _, component := range expectedComponents {
108+
if !strings.Contains(js, component) {
109+
t.Errorf("Expected JavaScript to contain '%s'", component)
110+
}
111+
}
112+
}

0 commit comments

Comments
 (0)