"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

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.webfinger_allowlist')
require('tests.schema_validation') require('tests.schema_validation')
require('tests.ddos_protection_challenge')
os.exit(lu.LuaUnit.run()) 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