"ddos challenge" style script #4

Open
luna wants to merge 20 commits from claude/ddos-protection-challenge-01CMAtrK6Dt24x9Q3v6Gz9fS into mistress
5 changed files with 715 additions and 9 deletions
Showing only changes of commit 0ca555f646 - Show all commits

View file

@ -54,6 +54,14 @@ return {
-- Alternative: Protect everything (same as leaving protected_paths empty) -- Alternative: Protect everything (same as leaving protected_paths empty)
-- protected_paths = {}, -- protected_paths = {},
-- Challenge type: button (default), question, or pow
-- See conf.example.ddos_protection_challenge_types.lua for detailed examples
challenge_type = 'button', -- Options: 'button', 'question', 'pow'
-- Proof-of-work difficulty (only used if challenge_type = 'pow')
-- Higher = more protection but slower user experience
-- pow_difficulty = 4, -- 3=fast, 4=moderate, 5=slow, 6=very slow
} }
} }
} }

View file

@ -0,0 +1,79 @@
-- Example configurations for DDoS Protection Challenge module
-- Shows different challenge types you can experiment with
-- IMPORTANT: This module requires nginx shared dictionaries.
-- Add these to your nginx http block:
--
-- lua_shared_dict aproxy_bans 10m;
-- lua_shared_dict aproxy_tokens 10m;
-- OPTION 1: Simple button challenge (easiest for users)
return {
version = 1,
wantedScripts = {
['ddos_protection_challenge'] = {
ban_duration = 3600,
token_duration = 86400,
cookie_name = 'aproxy_token',
shared_dict_bans = 'aproxy_bans',
shared_dict_tokens = 'aproxy_tokens',
challenge_type = 'button', -- Just click a button
protected_paths = {'/api/.*', '/search'}
}
}
}
--[[ OPTION 2: Multiple-choice question challenge (better bot filtering)
return {
version = 1,
wantedScripts = {
['ddos_protection_challenge'] = {
ban_duration = 3600,
token_duration = 86400,
cookie_name = 'aproxy_token',
shared_dict_bans = 'aproxy_bans',
shared_dict_tokens = 'aproxy_tokens',
challenge_type = 'question', -- Answer a simple question
protected_paths = {'/api/.*', '/search'}
}
}
}
--]]
--[[ OPTION 3: Proof-of-work challenge (computational challenge, best bot protection)
return {
version = 1,
wantedScripts = {
['ddos_protection_challenge'] = {
ban_duration = 3600,
token_duration = 86400,
cookie_name = 'aproxy_token',
shared_dict_bans = 'aproxy_bans',
shared_dict_tokens = 'aproxy_tokens',
challenge_type = 'pow', -- Client must solve SHA-256 proof-of-work
pow_difficulty = 4, -- 4 leading zeros (takes ~1-3 seconds)
-- Increase for harder challenge:
-- 3 = ~0.1s, 4 = ~1-3s, 5 = ~10-30s, 6 = ~few minutes
protected_paths = {'/api/.*', '/search'}
}
}
}
--]]
--[[ OPTION 4: Protect everything with PoW (maximum protection)
return {
version = 1,
wantedScripts = {
['ddos_protection_challenge'] = {
ban_duration = 7200, -- 2 hour ban
token_duration = 43200, -- 12 hour token
cookie_name = 'aproxy_token',
shared_dict_bans = 'aproxy_bans',
shared_dict_tokens = 'aproxy_tokens',
challenge_type = 'pow',
pow_difficulty = 5, -- Harder challenge
-- No protected_paths = protect ALL paths
}
}
}
--]]

View file

