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/scripts/ddos_protection_challenge.README.md b/scripts/ddos_protection_challenge.README.md
index 3907106..e7196e4 100644
--- a/scripts/ddos_protection_challenge.README.md
+++ b/scripts/ddos_protection_challenge.README.md
@@ -207,6 +207,8 @@ 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:
@@ -250,23 +252,8 @@ 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:
@@ -279,37 +266,3 @@ make test
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.
diff --git a/scripts/ddos_protection_challenge.lua b/scripts/ddos_protection_challenge.lua
index eb8b3f5..3930e43 100644
--- a/scripts/ddos_protection_challenge.lua
+++ b/scripts/ddos_protection_challenge.lua
@@ -2,18 +2,13 @@
-- 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 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)
+ -- 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)
@@ -27,6 +22,79 @@ 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},
@@ -60,7 +128,6 @@ local function challengeInit(cfg)
end
local function serveButtonChallenge(original_uri)
- -- Simple button challenge page with honeypot
local html = [[
@@ -68,78 +135,7 @@ local function serveButtonChallenge(original_uri)
Security Check
-
+
@@ -150,7 +146,6 @@ local function serveButtonChallenge(original_uri)
-
Click here to continue
@@ -161,8 +156,8 @@ end
local function serveQuestionChallenge(original_uri, state)
-- Select a random question
- math.randomseed(ngx.time() * 1000 + math.random(1, 1000))
- local q_idx = math.random(1, #QUESTIONS)
+ 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
@@ -188,77 +183,33 @@ local function serveQuestionChallenge(original_uri, state)
Security Check
@@ -296,59 +247,19 @@ local function servePowChallenge(original_uri, state)
Security Check
@@ -468,14 +379,24 @@ local function challengeCallback(cfg, state)
local challenge_exists = state.tokens_dict:get("pow:" .. challenge)
if challenge_exists 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
+ -- Verify the proof-of-work server-side
+ local resty_sha256 = require("resty.sha256")
+ local str = require("resty.string")
- -- Clean up the challenge
- state.tokens_dict:delete("pow:" .. challenge)
+ 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
+ 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
diff --git a/test.lua b/test.lua
index 7152815..cbb74ed 100644
--- a/test.lua
+++ b/test.lua
@@ -2,6 +2,56 @@ 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
@@ -27,7 +77,9 @@ function createNgx()
ngx.ERR = 3
-- only hold data here
- ngx.var = {}
+ ngx.var = {
+ request_method = 'GET'
+ }
-- request params api
ngx.req = {}
@@ -40,11 +92,30 @@ 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
diff --git a/tests/ddos_protection_challenge.lua b/tests/ddos_protection_challenge.lua
index 11bf984..f4c4bc5 100644
--- a/tests/ddos_protection_challenge.lua
+++ b/tests/ddos_protection_challenge.lua
@@ -1,55 +1,9 @@
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
+ -- Reset ngx (includes shared dicts, time, and request functions)
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')
@@ -205,9 +159,6 @@ function TestDDoSProtectionChallenge:testExpiredTokenShowsChallenge()
end
function TestDDoSProtectionChallenge:teardown()
- ngx.shared = nil
- ngx.header = nil
- ngx._post_args = nil
teardownNgx()
end
@@ -215,28 +166,9 @@ end
TestDDoSProtectionChallengePaths = {}
function TestDDoSProtectionChallengePaths:setup()
- -- Reset ngx
+ -- Reset ngx (includes shared dicts, time, and request functions)
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')
@@ -372,9 +304,6 @@ function TestDDoSProtectionChallengePaths:testBannedIPBlockedOnUnprotectedPath()
end
function TestDDoSProtectionChallengePaths:teardown()
- ngx.shared = nil
- ngx.header = nil
- ngx._post_args = nil
teardownNgx()
end
@@ -382,28 +311,9 @@ end
TestDDoSProtectionChallengeQuestion = {}
function TestDDoSProtectionChallengeQuestion:setup()
- -- Reset ngx
+ -- Reset ngx (includes shared dicts, time, and request functions)
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')
@@ -487,38 +397,36 @@ 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
+ -- Reset ngx (includes shared dicts, time, and request functions)
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')
@@ -530,7 +438,7 @@ function TestDDoSProtectionChallengePow:setup()
shared_dict_tokens = 'aproxy_tokens',
protected_paths = {},
challenge_type = 'pow',
- pow_difficulty = 4
+ pow_difficulty = 1
}
self.mod = require('scripts.ddos_protection_challenge')
@@ -558,17 +466,30 @@ function TestDDoSProtectionChallengePow:testPowChallengeShown()
lu.assertStrContains(ngx._say, '