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, '