539 lines
18 KiB
Lua
539 lines
18 KiB
Lua
-- DDoS Protection Challenge System
|
||
-- Similar to Cloudflare's "Under Attack" mode
|
||
-- Presents a challenge page with a honeypot link
|
||
|
||
local resty_random = require "resty.random"
|
||
local resty_string = require "resty.string"
|
||
|
||
local function generateToken()
|
||
-- Generate a cryptographically strong random token
|
||
-- Uses RAND_pseudo_bytes which is secure and won't fail
|
||
return resty_string.to_hex(resty_random.bytes(16))
|
||
end
|
||
|
||
local function getCookieValue(cookie_header, cookie_name)
|
||
if not cookie_header then
|
||
return nil
|
||
end
|
||
-- Parse cookie header to find our cookie
|
||
-- Use Lua patterns instead of PCRE for better test compatibility
|
||
local pattern = cookie_name .. "=([^;]+)"
|
||
local value = string.match(cookie_header, pattern)
|
||
return value
|
||
end
|
||
|
||
-- Common CSS styles for all challenge pages
|
||
local COMMON_STYLES = [[
|
||
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: 2rem;
|
||
line-height: 1.6;
|
||
}
|
||
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);
|
||
}
|
||
button:active {
|
||
transform: translateY(0);
|
||
}
|
||
.honeypot {
|
||
position: absolute;
|
||
left: -9999px;
|
||
width: 1px;
|
||
height: 1px;
|
||
overflow: hidden;
|
||
}
|
||
.spinner {
|
||
display: none;
|
||
border: 3px solid #f3f3f3;
|
||
border-top: 3px solid #667eea;
|
||
border-radius: 50%;
|
||
width: 30px;
|
||
height: 30px;
|
||
animation: spin 1s linear infinite;
|
||
margin: 1rem auto;
|
||
}
|
||
@keyframes spin {
|
||
0% { transform: rotate(0deg); }
|
||
100% { transform: rotate(360deg); }
|
||
}
|
||
.loading button { display: none; }
|
||
.loading .spinner { display: block; }
|
||
]]
|
||
|
||
-- 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)
|
||
-- Get references to shared dictionaries
|
||
local state = {
|
||
bans_dict = ngx.shared[cfg.shared_dict_bans],
|
||
tokens_dict = ngx.shared[cfg.shared_dict_tokens],
|
||
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
|
||
error("Shared dictionary '" .. cfg.shared_dict_bans .. "' not found. Add it to nginx config with: lua_shared_dict " .. cfg.shared_dict_bans .. " 10m;")
|
||
end
|
||
|
||
if not state.tokens_dict then
|
||
error("Shared dictionary '" .. cfg.shared_dict_tokens .. "' not found. Add it to nginx config with: lua_shared_dict " .. cfg.shared_dict_tokens .. " 10m;")
|
||
end
|
||
|
||
return state
|
||
end
|
||
|
||
local function serveButtonChallenge(original_uri)
|
||
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>]] .. COMMON_STYLES .. [[</style>
|
||
</head>
|
||
<body>
|
||
<div class="container" id="container">
|
||
<h1>🛡️ Security Check</h1>
|
||
<p>This site is protected against DDoS attacks. Please verify you're human to continue.</p>
|
||
<form method="POST" action="/__aproxy_challenge_verify" onsubmit="document.getElementById('container').classList.add('loading')">
|
||
<input type="hidden" name="return_to" value="]] .. original_uri .. [[">
|
||
<button type="submit">Verify I'm Human</button>
|
||
</form>
|
||
<div class="spinner"></div>
|
||
<a href="/__aproxy_challenge_trap" class="honeypot">Click here to continue</a>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
]]
|
||
return 403, html
|
||
end
|
||
|
||
local function serveQuestionChallenge(original_uri, state)
|
||
-- Select a random question
|
||
local random_byte = resty_random.bytes(1)
|
||
local q_idx = (string.byte(random_byte) % #QUESTIONS) + 1
|
||
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>
|
||
]] .. COMMON_STYLES .. [[
|
||
.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;
|
||
}
|
||
</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>
|
||
]] .. COMMON_STYLES .. [[
|
||
.status {
|
||
font-family: monospace;
|
||
color: #667eea;
|
||
font-weight: 600;
|
||
margin: 1rem 0;
|
||
}
|
||
.spinner {
|
||
display: block;
|
||
width: 40px;
|
||
height: 40px;
|
||
margin: 1.5rem auto;
|
||
}
|
||
</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 client_ip = ngx.var.remote_addr
|
||
local request_uri = ngx.var.uri
|
||
local request_method = ngx.var.request_method
|
||
|
||
-- Check if this is the honeypot endpoint (always handle)
|
||
if request_uri == "/__aproxy_challenge_trap" then
|
||
-- Bot fell for the trap! Ban this IP
|
||
local success, err = state.bans_dict:set(client_ip, true, cfg.ban_duration)
|
||
if not success then
|
||
ngx.log(ngx.ERR, "Failed to ban IP: " .. (err or "unknown error"))
|
||
end
|
||
return 403, "Access Denied"
|
||
end
|
||
|
||
-- Check if this is the verification endpoint (always handle)
|
||
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 proof-of-work server-side
|
||
local resty_sha256 = require("resty.sha256")
|
||
local str = require("resty.string")
|
||
|
||
local sha256 = resty_sha256:new()
|
||
sha256:update(challenge .. nonce)
|
||
local digest = sha256:final()
|
||
local hash_hex = str.to_hex(digest)
|
||
|
||
-- Check if hash starts with required number of zeros
|
||
local required_zeros = string.rep("0", state.pow_difficulty)
|
||
if hash_hex:sub(1, state.pow_difficulty) == required_zeros then
|
||
challenge_passed = true
|
||
-- Clean up the challenge
|
||
state.tokens_dict:delete("pow:" .. challenge)
|
||
else
|
||
ngx.log(ngx.WARN, "PoW verification failed: hash doesn't have enough leading zeros")
|
||
end
|
||
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
|
||
local token = generateToken()
|
||
|
||
-- Store the token in shared dict
|
||
local success, err = state.tokens_dict:set(token, true, cfg.token_duration)
|
||
if not success then
|
||
ngx.log(ngx.ERR, "Failed to store token: " .. (err or "unknown error"))
|
||
return 500, "Internal Server Error"
|
||
end
|
||
|
||
-- Set cookie and redirect
|
||
local cookie_value = token
|
||
local cookie_header = string.format(
|
||
"%s=%s; Path=/; Max-Age=%d; HttpOnly; SameSite=Lax",
|
||
cfg.cookie_name,
|
||
cookie_value,
|
||
cfg.token_duration
|
||
)
|
||
ngx.header["Set-Cookie"] = cookie_header
|
||
ngx.header["Location"] = return_to
|
||
return 302, ""
|
||
end
|
||
|
||
-- Check if this path should be protected
|
||
-- If protected_paths is configured, only apply challenge to matching paths
|
||
if state.protected_paths and #state.protected_paths > 0 then
|
||
local path_matches = false
|
||
for _, pattern in ipairs(state.protected_paths) do
|
||
local match = ngx.re.match(request_uri, pattern)
|
||
if match then
|
||
path_matches = true
|
||
break
|
||
end
|
||
end
|
||
-- If path doesn't match any protected pattern, pass through
|
||
if not path_matches then
|
||
return nil
|
||
end
|
||
end
|
||
-- If protected_paths is empty/nil, protect all paths (default behavior)
|
||
|
||
-- Check if IP is banned
|
||
local is_banned = state.bans_dict:get(client_ip)
|
||
if is_banned then
|
||
return 403, "Your IP has been temporarily banned due to suspicious activity"
|
||
end
|
||
|
||
-- Check for valid token cookie
|
||
local headers = ngx.req.get_headers()
|
||
local cookie_header = headers["Cookie"]
|
||
local token = getCookieValue(cookie_header, cfg.cookie_name)
|
||
|
||
if token then
|
||
-- Verify token is still valid in shared dict
|
||
local is_valid = state.tokens_dict:get(token)
|
||
if is_valid then
|
||
-- Token is valid, allow request through
|
||
return nil
|
||
end
|
||
end
|
||
|
||
-- No valid token, serve challenge page
|
||
return serveChallengePage(request_uri, state)
|
||
end
|
||
|
||
return {
|
||
name = 'DDoSProtectionChallenge',
|
||
author = 'luna@l4.pm',
|
||
title = 'DDoS Protection Challenge',
|
||
description = [[
|
||
DDoS protection system with challenge-response mechanism.
|
||
Similar to Cloudflare's "Under Attack" mode.
|
||
|
||
Features:
|
||
- Challenge page for unverified visitors
|
||
- Honeypot link to catch and ban bots
|
||
- Cookie-based token system for validated users
|
||
- Temporary IP banning for suspicious activity
|
||
|
||
Requires nginx shared dictionaries to be configured:
|
||
lua_shared_dict aproxy_bans 10m;
|
||
lua_shared_dict aproxy_tokens 10m;
|
||
]],
|
||
version = 1,
|
||
init = challengeInit,
|
||
callbacks = {
|
||
-- Match all requests
|
||
['.*'] = challengeCallback
|
||
},
|
||
config = {
|
||
['ban_duration'] = {
|
||
type = 'number',
|
||
description = 'How long to ban IPs in seconds (default: 3600 = 1 hour)'
|
||
},
|
||
['token_duration'] = {
|
||
type = 'number',
|
||
description = 'How long tokens are valid in seconds (default: 86400 = 24 hours)'
|
||
},
|
||
['cookie_name'] = {
|
||
type = 'string',
|
||
description = 'Name of the validation cookie (default: aproxy_token)'
|
||
},
|
||
['shared_dict_bans'] = {
|
||
type = 'string',
|
||
description = 'Name of nginx shared dict for banned IPs (default: aproxy_bans)'
|
||
},
|
||
['shared_dict_tokens'] = {
|
||
type = 'string',
|
||
description = 'Name of nginx shared dict for valid tokens (default: aproxy_tokens)'
|
||
},
|
||
['protected_paths'] = {
|
||
type = 'list',
|
||
schema = {
|
||
type = 'string',
|
||
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"]'
|
||
},
|
||
['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'
|
||
}
|
||
}
|
||
}
|