aproxy/scripts/ddos_protection_challenge.lua

539 lines
18 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 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'
}
}
}