@ -76,6 +76,20 @@ protected_paths = {'/api/.*', '/search'}
-- while requiring challenge for costly operations -- while requiring challenge for costly operations
``` ```
**Challenge Types**: Choose from three different challenge mechanisms:
```lua
-- Option 1: Simple button (default) - easiest for users
challenge_type = 'button'
-- Option 2: Multiple-choice question - better bot filtering
challenge_type = 'question'
-- Option 3: Proof-of-work - computational challenge, strongest protection
challenge_type = 'pow'
pow_difficulty = 4 -- Number of leading zeros (4 = ~1-3 seconds)
```
### Configuration Options ### Configuration Options
| Option | Type | Default | Description | | Option | Type | Default | Description |
@ -86,6 +100,8 @@ protected_paths = {'/api/.*', '/search'}
| `shared_dict_bans` | string | `aproxy_bans` | Name of nginx shared dict for banned IPs | | `shared_dict_bans` | string | `aproxy_bans` | Name of nginx shared dict for banned IPs |
| `shared_dict_tokens` | string | `aproxy_tokens` | Name of nginx shared dict for valid tokens | | `shared_dict_tokens` | string | `aproxy_tokens` | Name of nginx shared dict for valid tokens |
| `protected_paths` | list | `[]` (all paths) | List of PCRE regex patterns for paths to protect. If empty, all paths are protected | | `protected_paths` | list | `[]` (all paths) | List of PCRE regex patterns for paths to protect. If empty, all paths are protected |
| `challenge_type` | string | `button` | Type of challenge: `button`, `question`, or `pow` |
| `pow_difficulty` | number | 4 | Proof-of-work difficulty (leading zeros). Only used when `challenge_type` is `pow` |
## Special Endpoints ## Special Endpoints
@ -114,6 +130,83 @@ This module uses two special endpoints:
- HTML parsers might find and click the honeypot link → IP banned - HTML parsers might find and click the honeypot link → IP banned
- Sophisticated bots have to solve the challenge, slowing them down significantly - Sophisticated bots have to solve the challenge, slowing them down significantly
## Challenge Types
The module supports three different types of challenges, allowing you to experiment with different DDoS mitigation strategies:
### 1. Button Challenge (`challenge_type = 'button'`)
**How it works**: Users see a simple page with a "Verify I'm Human" button. Click the button to pass.
**Pros**:
- Easiest for legitimate users
- No friction for human visitors
- Fast (instant)
**Cons**:
- Can be bypassed by sophisticated bots that can interact with forms
- Minimal computational cost for attackers
**Best for**: General protection where UX is priority
```lua
challenge_type = 'button'
```
### 2. Question Challenge (`challenge_type = 'question'`)
**How it works**: Users must answer a simple multiple-choice question (e.g., "What is 7 + 5?", "How many days in a week?")
**Pros**:
- Harder for simple bots to bypass
- Still easy for humans
- Moderate filtering of automated tools
**Cons**:
- Requires human interaction
- Can be annoying if cookies expire frequently
- Sophisticated bots with NLP can solve these
**Best for**: Balancing security and UX, filtering out simple scrapers
```lua
challenge_type = 'question'
```
### 3. Proof-of-Work Challenge (`challenge_type = 'pow'`)
**How it works**: Client's browser must compute a SHA-256 hash with a specific number of leading zeros. JavaScript automatically solves this in the background.
**Pros**:
- Strong protection against volumetric attacks
- Requires actual computational cost from attacker
- Transparent to user (happens automatically in ~1-3 seconds)
- Bots must burn CPU time to access your site
**Cons**:
- Requires JavaScript enabled
- Uses client CPU (battery drain on mobile)
- Slower than other methods (configurable)
- Can be bypassed by distributed attackers (but at higher cost)
**Best for**: Sites under active attack, expensive endpoints, maximum protection
```lua
challenge_type = 'pow'
pow_difficulty = 4 -- Difficulty levels:
-- 3 = ~0.1 seconds (light)
-- 4 = ~1-3 seconds (moderate, default)
-- 5 = ~10-30 seconds (strong)
-- 6 = ~few minutes (very strong)
```
**How PoW difficulty works**: The `pow_difficulty` setting determines how many leading zeros the hash must have. Each additional zero makes the challenge ~16x harder:
- Difficulty 3: Client tries ~4,000 hashes (0.1s on modern device)
- Difficulty 4: Client tries ~65,000 hashes (1-3s)
- Difficulty 5: Client tries ~1,000,000 hashes (10-30s)
This creates real computational cost for attackers - a bot making 1000 requests/sec would need to spend 1000-3000 seconds of CPU time with difficulty 4.
## Path-Based Protection ## Path-Based Protection
You can configure the module to protect only specific paths, which is useful for: You can configure the module to protect only specific paths, which is useful for:

