diff --git a/CLAUDE.md b/CLAUDE.md
index 66f46f8..c1f73e7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,8 +10,10 @@ aproxy is an Activity Pub Reverse Proxy Framework built on OpenResty (NGINX + Lu
### Testing
```sh
-# Install test dependencies (only needed once)
+# Install test dependencies (only needed once for project setup)
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
new file mode 100644
index 0000000..17ba9f7
--- /dev/null
+++ b/conf.example.ddos_protection.lua
@@ -0,0 +1,67 @@
+-- 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
new file mode 100644
index 0000000..7b91f95
--- /dev/null
+++ b/conf.example.ddos_protection_challenge_types.lua
@@ -0,0 +1,79 @@
+-- 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
new file mode 100644
index 0000000..e7196e4
--- /dev/null
+++ b/scripts/ddos_protection_challenge.README.md
@@ -0,0 +1,268 @@
+# 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
new file mode 100644
index 0000000..3930e43
--- /dev/null
+++ b/scripts/ddos_protection_challenge.lua
@@ -0,0 +1,539 @@
+-- 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.
+
+
+]]
+ 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
+
+
+
+