Compare commits

..

No commits in common. "d8aece9d7052a51b19ea23f1b1247308e26f33f7" and "ea97689ee3fa1368291c99f9cedab4cf5b488896" have entirely different histories.

8 changed files with 391 additions and 320 deletions

View file

@ -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

View file

@ -207,8 +207,6 @@ pow_difficulty = 4 -- Difficulty levels:
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:
@ -252,8 +250,23 @@ protected_paths = {}
4. **Use PCRE regex**: Patterns are PCRE regular expressions, so you can use advanced patterns like `^/api/v[0-9]+/search$` for complex matching.
## 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:
@ -266,3 +279,37 @@ protected_paths = {}
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

@ -2,13 +2,18 @@
-- 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))
-- 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)
@ -22,79 +27,6 @@ local function getCookieValue(cookie_header, cookie_name)
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},
@ -128,6 +60,7 @@ local function challengeInit(cfg)
end
local function serveButtonChallenge(original_uri)
-- Simple button challenge page with honeypot
local html = [[
<!DOCTYPE html>
<html>
@ -135,7 +68,78 @@ local function serveButtonChallenge(original_uri)
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Security Check</title>
<style>]] .. COMMON_STYLES .. [[</style>
<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">
@ -146,6 +150,7 @@ local function serveButtonChallenge(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>
@ -156,8 +161,8 @@ 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
math.randomseed(ngx.time() * 1000 + math.random(1, 1000))
local q_idx = math.random(1, #QUESTIONS)
local question = QUESTIONS[q_idx]
-- Generate a challenge ID to store the correct answer
@ -183,18 +188,39 @@ local function serveQuestionChallenge(original_uri, state)
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Security Check</title>
<style>
]] .. COMMON_STYLES .. [[
.question {
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;
}
.question {
color: #444;
font-size: 1.1rem;
margin: 2rem 0;
font-weight: 500;
}
.options {
}
.options {
text-align: left;
margin: 2rem 0;
}
.option {
}
.option {
display: block;
padding: 1rem;
margin: 0.75rem 0;
@ -202,14 +228,37 @@ local function serveQuestionChallenge(original_uri, state)
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.option:hover {
}
.option:hover {
border-color: #667eea;
background: #f8f9ff;
}
.option input[type="radio"] {
}
.option input[type="radio"] {
margin-right: 0.75rem;
}
}
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);
}
.honeypot {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>
</head>
<body>
@ -247,19 +296,59 @@ local function servePowChallenge(original_uri, state)
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Security Check</title>
<style>
]] .. COMMON_STYLES .. [[
.status {
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: 1rem;
line-height: 1.6;
}
.status {
font-family: monospace;
color: #667eea;
font-weight: 600;
margin: 1rem 0;
}
.spinner {
display: block;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 1.5rem auto;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.honeypot {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>
</head>
<body>
@ -379,24 +468,14 @@ local function challengeCallback(cfg, state)
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
-- Verify the hash using resty.sha256 or just trust client for now
-- In production, you'd want to verify: sha256(challenge + nonce) starts with N zeros
-- For simplicity, we'll trust the client solved it correctly
-- A more secure implementation would verify server-side
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

View file

@ -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
@ -77,9 +27,7 @@ function createNgx()
ngx.ERR = 3
-- only hold data here
ngx.var = {
request_method = 'GET'
}
ngx.var = {}
-- request params api
ngx.req = {}
@ -92,30 +40,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

View file

@ -1,9 +1,55 @@
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()
-- Reset ngx (includes shared dicts, time, and request functions)
-- Reset ngx
resetNgx()
-- Create mock shared dictionaries AFTER resetNgx
ngx.shared = {
aproxy_bans = createMockSharedDict(),
aproxy_tokens = createMockSharedDict()
}
-- Mock ngx.time for consistent testing
ngx.time = function() return 1000000 end
-- Add missing ngx.req functions
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
ngx.header = {}
ngx.var.request_method = 'GET'
-- Setup module manually (can't use setupTest because it calls resetNgx)
local ctx = require('ctx')
local config = require('config')
@ -159,6 +205,9 @@ function TestDDoSProtectionChallenge:testExpiredTokenShowsChallenge()
end
function TestDDoSProtectionChallenge:teardown()
ngx.shared = nil
ngx.header = nil
ngx._post_args = nil
teardownNgx()
end
@ -166,9 +215,28 @@ end
TestDDoSProtectionChallengePaths = {}
function TestDDoSProtectionChallengePaths:setup()
-- Reset ngx (includes shared dicts, time, and request functions)
-- Reset ngx
resetNgx()
-- Create mock shared dictionaries AFTER resetNgx
ngx.shared = {
aproxy_bans = createMockSharedDict(),
aproxy_tokens = createMockSharedDict()
}
ngx.time = function() return 1000000 end
-- Add missing ngx.req functions
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
ngx.header = {}
ngx.var.request_method = 'GET'
-- Setup module manually
local ctx = require('ctx')
local config = require('config')
@ -304,6 +372,9 @@ function TestDDoSProtectionChallengePaths:testBannedIPBlockedOnUnprotectedPath()
end
function TestDDoSProtectionChallengePaths:teardown()
ngx.shared = nil
ngx.header = nil
ngx._post_args = nil
teardownNgx()
end
@ -311,9 +382,28 @@ end
TestDDoSProtectionChallengeQuestion = {}
function TestDDoSProtectionChallengeQuestion:setup()
-- Reset ngx (includes shared dicts, time, and request functions)
-- Reset ngx
resetNgx()
-- Create mock shared dictionaries AFTER resetNgx
ngx.shared = {
aproxy_bans = createMockSharedDict(),
aproxy_tokens = createMockSharedDict()
}
ngx.time = function() return 1000000 end
-- Add missing ngx.req functions
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
ngx.header = {}
ngx.var.request_method = 'GET'
-- Setup module manually
local ctx = require('ctx')
local config = require('config')
@ -397,36 +487,38 @@ function TestDDoSProtectionChallengeQuestion:testWrongAnswerFailsChallenge()
end
function TestDDoSProtectionChallengeQuestion:teardown()
ngx.shared = nil
ngx.header = nil
ngx._post_args = nil
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)
-- Reset ngx
resetNgx()
-- Create mock shared dictionaries AFTER resetNgx
ngx.shared = {
aproxy_bans = createMockSharedDict(),
aproxy_tokens = createMockSharedDict()
}
ngx.time = function() return 1000000 end
-- Add missing ngx.req functions
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
ngx.header = {}
ngx.var.request_method = 'GET'
-- Setup module manually
local ctx = require('ctx')
local config = require('config')
@ -438,7 +530,7 @@ function TestDDoSProtectionChallengePow:setup()
shared_dict_tokens = 'aproxy_tokens',
protected_paths = {},
challenge_type = 'pow',
pow_difficulty = 1
pow_difficulty = 4
}
self.mod = require('scripts.ddos_protection_challenge')
@ -466,30 +558,17 @@ function TestDDoSProtectionChallengePow:testPowChallengeShown()
lu.assertStrContains(ngx._say, '<script>')
end
local function generateToken()
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
function TestDDoSProtectionChallengePow:testValidPowPassesChallenge()
local test_challenge = generateToken()
local test_challenge = 'test_pow_challenge'
ngx.shared.aproxy_tokens:set('pow:' .. test_challenge, true, 300)
-- Compute a valid nonce that satisfies the PoW requirement
local valid_nonce = computeValidNonce(test_challenge, 1)
setupFakeRequest('/__aproxy_challenge_verify', {})
ngx.var.remote_addr = '192.168.4.2'
ngx.var.request_method = 'POST'
ngx._post_args = {
return_to = '/api/test',
challenge = test_challenge,
nonce = valid_nonce
nonce = '12345' -- In reality, this would be computed
}
onRequest()
@ -517,5 +596,8 @@ function TestDDoSProtectionChallengePow:testInvalidPowFailsChallenge()
end
function TestDDoSProtectionChallengePow:teardown()
ngx.shared = nil
ngx.header = nil
ngx._post_args = nil
teardownNgx()
end

View file

@ -1,16 +0,0 @@
-- Mock implementation of resty.random for testing
local random = {}
function random.bytes(len, strong)
-- For testing, generate predictable random bytes using math.random
-- In real OpenResty, this would use cryptographic random sources
local bytes = {}
for i = 1, len do
-- Use math.random to generate bytes (0-255)
table.insert(bytes, string.char(math.random(0, 255)))
end
return table.concat(bytes)
end
return random

View file

@ -1,35 +0,0 @@
-- Mock implementation of resty.sha256 for testing
-- Uses system sha256sum command since we don't have OpenResty libraries
local sha256 = {}
sha256.__index = sha256
function sha256:new()
local obj = {
buffer = ""
}
setmetatable(obj, sha256)
return obj
end
function sha256:update(data)
self.buffer = self.buffer .. data
end
function sha256:final()
-- Use sha256sum command to compute hash
local handle = io.popen("echo -n '" .. self.buffer:gsub("'", "'\\''") .. "' | sha256sum")
local result = handle:read("*a")
handle:close()
-- Parse hex string into binary
local hex = result:match("^(%x+)")
local binary = {}
for i = 1, #hex, 2 do
table.insert(binary, string.char(tonumber(hex:sub(i, i+1), 16)))
end
return table.concat(binary)
end
return sha256

View file

@ -1,13 +0,0 @@
-- Mock implementation of resty.string for testing
local str = {}
function str.to_hex(binary)
local hex = {}
for i = 1, #binary do
table.insert(hex, string.format("%02x", string.byte(binary, i)))
end
return table.concat(hex)
end
return str