Compare commits
10 commits
ea97689ee3
...
d8aece9d70
| Author | SHA1 | Date | |
|---|---|---|---|
| d8aece9d70 | |||
| 89dc5da94e | |||
| 0b0a9c7aaa | |||
| 2cbf796aef | |||
| 9dd95c82a7 | |||
| 37e9af217f | |||
| 4795879278 | |||
| be059b26c1 | |||
|
|
fbe238d3c1 | ||
|
|
60c6c10b0f |
8 changed files with 320 additions and 391 deletions
|
|
@ -10,8 +10,10 @@ aproxy is an Activity Pub Reverse Proxy Framework built on OpenResty (NGINX + Lu
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
```sh
|
```sh
|
||||||
# Install test dependencies (only needed once)
|
# Install test dependencies (only needed once for project setup)
|
||||||
make testdeps
|
make testdeps
|
||||||
|
|
||||||
|
# run this to setup the PATH so that it works
|
||||||
eval (luarocks-5.1 path --bin)
|
eval (luarocks-5.1 path --bin)
|
||||||
|
|
||||||
# Run test suite
|
# Run test suite
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## Path-Based Protection
|
||||||
|
|
||||||
You can configure the module to protect only specific paths, which is useful for:
|
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.
|
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
|
## 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.
|
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:
|
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
|
set_real_ip_from 10.0.0.0/8; # Your proxy IP range
|
||||||
real_ip_header X-Forwarded-For;
|
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.
|
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,13 @@
|
||||||
-- Similar to Cloudflare's "Under Attack" mode
|
-- Similar to Cloudflare's "Under Attack" mode
|
||||||
-- Presents a challenge page with a honeypot link
|
-- Presents a challenge page with a honeypot link
|
||||||
|
|
||||||
|
local resty_random = require "resty.random"
|
||||||
|
local resty_string = require "resty.string"
|
||||||
|
|
||||||
local function generateToken()
|
local function generateToken()
|
||||||
-- Generate a random token for validation
|
-- Generate a cryptographically strong random token
|
||||||
-- Using ngx.time() and math.random for simplicity
|
-- Uses RAND_pseudo_bytes which is secure and won't fail
|
||||||
-- In production, consider using a more secure method
|
return resty_string.to_hex(resty_random.bytes(16))
|
||||||
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
|
end
|
||||||
|
|
||||||
local function getCookieValue(cookie_header, cookie_name)
|
local function getCookieValue(cookie_header, cookie_name)
|
||||||
|
|
@ -27,6 +22,79 @@ local function getCookieValue(cookie_header, cookie_name)
|
||||||
return value
|
return value
|
||||||
end
|
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
|
-- Question pool for multiple-choice challenges
|
||||||
local QUESTIONS = {
|
local QUESTIONS = {
|
||||||
{q = "What is 7 + 5?", answers = {"10", "12", "14", "15"}, correct = 2},
|
{q = "What is 7 + 5?", answers = {"10", "12", "14", "15"}, correct = 2},
|
||||||
|
|
@ -60,7 +128,6 @@ local function challengeInit(cfg)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function serveButtonChallenge(original_uri)
|
local function serveButtonChallenge(original_uri)
|
||||||
-- Simple button challenge page with honeypot
|
|
||||||
local html = [[
|
local html = [[
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -68,78 +135,7 @@ local function serveButtonChallenge(original_uri)
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Security Check</title>
|
<title>Security Check</title>
|
||||||
<style>
|
<style>]] .. COMMON_STYLES .. [[</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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container" id="container">
|
<div class="container" id="container">
|
||||||
|
|
@ -150,7 +146,6 @@ local function serveButtonChallenge(original_uri)
|
||||||
<button type="submit">Verify I'm Human</button>
|
<button type="submit">Verify I'm Human</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<!-- Honeypot link for bots -->
|
|
||||||
<a href="/__aproxy_challenge_trap" class="honeypot">Click here to continue</a>
|
<a href="/__aproxy_challenge_trap" class="honeypot">Click here to continue</a>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
@ -161,8 +156,8 @@ end
|
||||||
|
|
||||||
local function serveQuestionChallenge(original_uri, state)
|
local function serveQuestionChallenge(original_uri, state)
|
||||||
-- Select a random question
|
-- Select a random question
|
||||||
math.randomseed(ngx.time() * 1000 + math.random(1, 1000))
|
local random_byte = resty_random.bytes(1)
|
||||||
local q_idx = math.random(1, #QUESTIONS)
|
local q_idx = (string.byte(random_byte) % #QUESTIONS) + 1
|
||||||
local question = QUESTIONS[q_idx]
|
local question = QUESTIONS[q_idx]
|
||||||
|
|
||||||
-- Generate a challenge ID to store the correct answer
|
-- Generate a challenge ID to store the correct answer
|
||||||
|
|
@ -188,28 +183,7 @@ local function serveQuestionChallenge(original_uri, state)
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Security Check</title>
|
<title>Security Check</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
]] .. COMMON_STYLES .. [[
|
||||||
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 {
|
.question {
|
||||||
color: #444;
|
color: #444;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
|
@ -236,29 +210,6 @@ local function serveQuestionChallenge(original_uri, state)
|
||||||
.option input[type="radio"] {
|
.option input[type="radio"] {
|
||||||
margin-right: 0.75rem;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -296,33 +247,7 @@ local function servePowChallenge(original_uri, state)
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Security Check</title>
|
<title>Security Check</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
]] .. COMMON_STYLES .. [[
|
||||||
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 {
|
.status {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
color: #667eea;
|
color: #667eea;
|
||||||
|
|
@ -330,25 +255,11 @@ local function servePowChallenge(original_uri, state)
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
.spinner {
|
.spinner {
|
||||||
border: 3px solid #f3f3f3;
|
display: block;
|
||||||
border-top: 3px solid #667eea;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin: 1.5rem auto;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -468,14 +379,24 @@ local function challengeCallback(cfg, state)
|
||||||
local challenge_exists = state.tokens_dict:get("pow:" .. challenge)
|
local challenge_exists = state.tokens_dict:get("pow:" .. challenge)
|
||||||
|
|
||||||
if challenge_exists then
|
if challenge_exists then
|
||||||
-- Verify the hash using resty.sha256 or just trust client for now
|
-- Verify the proof-of-work server-side
|
||||||
-- In production, you'd want to verify: sha256(challenge + nonce) starts with N zeros
|
local resty_sha256 = require("resty.sha256")
|
||||||
-- For simplicity, we'll trust the client solved it correctly
|
local str = require("resty.string")
|
||||||
-- 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
|
-- Clean up the challenge
|
||||||
state.tokens_dict:delete("pow:" .. 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
73
test.lua
73
test.lua
|
|
@ -2,6 +2,56 @@ lu = require('luaunit')
|
||||||
local rex = require('rex_pcre2')
|
local rex = require('rex_pcre2')
|
||||||
require('util')
|
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()
|
function createNgx()
|
||||||
local ngx = {
|
local ngx = {
|
||||||
status = nil
|
status = nil
|
||||||
|
|
@ -27,7 +77,9 @@ function createNgx()
|
||||||
ngx.ERR = 3
|
ngx.ERR = 3
|
||||||
|
|
||||||
-- only hold data here
|
-- only hold data here
|
||||||
ngx.var = {}
|
ngx.var = {
|
||||||
|
request_method = 'GET'
|
||||||
|
}
|
||||||
|
|
||||||
-- request params api
|
-- request params api
|
||||||
ngx.req = {}
|
ngx.req = {}
|
||||||
|
|
@ -40,11 +92,30 @@ function createNgx()
|
||||||
ngx._uri_args = val
|
ngx._uri_args = val
|
||||||
end
|
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
|
-- regex api
|
||||||
ngx.re = {}
|
ngx.re = {}
|
||||||
ngx.re.match = rex.match
|
ngx.re.match = rex.match
|
||||||
ngx.re.search = rex.find
|
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
|
return ngx
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,9 @@
|
||||||
TestDDoSProtectionChallenge = {}
|
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()
|
function TestDDoSProtectionChallenge:setup()
|
||||||
-- Reset ngx
|
-- Reset ngx (includes shared dicts, time, and request functions)
|
||||||
resetNgx()
|
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)
|
-- Setup module manually (can't use setupTest because it calls resetNgx)
|
||||||
local ctx = require('ctx')
|
local ctx = require('ctx')
|
||||||
local config = require('config')
|
local config = require('config')
|
||||||
|
|
@ -205,9 +159,6 @@ function TestDDoSProtectionChallenge:testExpiredTokenShowsChallenge()
|
||||||
end
|
end
|
||||||
|
|
||||||
function TestDDoSProtectionChallenge:teardown()
|
function TestDDoSProtectionChallenge:teardown()
|
||||||
ngx.shared = nil
|
|
||||||
ngx.header = nil
|
|
||||||
ngx._post_args = nil
|
|
||||||
teardownNgx()
|
teardownNgx()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -215,28 +166,9 @@ end
|
||||||
TestDDoSProtectionChallengePaths = {}
|
TestDDoSProtectionChallengePaths = {}
|
||||||
|
|
||||||
function TestDDoSProtectionChallengePaths:setup()
|
function TestDDoSProtectionChallengePaths:setup()
|
||||||
-- Reset ngx
|
-- Reset ngx (includes shared dicts, time, and request functions)
|
||||||
resetNgx()
|
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
|
-- Setup module manually
|
||||||
local ctx = require('ctx')
|
local ctx = require('ctx')
|
||||||
local config = require('config')
|
local config = require('config')
|
||||||
|
|
@ -372,9 +304,6 @@ function TestDDoSProtectionChallengePaths:testBannedIPBlockedOnUnprotectedPath()
|
||||||
end
|
end
|
||||||
|
|
||||||
function TestDDoSProtectionChallengePaths:teardown()
|
function TestDDoSProtectionChallengePaths:teardown()
|
||||||
ngx.shared = nil
|
|
||||||
ngx.header = nil
|
|
||||||
ngx._post_args = nil
|
|
||||||
teardownNgx()
|
teardownNgx()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -382,28 +311,9 @@ end
|
||||||
TestDDoSProtectionChallengeQuestion = {}
|
TestDDoSProtectionChallengeQuestion = {}
|
||||||
|
|
||||||
function TestDDoSProtectionChallengeQuestion:setup()
|
function TestDDoSProtectionChallengeQuestion:setup()
|
||||||
-- Reset ngx
|
-- Reset ngx (includes shared dicts, time, and request functions)
|
||||||
resetNgx()
|
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
|
-- Setup module manually
|
||||||
local ctx = require('ctx')
|
local ctx = require('ctx')
|
||||||
local config = require('config')
|
local config = require('config')
|
||||||
|
|
@ -487,38 +397,36 @@ function TestDDoSProtectionChallengeQuestion:testWrongAnswerFailsChallenge()
|
||||||
end
|
end
|
||||||
|
|
||||||
function TestDDoSProtectionChallengeQuestion:teardown()
|
function TestDDoSProtectionChallengeQuestion:teardown()
|
||||||
ngx.shared = nil
|
|
||||||
ngx.header = nil
|
|
||||||
ngx._post_args = nil
|
|
||||||
teardownNgx()
|
teardownNgx()
|
||||||
end
|
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
|
-- Tests for proof-of-work challenge type
|
||||||
TestDDoSProtectionChallengePow = {}
|
TestDDoSProtectionChallengePow = {}
|
||||||
|
|
||||||
function TestDDoSProtectionChallengePow:setup()
|
function TestDDoSProtectionChallengePow:setup()
|
||||||
-- Reset ngx
|
-- Reset ngx (includes shared dicts, time, and request functions)
|
||||||
resetNgx()
|
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
|
-- Setup module manually
|
||||||
local ctx = require('ctx')
|
local ctx = require('ctx')
|
||||||
local config = require('config')
|
local config = require('config')
|
||||||
|
|
@ -530,7 +438,7 @@ function TestDDoSProtectionChallengePow:setup()
|
||||||
shared_dict_tokens = 'aproxy_tokens',
|
shared_dict_tokens = 'aproxy_tokens',
|
||||||
protected_paths = {},
|
protected_paths = {},
|
||||||
challenge_type = 'pow',
|
challenge_type = 'pow',
|
||||||
pow_difficulty = 4
|
pow_difficulty = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
self.mod = require('scripts.ddos_protection_challenge')
|
self.mod = require('scripts.ddos_protection_challenge')
|
||||||
|
|
@ -558,17 +466,30 @@ function TestDDoSProtectionChallengePow:testPowChallengeShown()
|
||||||
lu.assertStrContains(ngx._say, '<script>')
|
lu.assertStrContains(ngx._say, '<script>')
|
||||||
end
|
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()
|
function TestDDoSProtectionChallengePow:testValidPowPassesChallenge()
|
||||||
local test_challenge = 'test_pow_challenge'
|
local test_challenge = generateToken()
|
||||||
ngx.shared.aproxy_tokens:set('pow:' .. test_challenge, true, 300)
|
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', {})
|
setupFakeRequest('/__aproxy_challenge_verify', {})
|
||||||
ngx.var.remote_addr = '192.168.4.2'
|
ngx.var.remote_addr = '192.168.4.2'
|
||||||
ngx.var.request_method = 'POST'
|
ngx.var.request_method = 'POST'
|
||||||
ngx._post_args = {
|
ngx._post_args = {
|
||||||
return_to = '/api/test',
|
return_to = '/api/test',
|
||||||
challenge = test_challenge,
|
challenge = test_challenge,
|
||||||
nonce = '12345' -- In reality, this would be computed
|
nonce = valid_nonce
|
||||||
}
|
}
|
||||||
|
|
||||||
onRequest()
|
onRequest()
|
||||||
|
|
@ -596,8 +517,5 @@ function TestDDoSProtectionChallengePow:testInvalidPowFailsChallenge()
|
||||||
end
|
end
|
||||||
|
|
||||||
function TestDDoSProtectionChallengePow:teardown()
|
function TestDDoSProtectionChallengePow:teardown()
|
||||||
ngx.shared = nil
|
|
||||||
ngx.header = nil
|
|
||||||
ngx._post_args = nil
|
|
||||||
teardownNgx()
|
teardownNgx()
|
||||||
end
|
end
|
||||||
|
|
|
||||||
16
tests/mock_resty_random.lua
Normal file
16
tests/mock_resty_random.lua
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- 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
|
||||||
35
tests/mock_resty_sha256.lua
Normal file
35
tests/mock_resty_sha256.lua
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
-- 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
|
||||||
13
tests/mock_resty_string.lua
Normal file
13
tests/mock_resty_string.lua
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue