"ddos challenge" style script #4

Open
luna wants to merge 20 commits from claude/ddos-protection-challenge-01CMAtrK6Dt24x9Q3v6Gz9fS into mistress
5 changed files with 676 additions and 0 deletions
Showing only changes of commit 40072ec6ff - Show all commits

Add DDoS protection challenge module with honeypot

Implements a Cloudflare-style "Under Attack" mode that protects against
DDoS attacks, scraping, and automated bots.

Features:
- Challenge-response system requiring human interaction
- Honeypot link that automatically bans IPs of bots that click it
- Cookie-based token system for validated users (24h default)
- Temporary IP banning (1h default)
- Comprehensive test suite

The module intercepts requests before they hit the backend, reducing
computational cost from scraping and DDoS attempts. It's particularly
effective against simple scrapers and volumetric attacks.

Files added:
- scripts/ddos_protection_challenge.lua - Main module implementation
- tests/ddos_protection_challenge.lua - Comprehensive test suite
- scripts/ddos_protection_challenge.README.md - Full documentation
- conf.example.ddos_protection.lua - Example configuration
- test.lua - Added test import
Claude 2025-11-22 04:28:34 +00:00 committed by Luna

View file

@ -0,0 +1,43 @@
-- 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',
}
}
}

View file

@ -0,0 +1,164 @@
# 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',
}
}
}
```
### 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 |
## 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
## Testing
The module includes comprehensive tests. To run them:
```bash
# Install test dependencies (once)
make testdeps
eval $(luarocks-5.1 path --bin)
# Run tests
make test
```
## Security Considerations
1. **Token Generation**: The module uses Lua's `math.random` for token generation. For production use with high security requirements, consider integrating a cryptographically secure random source.
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;
```
## Limitations
- **Shared State**: Uses nginx shared memory, so it's per-server. In a multi-server setup, bans and tokens aren't shared across servers.
- **Memory Limits**: Shared dictionaries have fixed sizes. Old entries are evicted when full (LRU).
- **Not a Complete Solution**: This helps with volumetric attacks and simple bots, but sophisticated attackers can bypass it. Use in combination with other security measures.
## Combining With Other Scripts
This module can be combined with other aproxy scripts for defense in depth:
```lua
return {
version = 1,
wantedScripts = {
-- First layer: Challenge-response for DDoS/bot protection
['ddos_protection_challenge'] = {
ban_duration = 3600,
token_duration = 86400,
-- ... config
},
-- Second layer: Restrict specific expensive endpoints
['pleroma_restrict_unauthenticated_search'] = {},
-- Third layer: Allowlist for webfinger
['webfinger_allowlist'] = {
accounts = {'user@domain.com'}
}
}
}
```
Scripts execute in order, so the challenge runs first, filtering out bots before they hit your more specific rules.

View file

@ -0,0 +1,265 @@
-- DDoS Protection Challenge System
-- Similar to Cloudflare's "Under Attack" mode
-- Presents a challenge page with a honeypot link
local function generateToken()
-- Generate a random token for validation
-- Using ngx.time() and math.random for simplicity
-- In production, consider using a more secure method
math.randomseed(ngx.time() * 1000 + math.random(1, 1000))
local chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
local token = {}
for i = 1, 32 do
local idx = math.random(1, #chars)
token[i] = chars:sub(idx, idx)
end
return table.concat(token)
end
local function getCookieValue(cookie_header, cookie_name)
if not cookie_header then
return nil
end
-- Parse cookie header to find our cookie
local pattern = cookie_name .. "=([^;]+)"
local match = ngx.re.match(cookie_header, pattern)
if match then
return match[1]
end
return nil
end
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]
}
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 serveChallengePage(original_uri)
-- HTML challenge page with honeypot
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>
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: 400px;
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 link - invisible to humans but crawlable by bots */
.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; }
</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>
<!-- Honeypot link for bots -->
<a href="/__aproxy_challenge_trap" class="honeypot">Click here to continue</a>
</div>
</body>
</html>
]]
return 403, html
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
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
if request_uri == "/__aproxy_challenge_verify" and request_method == "POST" then
-- 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
-- Get the return URL from POST body
ngx.req.read_body()
local args = ngx.req.get_post_args()
local return_to = args["return_to"] or "/"
-- 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 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)
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)'
}
}
}

View file

@ -82,4 +82,5 @@ end
require('tests.webfinger_allowlist')
require('tests.schema_validation')
require('tests.ddos_protection_challenge')
os.exit(lu.LuaUnit.run())

View file

@ -0,0 +1,203 @@
TestDDoSProtectionChallenge = {}
-- 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
function TestDDoSProtectionChallenge:setup()
-- Create mock shared dictionaries
ngx.shared = {
aproxy_bans = createMockSharedDict(),
aproxy_tokens = createMockSharedDict()
}
-- Mock ngx.time for consistent testing
ngx.time = function() return 1000000 end
-- Add request method support
ngx.var.request_method = 'GET'
-- Add header setting support
ngx.header = {}
-- Add POST body reading support
ngx.req.read_body = function() end
ngx.req.get_post_args = function()
return ngx._post_args or {}
end
self.mod = setupTest('scripts.ddos_protection_challenge', {
ban_duration = 3600,
token_duration = 86400,
cookie_name = 'aproxy_token',
shared_dict_bans = 'aproxy_bans',
shared_dict_tokens = 'aproxy_tokens'
})
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)
-- Mock get_headers to return cookie
local original_get_headers = ngx.req.get_headers
ngx.req.get_headers = function()
return {
Cookie = 'aproxy_token=' .. test_token
}
end
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)
-- Restore original function
ngx.req.get_headers = original_get_headers
end
function TestDDoSProtectionChallenge:testInvalidTokenShowsChallenge()
-- Mock get_headers with invalid token
local original_get_headers = ngx.req.get_headers
ngx.req.get_headers = function()
return {
Cookie = 'aproxy_token=invalid_token'
}
end
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')
-- Restore original function
ngx.req.get_headers = original_get_headers
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
-- Mock get_headers
local original_get_headers = ngx.req.get_headers
ngx.req.get_headers = function()
return {
Cookie = 'aproxy_token=' .. test_token
}
end
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')
-- Restore original function
ngx.req.get_headers = original_get_headers
end
function TestDDoSProtectionChallenge:teardown()
ngx.shared = nil
ngx.header = nil
ngx._post_args = nil
teardownNgx()
end