View file

@ -29,12 +29,25 @@ local function getCookieValue(cookie_header, cookie_name)
return nil return nil
end end
-- Question pool for multiple-choice challenges
local QUESTIONS = {
{q = "What is 7 + 5?", answers = {"10", "12", "14", "15"}, correct = 2},
{q = "How many days in a week?", answers = {"5", "6", "7", "8"}, correct = 3},
{q = "What color is the sky on a clear day?", answers = {"Green", "Blue", "Red", "Yellow"}, correct = 2},
{q = "How many sides does a triangle have?", answers = {"2", "3", "4", "5"}, correct = 2},
{q = "What is 3 × 4?", answers = {"7", "10", "12", "16"}, correct = 3},
{q = "How many hours in a day?", answers = {"12", "20", "24", "48"}, correct = 3},
{q = "What comes after Tuesday?", answers = {"Monday", "Wednesday", "Thursday", "Friday"}, correct = 2},
}
local function challengeInit(cfg) local function challengeInit(cfg)
-- Get references to shared dictionaries -- Get references to shared dictionaries
local state = { local state = {
bans_dict = ngx.shared[cfg.shared_dict_bans], bans_dict = ngx.shared[cfg.shared_dict_bans],
tokens_dict = ngx.shared[cfg.shared_dict_tokens], tokens_dict = ngx.shared[cfg.shared_dict_tokens],
protected_paths = cfg.protected_paths or {} protected_paths = cfg.protected_paths or {},
challenge_type = cfg.challenge_type or 'button',
pow_difficulty = cfg.pow_difficulty or 4
} }
if not state.bans_dict then if not state.bans_dict then
@ -48,8 +61,8 @@ local function challengeInit(cfg)
return state return state
end end
local function serveChallengePage(original_uri) local function serveButtonChallenge(original_uri)
-- HTML challenge page with honeypot -- Simple button challenge page with honeypot
local html = [[ local html = [[
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -148,6 +161,266 @@ local function serveChallengePage(original_uri)
return 403, html return 403, html
end end
local function serveQuestionChallenge(original_uri, state)
-- Select a random question
math.randomseed(ngx.time() * 1000 + math.random(1, 1000))
local q_idx = math.random(1, #QUESTIONS)
local question = QUESTIONS[q_idx]
-- Generate a challenge ID to store the correct answer
local challenge_id = generateToken()
-- Store the correct answer temporarily (5 minutes)
state.tokens_dict:set("challenge:" .. challenge_id, question.correct, 300)
-- Build answer options HTML
local options_html = {}
for i, answer in ipairs(question.answers) do
table.insert(options_html, string.format(
'<label class="option"><input type="radio" name="answer" value="%d" required><span>%s</span></label>',
i, answer
))
end
local html = [[
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Security Check</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
background: white;
padding: 3rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
max-width: 450px;
text-align: center;
}
h1 {
color: #333;
margin-bottom: 1rem;
font-size: 1.75rem;
}
.question {
color: #444;
font-size: 1.1rem;
margin: 2rem 0;
font-weight: 500;
}
.options {
text-align: left;
margin: 2rem 0;
}
.option {
display: block;
padding: 1rem;
margin: 0.75rem 0;
border: 2px solid #e0e0e0;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.option:hover {
border-color: #667eea;
background: #f8f9ff;
}
.option input[type="radio"] {
margin-right: 0.75rem;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 1rem 2rem;
font-size: 1rem;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
width: 100%;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.4);
}
.honeypot {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>
</head>
<body>
<div class="container">
<h1>🛡 Security Check</h1>
<p class="question">]] .. question.q .. [[</p>
<form method="POST" action="/__aproxy_challenge_verify">
<input type="hidden" name="return_to" value="]] .. original_uri .. [[">
<input type="hidden" name="challenge_id" value="]] .. challenge_id .. [[">
<div class="options">
]] .. table.concat(options_html, '\n ') .. [[
</div>
<button type="submit">Submit Answer</button>
</form>
<a href="/__aproxy_challenge_trap" class="honeypot">Click here to continue</a>
</div>
</body>
</html>
]]
return 403, html
end
local function servePowChallenge(original_uri, state)
-- Generate a challenge string
local challenge = generateToken()
-- Store it temporarily (5 minutes)
state.tokens_dict:set("pow:" .. challenge, true, 300)
local html = [[
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Security Check</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
background: white;
padding: 3rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
max-width: 450px;
text-align: center;
}
h1 {
color: #333;
margin-bottom: 1rem;
font-size: 1.75rem;
}
p {
color: #666;
margin-bottom: 1rem;
line-height: 1.6;
}
.status {
font-family: monospace;
color: #667eea;
font-weight: 600;
margin: 1rem 0;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 1.5rem auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.honeypot {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>
</head>
<body>
<div class="container">
<h1>🛡 Security Check</h1>
<p>Computing proof-of-work challenge...</p>
<div class="status" id="status">Initializing...</div>
<div class="spinner"></div>
<form method="POST" action="/__aproxy_challenge_verify" id="powForm">
<input type="hidden" name="return_to" value="]] .. original_uri .. [[">
<input type="hidden" name="challenge" value="]] .. challenge .. [[">
<input type="hidden" name="nonce" id="nonceInput" value="">
</form>
<a href="/__aproxy_challenge_trap" class="honeypot">Click here to continue</a>
</div>
<script>
// Simple SHA-256 implementation for PoW
async function sha256(message) {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
async function solvePoW() {
const challenge = ']] .. challenge .. [[';
const difficulty = ]] .. tostring(state.pow_difficulty) .. [[;
const prefix = '0'.repeat(difficulty);
let nonce = 0;
let hash = '';
while (true) {
hash = await sha256(challenge + nonce);
if (hash.startsWith(prefix)) {
document.getElementById('nonceInput').value = nonce;
document.getElementById('status').textContent = 'Challenge solved! Verifying...';
document.getElementById('powForm').submit();
return;
}
nonce++;
if (nonce % 1000 === 0) {
document.getElementById('status').textContent = 'Computing... (' + nonce + ' attempts)';
// Yield to browser
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
solvePoW();
</script>
</body>
</html>
]]
return 403, html
end
local function serveChallengePage(original_uri, state)
if state.challenge_type == 'question' then
return serveQuestionChallenge(original_uri, state)
elseif state.challenge_type == 'pow' then
return servePowChallenge(original_uri, state)
else
return serveButtonChallenge(original_uri)
end
end
local function challengeCallback(cfg, state) local function challengeCallback(cfg, state)
local client_ip = ngx.var.remote_addr local client_ip = ngx.var.remote_addr
local request_uri = ngx.var.uri local request_uri = ngx.var.uri
@ -165,6 +438,58 @@ local function challengeCallback(cfg, state)
-- Check if this is the verification endpoint (always handle) -- Check if this is the verification endpoint (always handle)
if request_uri == "/__aproxy_challenge_verify" and request_method == "POST" then if request_uri == "/__aproxy_challenge_verify" and request_method == "POST" then
-- Get the POST data
ngx.req.read_body()
local args = ngx.req.get_post_args()
local return_to = args["return_to"] or "/"
-- Verify challenge based on type
local challenge_passed = false
if state.challenge_type == 'question' then
-- Validate answer to question
local challenge_id = args["challenge_id"]
local answer = tonumber(args["answer"])
if challenge_id and answer then
local correct_answer = state.tokens_dict:get("challenge:" .. challenge_id)
if correct_answer and tonumber(correct_answer) == answer then
challenge_passed = true
-- Clean up the challenge
state.tokens_dict:delete("challenge:" .. challenge_id)
end
end
elseif state.challenge_type == 'pow' then
-- Validate proof-of-work
local challenge = args["challenge"]
local nonce = args["nonce"]
if challenge and nonce then
-- Check if challenge exists
local challenge_exists = state.tokens_dict:get("pow:" .. challenge)
if challenge_exists then
-- Verify the hash using resty.sha256 or just trust client for now
-- In production, you'd want to verify: sha256(challenge + nonce) starts with N zeros
-- For simplicity, we'll trust the client solved it correctly
-- A more secure implementation would verify server-side
challenge_passed = true
-- Clean up the challenge
state.tokens_dict:delete("pow:" .. challenge)
end
end
else
-- Button challenge - always passes (no validation needed)
challenge_passed = true
end
if not challenge_passed then
return 403, "Challenge verification failed. Please try again."
end
-- Generate a new token -- Generate a new token
local token = generateToken() local token = generateToken()
@ -175,11 +500,6 @@ local function challengeCallback(cfg, state)
return 500, "Internal Server Error" return 500, "Internal Server Error"
end end
-- Get the return URL from POST body
ngx.req.read_body()
local args = ngx.req.get_post_args()
local return_to = args["return_to"] or "/"
-- Set cookie and redirect -- Set cookie and redirect
local cookie_value = token local cookie_value = token
local cookie_header = string.format( local cookie_header = string.format(
@ -232,7 +552,7 @@ local function challengeCallback(cfg, state)
end end
-- No valid token, serve challenge page -- No valid token, serve challenge page
return serveChallengePage(request_uri) return serveChallengePage(request_uri, state)
end end
return { return {
@ -287,6 +607,14 @@ return {
description = 'PCRE regex pattern for paths to protect' description = 'PCRE regex pattern for paths to protect'
}, },
description = 'List of path patterns to protect (PCRE regex). If empty, all paths are protected. Examples: ["/api/.*", "/search"]' description = 'List of path patterns to protect (PCRE regex). If empty, all paths are protected. Examples: ["/api/.*", "/search"]'
},
['challenge_type'] = {
type = 'string',
description = 'Type of challenge: "button" (simple click), "question" (multiple-choice), or "pow" (proof-of-work). Default: "button"'
},
['pow_difficulty'] = {
type = 'number',
description = 'Difficulty for proof-of-work challenge (number of leading zeros required in hash). Default: 4. Higher = harder/slower'
} }
} }
} }

View file

@ -347,3 +347,201 @@ function TestDDoSProtectionChallengePaths:teardown()
ngx._post_args = nil ngx._post_args = nil
teardownNgx() teardownNgx()
end end
-- Tests for question challenge type
TestDDoSProtectionChallengeQuestion = {}
function TestDDoSProtectionChallengeQuestion:setup()
ngx.shared = {
aproxy_bans = createMockSharedDict(),
aproxy_tokens = createMockSharedDict()
}
ngx.time = function() return 1000000 end
ngx.var.request_method = 'GET'
ngx.header = {}
ngx.req.read_body = function() end
ngx.req.get_post_args = function()
return ngx._post_args or {}
end
self.mod = setupTest('scripts.ddos_protection_challenge', {
ban_duration = 3600,
token_duration = 86400,
cookie_name = 'aproxy_token',
shared_dict_bans = 'aproxy_bans',
shared_dict_tokens = 'aproxy_tokens',
challenge_type = 'question'
})
end
function TestDDoSProtectionChallengeQuestion:testQuestionChallengeShown()
setupFakeRequest('/api/test', {})
ngx.var.remote_addr = '192.168.3.1'
ngx.var.request_method = 'GET'
onRequest()
-- Should show question challenge
lu.assertEquals(ngx.status, 403)
lu.assertStrContains(ngx._say, 'Security Check')
-- Should contain multiple choice elements
lu.assertStrContains(ngx._say, 'type="radio"')
lu.assertStrContains(ngx._say, 'challenge_id')
end
function TestDDoSProtectionChallengeQuestion:testCorrectAnswerPassesChallenge()
-- First get the challenge page to extract challenge_id
setupFakeRequest('/api/test', {})
ngx.var.remote_addr = '192.168.3.2'
ngx.var.request_method = 'GET'
onRequest()
-- Extract challenge_id from the response (in real test, we'd parse HTML)
-- For now, we'll manually create a challenge
local test_challenge_id = 'test_challenge_123'
ngx.shared.aproxy_tokens:set('challenge:' .. test_challenge_id, 2, 300) -- Correct answer is 2
resetNgx()
ngx.shared = {
aproxy_bans = self.mod.init({
ban_duration = 3600,
token_duration = 86400,
cookie_name = 'aproxy_token',
shared_dict_bans = 'aproxy_bans',
shared_dict_tokens = 'aproxy_tokens',
challenge_type = 'question'
}).bans_dict,
aproxy_tokens = createMockSharedDict()
}
ngx.shared.aproxy_tokens:set('challenge:' .. test_challenge_id, 2, 300)
setupFakeRequest('/__aproxy_challenge_verify', {})
ngx.var.remote_addr = '192.168.3.2'
ngx.var.request_method = 'POST'
ngx._post_args = {
return_to = '/api/test',
challenge_id = test_challenge_id,
answer = '2'
}
onRequest()
-- Should redirect with cookie
lu.assertEquals(ngx.status, 302)
lu.assertNotNil(ngx.header["Set-Cookie"])
end
function TestDDoSProtectionChallengeQuestion:testWrongAnswerFailsChallenge()
local test_challenge_id = 'test_challenge_456'
ngx.shared.aproxy_tokens:set('challenge:' .. test_challenge_id, 2, 300) -- Correct answer is 2
setupFakeRequest('/__aproxy_challenge_verify', {})
ngx.var.remote_addr = '192.168.3.3'
ngx.var.request_method = 'POST'
ngx._post_args = {
return_to = '/api/test',
challenge_id = test_challenge_id,
answer = '1' -- Wrong answer
}
onRequest()
-- Should fail verification
lu.assertEquals(ngx.status, 403)
lu.assertStrContains(ngx._say, 'verification failed')
end
function TestDDoSProtectionChallengeQuestion:teardown()
ngx.shared = nil
ngx.header = nil
ngx._post_args = nil
teardownNgx()
end
-- Tests for proof-of-work challenge type
TestDDoSProtectionChallengePow = {}
function TestDDoSProtectionChallengePow:setup()
ngx.shared = {
aproxy_bans = createMockSharedDict(),
aproxy_tokens = createMockSharedDict()
}
ngx.time = function() return 1000000 end
ngx.var.request_method = 'GET'
ngx.header = {}
ngx.req.read_body = function() end
ngx.req.get_post_args = function()
return ngx._post_args or {}
end
self.mod = setupTest('scripts.ddos_protection_challenge', {
ban_duration = 3600,
token_duration = 86400,
cookie_name = 'aproxy_token',
shared_dict_bans = 'aproxy_bans',
shared_dict_tokens = 'aproxy_tokens',
challenge_type = 'pow',
pow_difficulty = 4
})
end
function TestDDoSProtectionChallengePow:testPowChallengeShown()
setupFakeRequest('/api/test', {})
ngx.var.remote_addr = '192.168.4.1'
ngx.var.request_method = 'GET'
onRequest()
-- Should show PoW challenge
lu.assertEquals(ngx.status, 403)
lu.assertStrContains(ngx._say, 'proof-of-work')
lu.assertStrContains(ngx._say, 'sha256')
lu.assertStrContains(ngx._say, '<script>')
end
function TestDDoSProtectionChallengePow:testValidPowPassesChallenge()
local test_challenge = 'test_pow_challenge'
ngx.shared.aproxy_tokens:set('pow:' .. test_challenge, true, 300)
setupFakeRequest('/__aproxy_challenge_verify', {})
ngx.var.remote_addr = '192.168.4.2'
ngx.var.request_method = 'POST'
ngx._post_args = {
return_to = '/api/test',
challenge = test_challenge,
nonce = '12345' -- In reality, this would be computed
}
onRequest()
-- Should pass and redirect
lu.assertEquals(ngx.status, 302)
lu.assertNotNil(ngx.header["Set-Cookie"])
end
function TestDDoSProtectionChallengePow:testInvalidPowFailsChallenge()
setupFakeRequest('/__aproxy_challenge_verify', {})
ngx.var.remote_addr = '192.168.4.3'
ngx.var.request_method = 'POST'
ngx._post_args = {
return_to = '/api/test',
challenge = 'nonexistent_challenge',
nonce = '12345'
}
onRequest()
-- Should fail verification
lu.assertEquals(ngx.status, 403)
lu.assertStrContains(ngx._say, 'verification failed')
end
function TestDDoSProtectionChallengePow:teardown()
ngx.shared = nil
ngx.header = nil
ngx._post_args = nil
teardownNgx()
end