-- 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 = [[ Security Check

🛡️ Security Check

This site is protected against DDoS attacks. Please verify you're human to continue.

Click here to continue
]] 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( '', i, answer )) end local html = [[ Security Check

🛡️ Security Check

]] .. question.q .. [[

]] .. table.concat(options_html, '\n ') .. [[
Click here to continue
]] 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 = [[ Security Check

🛡️ Security Check

Computing proof-of-work challenge...

Initializing...
Click here to continue
]] 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' } } }