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