diff --git a/CLAUDE.md b/CLAUDE.md index c1f73e7..66f46f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,10 +10,8 @@ aproxy is an Activity Pub Reverse Proxy Framework built on OpenResty (NGINX + Lu ### Testing ```sh -# Install test dependencies (only needed once for project setup) +# Install test dependencies (only needed once) make testdeps - -# run this to setup the PATH so that it works eval (luarocks-5.1 path --bin) # Run test suite diff --git a/conf.example.ddos_protection.lua b/conf.example.ddos_protection.lua deleted file mode 100644 index 17ba9f7..0000000 --- a/conf.example.ddos_protection.lua +++ /dev/null @@ -1,67 +0,0 @@ --- Example configuration for DDoS Protection Challenge module --- --- IMPORTANT: This module requires nginx shared dictionaries to be configured. --- Add these lines to your nginx http block (or openresty config): --- --- lua_shared_dict aproxy_bans 10m; --- lua_shared_dict aproxy_tokens 10m; --- --- The shared dictionaries store: --- - aproxy_bans: Banned IP addresses with expiry times --- - aproxy_tokens: Valid challenge tokens with expiry times --- --- You can adjust the size (10m = 10 megabytes) based on your needs. - -return { - version = 1, - wantedScripts = { - ['ddos_protection_challenge'] = { - -- How long to ban IPs that trigger the honeypot (in seconds) - -- Default: 3600 (1 hour) - ban_duration = 3600, - - -- How long tokens remain valid after passing the challenge (in seconds) - -- Default: 86400 (24 hours) - -- Users won't see the challenge again during this period - token_duration = 86400, - - -- Name of the cookie used to store the validation token - -- Default: 'aproxy_token' - cookie_name = 'aproxy_token', - - -- Name of the nginx shared dictionary for storing banned IPs - -- Must match the lua_shared_dict directive in nginx config - -- Default: 'aproxy_bans' - shared_dict_bans = 'aproxy_bans', - - -- Name of the nginx shared dictionary for storing valid tokens - -- Must match the lua_shared_dict directive in nginx config - -- Default: 'aproxy_tokens' - shared_dict_tokens = 'aproxy_tokens', - - -- List of path patterns to protect (PCRE regex) - -- If not specified or empty, ALL paths are protected - -- Examples: - -- - {'/api/.*'} - Protect all API endpoints - -- - {'/search', '/api/v2/search'} - Protect specific endpoints - -- - {'/api/.*', '/.well-known/webfinger'} - Protect multiple patterns - -- Leave empty or comment out to protect ALL paths (default behavior) - protected_paths = { - '/api/.*', -- All API endpoints - '/search', -- Search endpoint - '/.well-known/.*' -- Well-known endpoints - }, - - -- Alternative: Protect everything (same as leaving protected_paths empty) - -- 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 - } - } -} diff --git a/conf.example.ddos_protection_challenge_types.lua b/conf.example.ddos_protection_challenge_types.lua deleted file mode 100644 index 7b91f95..0000000 --- a/conf.example.ddos_protection_challenge_types.lua +++ /dev/null @@ -1,79 +0,0 @@ --- 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 - } - } -} ---]] diff --git a/scripts/ddos_protection_challenge.README.md b/scripts/ddos_protection_challenge.README.md deleted file mode 100644 index e7196e4..0000000 --- a/scripts/ddos_protection_challenge.README.md +++ /dev/null @@ -1,268 +0,0 @@ -# DDoS Protection Challenge Module - -A Cloudflare-style "Under Attack" mode for aproxy that protects your service from DDoS attacks, aggressive scraping, and automated bots. - -## How It Works - -This module implements a multi-layered defense system: - -### 1. Challenge-Response System -When an unverified visitor (without a valid token) accesses your site, they see a security challenge page instead of the actual content. The visitor must click a "Verify I'm Human" button to prove they're not a bot. - -### 2. Honeypot Detection -The challenge page includes a hidden link that's invisible to humans but may be discovered by automated scrapers and bots. If this link is accessed, the IP is immediately banned for the configured duration. - -### 3. Token-Based Validation -Upon successfully completing the challenge, users receive a cookie with a cryptographic token. This token remains valid for the configured duration (default: 24 hours), so legitimate users don't have to solve challenges repeatedly. - -### 4. IP Banning -IPs that trigger the honeypot are temporarily banned and cannot access your service. The ban duration is configurable. - -## Why This Helps With DDoS/Scraping - -- **Computational Cost**: Most DDoS attacks and scrapers make thousands of requests. Each request hitting your application has computational cost. This module intercepts requests before they reach your backend. -- **Bot Detection**: Automated tools often don't execute JavaScript or render pages properly. The challenge page requires interaction, filtering out most bots. -- **Honeypot Trap**: Scrapers that parse HTML for links will likely find and follow the honeypot link, getting themselves banned. -- **Rate Limiting Effect**: Even sophisticated bots that can solve the challenge have to do extra work, effectively rate-limiting them. - -## Configuration - -### Nginx Setup - -**REQUIRED**: Add these shared dictionaries to your nginx/OpenResty configuration: - -```nginx -http { - # Shared dictionary for banned IPs - lua_shared_dict aproxy_bans 10m; - - # Shared dictionary for valid tokens - lua_shared_dict aproxy_tokens 10m; - - # ... rest of your config -} -``` - -### aproxy Configuration - -Add to your `conf.lua`: - -```lua -return { - version = 1, - wantedScripts = { - ['ddos_protection_challenge'] = { - ban_duration = 3600, -- 1 hour ban for honeypot triggers - token_duration = 86400, -- 24 hour token validity - cookie_name = 'aproxy_token', - shared_dict_bans = 'aproxy_bans', - shared_dict_tokens = 'aproxy_tokens', - protected_paths = { -- Optional: specific paths to protect - '/api/.*', -- Protect all API endpoints - '/search', -- Protect search endpoint - }, - } - } -} -``` - -**Protect Specific Paths Only**: By default, if `protected_paths` is not configured or is empty, the challenge applies to ALL requests. You can configure specific paths to protect expensive endpoints while leaving static assets unprotected: - -```lua --- Protect only expensive API endpoints -protected_paths = {'/api/.*', '/search'} - --- This allows static assets, images, etc. to pass through freely --- 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 - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `ban_duration` | number | 3600 | How long to ban IPs (in seconds) that trigger the honeypot | -| `token_duration` | number | 86400 | How long tokens remain valid after passing challenge (in seconds) | -| `cookie_name` | string | `aproxy_token` | Name of the validation cookie | -| `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 | -| `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 - -This module uses two special endpoints: - -- `/__aproxy_challenge_verify` - Challenge form submission endpoint (POST) -- `/__aproxy_challenge_trap` - Honeypot link that bans IPs (GET) - -⚠️ **Warning**: Don't create routes with these paths in your application. - -## User Experience - -### First Visit -1. User visits your site -2. Sees a security check page with a "Verify I'm Human" button -3. Clicks the button -4. Gets redirected to their original destination -5. Cookie is set for 24 hours (configurable) - -### Subsequent Visits -- Users with valid cookies pass through immediately -- No challenge shown until cookie expires - -### Bots/Scrapers -- Simple bots see the challenge page and likely fail to proceed -- HTML parsers might find and click the honeypot link → IP banned -- 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. - -**Security**: The server verifies the proof-of-work by computing `SHA-256(challenge + nonce)` and checking that it has the required leading zeros. Bots cannot bypass this by submitting random nonces. - -## Path-Based Protection - -You can configure the module to protect only specific paths, which is useful for: - -- **Protecting expensive endpoints** while leaving static assets unrestricted -- **Selective protection** for API routes that cause high computational cost -- **Hybrid approach** where public pages are open but authenticated/search endpoints are protected - -### Example Use Cases - -**Protect only API endpoints:** -```lua -protected_paths = {'/api/.*'} --- Static assets, homepage, etc. pass through freely --- Only /api/* routes require the challenge -``` - -**Protect multiple expensive operations:** -```lua -protected_paths = { - '/api/.*', -- All API routes - '/search', -- Search endpoint - '/.well-known/webfinger', -- Webfinger (can be DB-heavy) -} -``` - -**Protect everything (default):** -```lua -protected_paths = {} --- OR simply omit the protected_paths config entirely --- All requests require challenge verification -``` - -### Important Notes on Path Protection - -1. **Special endpoints always work**: The challenge verification (`/__aproxy_challenge_verify`) and honeypot (`/__aproxy_challenge_trap`) endpoints always function regardless of `protected_paths` configuration. - -2. **IP bans are path-specific**: If an IP is banned and tries to access an unprotected path, they can still access it. Bans only apply to protected paths. This is intentional - you probably don't want to prevent banned IPs from loading CSS/images. - -3. **Token applies everywhere**: Once a user passes the challenge for a protected path, their token is valid for ALL protected paths. They don't need to solve the challenge separately for each path. - -4. **Use PCRE regex**: Patterns are PCRE regular expressions, so you can use advanced patterns like `^/api/v[0-9]+/search$` for complex matching. - -## Security Considerations - -2. **Cookie Security**: Cookies are set with `HttpOnly` and `SameSite=Lax` flags for security. Consider adding `Secure` flag if you're running HTTPS only. - -3. **Shared Dictionary Size**: Size the shared dictionaries appropriately: - - Each banned IP takes ~100 bytes - - Each token takes ~100 bytes - - 10MB can store ~100,000 entries - -4. **IP Address Source**: Uses `ngx.var.remote_addr`. If behind a proxy/load balancer, configure nginx to use the correct IP: - ```nginx - set_real_ip_from 10.0.0.0/8; # Your proxy IP range - real_ip_header X-Forwarded-For; - ``` diff --git a/scripts/ddos_protection_challenge.lua b/scripts/ddos_protection_challenge.lua deleted file mode 100644 index 3930e43..0000000 --- a/scripts/ddos_protection_challenge.lua +++ /dev/null @@ -1,539 +0,0 @@ --- 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' - } - } -} diff --git a/test.lua b/test.lua index cbb74ed..ded2270 100644 --- a/test.lua +++ b/test.lua @@ -2,56 +2,6 @@ lu = require('luaunit') local rex = require('rex_pcre2') require('util') --- Preload mock resty libraries for testing -package.preload['resty.sha256'] = function() - return require('tests.mock_resty_sha256') -end -package.preload['resty.string'] = function() - return require('tests.mock_resty_string') -end -package.preload['resty.random'] = function() - return require('tests.mock_resty_random') -end - --- Create a mock shared dictionary -local function createMockSharedDict() - local storage = {} - return { - get = function(self, key) - local item = storage[key] - if not item then return nil end - if item.expiry and item.expiry < ngx.time() then - storage[key] = nil - return nil - end - return item.value - end, - set = function(self, key, value, exptime) - storage[key] = { - value = value, - expiry = exptime and (ngx.time() + exptime) or nil - } - return true, nil - end, - delete = function(self, key) - storage[key] = nil - end - } -end - --- Create a proxy table that dynamically creates mock dictionaries on access -local function createSharedProxy() - local dicts = {} - return setmetatable({}, { - __index = function(_, key) - if not dicts[key] then - dicts[key] = createMockSharedDict() - end - return dicts[key] - end - }) -end - function createNgx() local ngx = { status = nil @@ -70,16 +20,8 @@ function createNgx() print(msg) end - -- Log level constants - ngx.DEBUG = 7 - ngx.INFO = 6 - ngx.WARN = 4 - ngx.ERR = 3 - -- only hold data here - ngx.var = { - request_method = 'GET' - } + ngx.var = {} -- request params api ngx.req = {} @@ -92,30 +34,11 @@ function createNgx() ngx._uri_args = val end - ngx.req.read_body = function() end - - ngx.req.get_post_args = function() - return ngx._post_args or {} - end - - ngx.req.get_headers = function() - return ngx._headers or {} - end - - -- response headers - ngx.header = {} - -- regex api ngx.re = {} ngx.re.match = rex.match ngx.re.search = rex.find - -- shared memory dictionaries for testing (dynamically created on access) - ngx.shared = createSharedProxy() - - -- time function for testing - ngx.time = function() return 1000000 end - return ngx end @@ -159,5 +82,4 @@ end require('tests.webfinger_allowlist') require('tests.schema_validation') -require('tests.ddos_protection_challenge') os.exit(lu.LuaUnit.run()) diff --git a/tests/ddos_protection_challenge.lua b/tests/ddos_protection_challenge.lua deleted file mode 100644 index f4c4bc5..0000000 --- a/tests/ddos_protection_challenge.lua +++ /dev/null @@ -1,521 +0,0 @@ -TestDDoSProtectionChallenge = {} - -function TestDDoSProtectionChallenge:setup() - -- Reset ngx (includes shared dicts, time, and request functions) - resetNgx() - - -- Setup module manually (can't use setupTest because it calls resetNgx) - local ctx = require('ctx') - local config = require('config') - local test_config = { - ban_duration = 3600, - token_duration = 86400, - cookie_name = 'aproxy_token', - shared_dict_bans = 'aproxy_bans', - shared_dict_tokens = 'aproxy_tokens', - protected_paths = {}, - challenge_type = 'button', - pow_difficulty = 4 - } - - self.mod = require('scripts.ddos_protection_challenge') - local schema_errors = config.validateSchema(self.mod.config, test_config) - local count = table.pprint(schema_errors) - lu.assertIs(count, 0) - - local state = self.mod.init(test_config) - ctx.compiled_chain = { - {self.mod, test_config, state} - } -end - -function TestDDoSProtectionChallenge:testNoTokenShowsChallenge() - setupFakeRequest('/api/test', {}) - ngx.var.remote_addr = '192.168.1.1' - ngx.var.request_method = 'GET' - - onRequest() - - -- Should return 403 with challenge page - lu.assertEquals(ngx.status, 403) - lu.assertNotNil(ngx._say) - lu.assertStrContains(ngx._say, 'Security Check') - lu.assertStrContains(ngx._say, 'Verify I\'m Human') -end - -function TestDDoSProtectionChallenge:testHoneypotBansIP() - setupFakeRequest('/__aproxy_challenge_trap', {}) - ngx.var.remote_addr = '192.168.1.2' - ngx.var.request_method = 'GET' - - onRequest() - - -- Should return 403 - lu.assertEquals(ngx.status, 403) - - -- IP should be banned - local is_banned = ngx.shared.aproxy_bans:get('192.168.1.2') - lu.assertTrue(is_banned) -end - -function TestDDoSProtectionChallenge:testBannedIPGets403() - -- Pre-ban an IP - ngx.shared.aproxy_bans:set('192.168.1.3', true, 3600) - - setupFakeRequest('/api/test', {}) - ngx.var.remote_addr = '192.168.1.3' - ngx.var.request_method = 'GET' - - onRequest() - - -- Should return 403 - lu.assertEquals(ngx.status, 403) - lu.assertStrContains(ngx._say, 'temporarily banned') -end - -function TestDDoSProtectionChallenge:testChallengeSubmissionIssuesToken() - setupFakeRequest('/__aproxy_challenge_verify', {}) - ngx.var.remote_addr = '192.168.1.4' - ngx.var.request_method = 'POST' - ngx._post_args = { return_to = '/api/test' } - - onRequest() - - -- Should return 302 redirect - lu.assertEquals(ngx.status, 302) - lu.assertEquals(ngx.header["Location"], '/api/test') - - -- Should set cookie - lu.assertNotNil(ngx.header["Set-Cookie"]) - lu.assertStrContains(ngx.header["Set-Cookie"], 'aproxy_token=') - lu.assertStrContains(ngx.header["Set-Cookie"], 'HttpOnly') -end - -function TestDDoSProtectionChallenge:testValidTokenAllowsAccess() - -- Create a valid token - local test_token = 'valid_test_token_123' - ngx.shared.aproxy_tokens:set(test_token, true, 86400) - - -- Set headers via ngx._headers (which our mock get_headers uses) - ngx._headers = { - Cookie = 'aproxy_token=' .. test_token - } - - setupFakeRequest('/api/test', {}) - ngx.var.remote_addr = '192.168.1.5' - ngx.var.request_method = 'GET' - - onRequest() - - -- Should pass through (status is nil) - lu.assertIsNil(ngx.status) - - -- Clean up - ngx._headers = nil -end - -function TestDDoSProtectionChallenge:testInvalidTokenShowsChallenge() - -- Set headers with invalid token - ngx._headers = { - Cookie = 'aproxy_token=invalid_token' - } - - setupFakeRequest('/api/test', {}) - ngx.var.remote_addr = '192.168.1.6' - ngx.var.request_method = 'GET' - - onRequest() - - -- Should return challenge page - lu.assertEquals(ngx.status, 403) - lu.assertStrContains(ngx._say, 'Security Check') - - -- Clean up - ngx._headers = nil -end - -function TestDDoSProtectionChallenge:testExpiredTokenShowsChallenge() - -- Create a token with very short expiry - local test_token = 'expiring_token' - ngx.shared.aproxy_tokens:set(test_token, true, -1) -- Already expired - - -- Set headers - ngx._headers = { - Cookie = 'aproxy_token=' .. test_token - } - - setupFakeRequest('/api/test', {}) - ngx.var.remote_addr = '192.168.1.7' - ngx.var.request_method = 'GET' - - onRequest() - - -- Should return challenge page - lu.assertEquals(ngx.status, 403) - lu.assertStrContains(ngx._say, 'Security Check') - - -- Clean up - ngx._headers = nil -end - -function TestDDoSProtectionChallenge:teardown() - teardownNgx() -end - --- Tests for path-based filtering -TestDDoSProtectionChallengePaths = {} - -function TestDDoSProtectionChallengePaths:setup() - -- Reset ngx (includes shared dicts, time, and request functions) - resetNgx() - - -- Setup module manually - local ctx = require('ctx') - local config = require('config') - local test_config = { - ban_duration = 3600, - token_duration = 86400, - cookie_name = 'aproxy_token', - shared_dict_bans = 'aproxy_bans', - shared_dict_tokens = 'aproxy_tokens', - protected_paths = {'/api/.*', '/search'}, - challenge_type = 'button', - pow_difficulty = 4 - } - - self.mod = require('scripts.ddos_protection_challenge') - local schema_errors = config.validateSchema(self.mod.config, test_config) - local count = table.pprint(schema_errors) - lu.assertIs(count, 0) - - local state = self.mod.init(test_config) - ctx.compiled_chain = { - {self.mod, test_config, state} - } -end - -function TestDDoSProtectionChallengePaths:testProtectedPathShowsChallenge() - setupFakeRequest('/api/users', {}) - ngx.var.remote_addr = '192.168.2.1' - ngx.var.request_method = 'GET' - - onRequest() - - -- Should return challenge page - lu.assertEquals(ngx.status, 403) - lu.assertStrContains(ngx._say, 'Security Check') -end - -function TestDDoSProtectionChallengePaths:testUnprotectedPathPassesThrough() - setupFakeRequest('/static/style.css', {}) - ngx.var.remote_addr = '192.168.2.2' - ngx.var.request_method = 'GET' - - onRequest() - - -- Should pass through (status is nil) - lu.assertIsNil(ngx.status) -end - -function TestDDoSProtectionChallengePaths:testExactMatchProtectedPath() - setupFakeRequest('/search', {}) - ngx.var.remote_addr = '192.168.2.3' - ngx.var.request_method = 'GET' - - onRequest() - - -- Should return challenge page - lu.assertEquals(ngx.status, 403) - lu.assertStrContains(ngx._say, 'Security Check') -end - -function TestDDoSProtectionChallengePaths:testHoneypotWorksRegardlessOfPaths() - -- Honeypot should work even though it's not in protected_paths - setupFakeRequest('/__aproxy_challenge_trap', {}) - ngx.var.remote_addr = '192.168.2.4' - ngx.var.request_method = 'GET' - - onRequest() - - -- Should ban the IP - lu.assertEquals(ngx.status, 403) - local is_banned = ngx.shared.aproxy_bans:get('192.168.2.4') - lu.assertTrue(is_banned) -end - -function TestDDoSProtectionChallengePaths:testVerifyEndpointWorksRegardlessOfPaths() - -- Verify endpoint should work even though it's not in protected_paths - setupFakeRequest('/__aproxy_challenge_verify', {}) - ngx.var.remote_addr = '192.168.2.5' - ngx.var.request_method = 'POST' - ngx._post_args = { return_to = '/api/test' } - - onRequest() - - -- Should return 302 redirect - lu.assertEquals(ngx.status, 302) - lu.assertNotNil(ngx.header["Set-Cookie"]) -end - -function TestDDoSProtectionChallengePaths:testValidTokenAllowsAccessToProtectedPath() - -- Create a valid token - local test_token = 'valid_test_token_paths' - ngx.shared.aproxy_tokens:set(test_token, true, 86400) - - -- Set headers via ngx._headers (which our mock get_headers uses) - ngx._headers = { - Cookie = 'aproxy_token=' .. test_token - } - - setupFakeRequest('/api/protected', {}) - ngx.var.remote_addr = '192.168.2.6' - ngx.var.request_method = 'GET' - - onRequest() - - -- Should pass through even though path is protected - lu.assertIsNil(ngx.status) - - -- Clean up - ngx._headers = nil -end - -function TestDDoSProtectionChallengePaths:testBannedIPBlockedOnUnprotectedPath() - -- Pre-ban an IP - ngx.shared.aproxy_bans:set('192.168.2.7', true, 3600) - - -- Try to access an unprotected path - setupFakeRequest('/static/image.png', {}) - ngx.var.remote_addr = '192.168.2.7' - ngx.var.request_method = 'GET' - - onRequest() - - -- Banned IPs should be blocked everywhere, not just protected paths - -- But wait, with our current implementation, unprotected paths return nil early - -- So banned IPs can still access unprotected paths... let me check the logic - - -- Actually, looking at the code, the path check happens BEFORE the ban check - -- So if the path is not protected, it returns nil before checking if IP is banned - -- This might be intentional - you only want to ban IPs from protected resources - - -- Should pass through since path is not protected - lu.assertIsNil(ngx.status) -end - -function TestDDoSProtectionChallengePaths:teardown() - teardownNgx() -end - --- Tests for question challenge type -TestDDoSProtectionChallengeQuestion = {} - -function TestDDoSProtectionChallengeQuestion:setup() - -- Reset ngx (includes shared dicts, time, and request functions) - resetNgx() - - -- Setup module manually - local ctx = require('ctx') - local config = require('config') - local test_config = { - ban_duration = 3600, - token_duration = 86400, - cookie_name = 'aproxy_token', - shared_dict_bans = 'aproxy_bans', - shared_dict_tokens = 'aproxy_tokens', - protected_paths = {}, - challenge_type = 'question', - pow_difficulty = 4 - } - - self.mod = require('scripts.ddos_protection_challenge') - local schema_errors = config.validateSchema(self.mod.config, test_config) - local count = table.pprint(schema_errors) - lu.assertIs(count, 0) - - local state = self.mod.init(test_config) - ctx.compiled_chain = { - {self.mod, test_config, state} - } -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() - -- Create a challenge with correct answer = 2 - local test_challenge_id = 'test_challenge_123' - ngx.shared.aproxy_tokens:set('challenge:' .. test_challenge_id, 2, 300) - - -- Simulate POST to verification endpoint with correct answer - 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() - teardownNgx() -end - --- Helper function to compute a valid PoW nonce for testing -local function computeValidNonce(challenge, difficulty) - local resty_sha256 = require("resty.sha256") - local str = require("resty.string") - local required_zeros = string.rep("0", difficulty) - - for nonce = 0, 150 do - local sha256 = resty_sha256:new() - sha256:update(challenge .. tostring(nonce)) - local digest = sha256:final() - local hash_hex = str.to_hex(digest) - - if hash_hex:sub(1, difficulty) == required_zeros then - return tostring(nonce) - end - end - - error("Could not find valid nonce after 150 attempts") -end - --- Tests for proof-of-work challenge type -TestDDoSProtectionChallengePow = {} - -function TestDDoSProtectionChallengePow:setup() - -- Reset ngx (includes shared dicts, time, and request functions) - resetNgx() - - -- Setup module manually - local ctx = require('ctx') - local config = require('config') - local test_config = { - ban_duration = 3600, - token_duration = 86400, - cookie_name = 'aproxy_token', - shared_dict_bans = 'aproxy_bans', - shared_dict_tokens = 'aproxy_tokens', - protected_paths = {}, - challenge_type = 'pow', - pow_difficulty = 1 - } - - self.mod = require('scripts.ddos_protection_challenge') - local schema_errors = config.validateSchema(self.mod.config, test_config) - local count = table.pprint(schema_errors) - lu.assertIs(count, 0) - - local state = self.mod.init(test_config) - ctx.compiled_chain = { - {self.mod, test_config, state} - } -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, '