diff --git a/CLAUDE.md b/CLAUDE.md index c1f73e7..66f46f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/scripts/ddos_protection_challenge.README.md b/scripts/ddos_protection_challenge.README.md index e7196e4..3907106 100644 --- a/scripts/ddos_protection_challenge.README.md +++ b/scripts/ddos_protection_challenge.README.md @@ -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. diff --git a/scripts/ddos_protection_challenge.lua b/scripts/ddos_protection_challenge.lua index 3930e43..eb8b3f5 100644 --- a/scripts/ddos_protection_challenge.lua +++ b/scripts/ddos_protection_challenge.lua @@ -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 = [[ @@ -135,7 +68,78 @@ local function serveButtonChallenge(original_uri) Security Check - +
@@ -146,6 +150,7 @@ local function serveButtonChallenge(original_uri)
+ Click here to continue
@@ -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,33 +188,77 @@ local function serveQuestionChallenge(original_uri, state) Security Check @@ -247,19 +296,59 @@ local function servePowChallenge(original_uri, state) Security Check @@ -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") + -- 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 - 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 + -- Clean up the challenge + state.tokens_dict:delete("pow:" .. challenge) end end diff --git a/test.lua b/test.lua index cbb74ed..7152815 100644 --- a/test.lua +++ b/test.lua @@ -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 diff --git a/tests/ddos_protection_challenge.lua b/tests/ddos_protection_challenge.lua index f4c4bc5..11bf984 100644 --- a/tests/ddos_protection_challenge.lua +++ b/tests/ddos_protection_challenge.lua @@ -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, '