"ddos challenge" style script #4
5 changed files with 676 additions and 0 deletions
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
commit
40072ec6ff
43
conf.example.ddos_protection.lua
Normal file
43
conf.example.ddos_protection.lua
Normal 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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
164
scripts/ddos_protection_challenge.README.md
Normal file
164
scripts/ddos_protection_challenge.README.md
Normal 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.
|
||||||
265
scripts/ddos_protection_challenge.lua
Normal file
265
scripts/ddos_protection_challenge.lua
Normal 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)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
test.lua
1
test.lua
|
|
@ -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())
|
||||||
|
|
|
||||||
203
tests/ddos_protection_challenge.lua
Normal file
203
tests/ddos_protection_challenge.lua
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue