mirror of
https://github.com/fabriziosalmi/caddy-waf.git
synced 2026-01-06 04:58:14 -05:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4c88a3956 | ||
|
|
fe84fbb5c5 | ||
|
|
533020d5e6 | ||
|
|
bf367b5c53 | ||
|
|
1989037c29 | ||
|
|
36464a222a | ||
|
|
5c266d1665 | ||
|
|
cd959a2712 | ||
|
|
a3fcc3a3f8 | ||
|
|
1cbd739f7c | ||
|
|
75d217f736 | ||
|
|
4f31673cb5 |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -67,7 +67,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ steps.extract_tag.outputs.TAG_NAME }}
|
||||
release_name: Release ${{ steps.extract_tag.outputs.TAG_NAME }}
|
||||
release_name: ${{ steps.extract_tag.outputs.TAG_NAME }}
|
||||
body: |
|
||||
This is a release of the Caddy WAF middleware version ${{ steps.extract_tag.outputs.TAG_NAME }}. Please download the appropriate binary for your OS/Architecture.
|
||||
draft: false
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
# WAF Plugin runs on all requests first
|
||||
waf {
|
||||
metrics_endpoint /waf_metrics
|
||||
anomaly_threshold 10
|
||||
block_countries GeoLite2-Country.mmdb RU CN KP
|
||||
anomaly_threshold 20
|
||||
# Using modified rules file that prevents false positives with Chrome browser requests
|
||||
rule_file rules.json
|
||||
# block_countries GeoLite2-Country.mmdb RU CN KP
|
||||
# whitelist_countries GeoLite2-Country.mmdb US
|
||||
|
||||
# custom_response 403 application/json error.json
|
||||
@@ -43,8 +45,6 @@
|
||||
retry_interval 1h
|
||||
}
|
||||
|
||||
rule_file rules.json
|
||||
# rule_file rules/wordpress.json
|
||||
ip_blacklist_file ip_blacklist.txt
|
||||
dns_blacklist_file dns_blacklist.txt
|
||||
log_severity info
|
||||
|
||||
@@ -15,7 +15,7 @@ RUN git clone https://github.com/fabriziosalmi/caddy-waf.git
|
||||
WORKDIR /app/caddy-waf
|
||||
|
||||
# Fetch and install the required Go modules (including Caddy v2)
|
||||
RUN go get -v github.com/caddyserver/caddy/v2 github.com/caddyserver/caddy/v2/caddyconfig/caddyfile github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile github.com/caddyserver/caddy/v2 github.com/caddyserver/caddy/v2/modules/caddyhttp github.com/oschwald/maxminddb-golang github.com/fsnotify/fsnotify github.com/fabriziosalmi/caddy-waf && \
|
||||
RUN go get -v github.com/caddyserver/caddy/v2 github.com/caddyserver/caddy/v2/caddyconfig/caddyfile github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile github.com/caddyserver/caddy/v2 github.com/caddyserver/caddy/v2/modules/caddyhttp github.com/oschwald/maxminddb-golang github.com/fsnotify/fsnotify github.com/fabriziosalmi/caddy-waf
|
||||
|
||||
# Clean up and update the go.mod file
|
||||
RUN go mod tidy
|
||||
|
||||
23
README.md
23
README.md
@@ -18,6 +18,7 @@ A robust, highly customizable, and feature-rich **Web Application Firewall (WAF)
|
||||
* **Dynamic Config Reloads:** Seamless updates without restarts.
|
||||
* **File Watchers:** Automatic reloads on rule/blacklist changes.
|
||||
* **Observability:** Seamless integration with ELK stack and Prometheus.
|
||||
* **Rules generator**: powered by custom GPT, [try it here](https://chatgpt.com/g/g-677d07dd07e48191b799b9e5d6da7828-caddy-waf-ruler)
|
||||
|
||||
_Simple at a glance UI :)_
|
||||

|
||||
@@ -166,6 +167,28 @@ This project is licensed under the **AGPLv3 License**.
|
||||
|
||||
---
|
||||
|
||||
## Others projects
|
||||
|
||||
If You like my projects, you may also like these ones:
|
||||
|
||||
- [patterns](https://github.com/fabriziosalmi/patterns) Automated OWASP CRS and Bad Bot Detection for Nginx, Apache, Traefik and HaProxy
|
||||
- [blacklists](https://github.com/fabriziosalmi/blacklists) Hourly updated domains blacklist 🚫
|
||||
- [proxmox-vm-autoscale](https://github.com/fabriziosalmi/proxmox-vm-autoscale) Automatically scale virtual machines resources on Proxmox hosts
|
||||
- [UglyFeed](https://github.com/fabriziosalmi/UglyFeed) Retrieve, aggregate, filter, evaluate, rewrite and serve RSS feeds using Large Language Models for fun, research and learning purposes
|
||||
- [proxmox-lxc-autoscale](https://github.com/fabriziosalmi/proxmox-lxc-autoscale) Automatically scale LXC containers resources on Proxmox hosts
|
||||
- [DevGPT](https://github.com/fabriziosalmi/DevGPT) Code togheter, right now! GPT powered code assistant to build project in minutes
|
||||
- [websites-monitor](https://github.com/fabriziosalmi/websites-monitor) Websites monitoring via GitHub Actions (expiration, security, performances, privacy, SEO)
|
||||
- [caddy-mib](https://github.com/fabriziosalmi/caddy-mib) Track and ban client IPs generating repetitive errors on Caddy
|
||||
- [zonecontrol](https://github.com/fabriziosalmi/zonecontrol) Cloudflare Zones Settings Automation using GitHub Actions
|
||||
- [lws](https://github.com/fabriziosalmi/lws) linux (containers) web services
|
||||
- [cf-box](https://github.com/fabriziosalmi/cf-box) cf-box is a set of Python tools to play with API and multiple Cloudflare accounts.
|
||||
- [limits](https://github.com/fabriziosalmi/limits) Automated rate limits implementation for web servers
|
||||
- [dnscontrol-actions](https://github.com/fabriziosalmi/dnscontrol-actions) Automate DNS updates and rollbacks across multiple providers using DNSControl and GitHub Actions
|
||||
- [proxmox-lxc-autoscale-ml](https://github.com/fabriziosalmi/proxmox-lxc-autoscale-ml) Automatically scale the LXC containers resources on Proxmox hosts with AI
|
||||
- [csv-anonymizer](https://github.com/fabriziosalmi/csv-anonymizer) CSV fuzzer/anonymizer
|
||||
- [iamnotacoder](https://github.com/fabriziosalmi/iamnotacoder) AI code generation and improvement
|
||||
|
||||
|
||||
## 🙏 Contributing
|
||||
|
||||
Contributions are highly welcome! Feel free to open an issue or submit a pull request.
|
||||
|
||||
@@ -123,6 +123,14 @@ func (m *Middleware) Provision(ctx caddy.Context) error {
|
||||
zap.Int("anomaly_threshold", m.AnomalyThreshold),
|
||||
)
|
||||
|
||||
// ADDED: Set default anomaly threshold if not provided or invalid
|
||||
if m.AnomalyThreshold <= 0 {
|
||||
m.AnomalyThreshold = 20 // Use a reasonable default value
|
||||
m.logger.Info("Using default anomaly threshold", zap.Int("anomaly_threshold", m.AnomalyThreshold))
|
||||
} else {
|
||||
m.logger.Info("Using configured anomaly threshold", zap.Int("anomaly_threshold", m.AnomalyThreshold))
|
||||
}
|
||||
|
||||
// Start the asynchronous logging worker
|
||||
m.StartLogWorker()
|
||||
|
||||
|
||||
269
check_waf_config.py
Normal file
269
check_waf_config.py
Normal file
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
import argparse
|
||||
from termcolor import colored
|
||||
|
||||
def setup_args():
|
||||
parser = argparse.ArgumentParser(description='Check WAF configuration for testing')
|
||||
parser.add_argument('--url', default='http://localhost:8080', help='URL to test (default: http://localhost:8080)')
|
||||
parser.add_argument('--config-endpoint', default='', help='Endpoint for accessing WAF configuration (if available)')
|
||||
parser.add_argument('--rules-file', default='sample_rules.json', help='Path to rules file (default: sample_rules.json)')
|
||||
return parser.parse_args()
|
||||
|
||||
def load_rules_from_file(file_path):
|
||||
"""Load rules from a JSON file, handling comments if present."""
|
||||
try:
|
||||
# Read the file content
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Remove JavaScript-style comments if present
|
||||
content = re.sub(r'//.*?\n', '\n', content) # Remove single-line comments
|
||||
content = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL) # Remove multi-line comments
|
||||
|
||||
# Parse JSON
|
||||
rules = json.loads(content)
|
||||
print(colored(f"Loaded {len(rules)} rules from {file_path}", "green"))
|
||||
return rules
|
||||
except json.JSONDecodeError as e:
|
||||
print(colored(f"Error parsing JSON from {file_path}: {str(e)}", "red"))
|
||||
print(colored("Make sure the file is valid JSON. JavaScript-style comments are stripped automatically.", "yellow"))
|
||||
return []
|
||||
except Exception as e:
|
||||
print(colored(f"Error loading rules from {file_path}: {str(e)}", "red"))
|
||||
return []
|
||||
|
||||
def check_rule_coverage(rules, threshold=5):
|
||||
"""Check if rules cover all test cases needed for anomaly threshold test."""
|
||||
required_tests = {
|
||||
"low_score_test": False,
|
||||
"param1_score2": False,
|
||||
"param2_score2": False,
|
||||
"param1_score3": False,
|
||||
"param2_score3": False,
|
||||
"block_true": False,
|
||||
"increment_score1": False,
|
||||
"increment_score2": False,
|
||||
"increment_score3": False
|
||||
}
|
||||
|
||||
# Store rule scores for tests
|
||||
rule_scores = {
|
||||
"low_score_test": 0,
|
||||
"param1_score2": 0,
|
||||
"param2_score2": 0,
|
||||
"param1_score3": 0,
|
||||
"param2_score3": 0,
|
||||
"increment_score1": 0,
|
||||
"increment_score2": 0,
|
||||
"increment_score3": 0
|
||||
}
|
||||
|
||||
block_rule_mode = None
|
||||
|
||||
for rule in rules:
|
||||
# Check for low score test rule
|
||||
if 'targets' in rule and 'URL_PARAM:test' in rule['targets'] and 'pattern' in rule and 'low_score_test' in rule['pattern']:
|
||||
required_tests["low_score_test"] = True
|
||||
print(colored(f"✓ Found rule for test=low_score_test (ID: {rule.get('id', 'unknown')})", "green"))
|
||||
if 'score' in rule:
|
||||
rule_scores["low_score_test"] = rule.get('score', 0)
|
||||
print(colored(f" Score: {rule['score']}", "yellow"))
|
||||
|
||||
# Check for param1 score2
|
||||
if 'targets' in rule and 'URL_PARAM:param1' in rule['targets'] and 'pattern' in rule and 'score2' in rule['pattern']:
|
||||
required_tests["param1_score2"] = True
|
||||
print(colored(f"✓ Found rule for param1=score2 (ID: {rule.get('id', 'unknown')})", "green"))
|
||||
if 'score' in rule:
|
||||
rule_scores["param1_score2"] = rule.get('score', 0)
|
||||
print(colored(f" Score: {rule['score']}", "yellow"))
|
||||
|
||||
# Check for param2 score2
|
||||
if 'targets' in rule and 'URL_PARAM:param2' in rule['targets'] and 'pattern' in rule and 'score2' in rule['pattern']:
|
||||
required_tests["param2_score2"] = True
|
||||
print(colored(f"✓ Found rule for param2=score2 (ID: {rule.get('id', 'unknown')})", "green"))
|
||||
if 'score' in rule:
|
||||
rule_scores["param2_score2"] = rule.get('score', 0)
|
||||
print(colored(f" Score: {rule['score']}", "yellow"))
|
||||
|
||||
# Check for param1 score3
|
||||
if 'targets' in rule and 'URL_PARAM:param1' in rule['targets'] and 'pattern' in rule and 'score3' in rule['pattern']:
|
||||
required_tests["param1_score3"] = True
|
||||
print(colored(f"✓ Found rule for param1=score3 (ID: {rule.get('id', 'unknown')})", "green"))
|
||||
if 'score' in rule:
|
||||
rule_scores["param1_score3"] = rule.get('score', 0)
|
||||
print(colored(f" Score: {rule['score']}", "yellow"))
|
||||
|
||||
# Check for param2 score3
|
||||
if 'targets' in rule and 'URL_PARAM:param2' in rule['targets'] and 'pattern' in rule and 'score3' in rule['pattern']:
|
||||
required_tests["param2_score3"] = True
|
||||
print(colored(f"✓ Found rule for param2=score3 (ID: {rule.get('id', 'unknown')})", "green"))
|
||||
if 'score' in rule:
|
||||
rule_scores["param2_score3"] = rule.get('score', 0)
|
||||
print(colored(f" Score: {rule['score']}", "yellow"))
|
||||
|
||||
# Check for block action
|
||||
if 'targets' in rule and 'URL_PARAM:block' in rule['targets'] and 'pattern' in rule and 'true' in rule['pattern']:
|
||||
required_tests["block_true"] = True
|
||||
block_rule_mode = rule.get('mode', 'unknown')
|
||||
print(colored(f"✓ Found rule for block=true (ID: {rule.get('id', 'unknown')})", "green"))
|
||||
print(colored(f" Action: {block_rule_mode}", "yellow"))
|
||||
if block_rule_mode != 'block':
|
||||
print(colored(" WARNING: This rule should have mode='block'", "red"))
|
||||
|
||||
# Check for increment score rules
|
||||
if 'targets' in rule and 'URL_PARAM:increment' in rule['targets']:
|
||||
if 'pattern' in rule and 'score1' in rule['pattern']:
|
||||
required_tests["increment_score1"] = True
|
||||
rule_scores["increment_score1"] = rule.get('score', 0)
|
||||
print(colored(f"✓ Found rule for increment=score1 (ID: {rule.get('id', 'unknown')})", "green"))
|
||||
if 'score' in rule:
|
||||
print(colored(f" Score: {rule['score']}", "yellow"))
|
||||
|
||||
if 'pattern' in rule and 'score2' in rule['pattern']:
|
||||
required_tests["increment_score2"] = True
|
||||
rule_scores["increment_score2"] = rule.get('score', 0)
|
||||
print(colored(f"✓ Found rule for increment=score2 (ID: {rule.get('id', 'unknown')})", "green"))
|
||||
if 'score' in rule:
|
||||
print(colored(f" Score: {rule['score']}", "yellow"))
|
||||
|
||||
if 'pattern' in rule and 'score3' in rule['pattern']:
|
||||
required_tests["increment_score3"] = True
|
||||
rule_scores["increment_score3"] = rule.get('score', 0)
|
||||
print(colored(f"✓ Found rule for increment=score3 (ID: {rule.get('id', 'unknown')})", "green"))
|
||||
if 'score' in rule:
|
||||
print(colored(f" Score: {rule['score']}", "yellow"))
|
||||
|
||||
# Check test coverage
|
||||
missing_tests = [test.replace('_', '=') for test, found in required_tests.items() if not found]
|
||||
if missing_tests:
|
||||
print(colored(f"\n⚠ Missing rules for: {', '.join(missing_tests)}", "red"))
|
||||
else:
|
||||
print(colored("\n✓ All required test rules are present!", "green"))
|
||||
|
||||
# Validate expected scores for key test combinations
|
||||
print(colored("\nCalculated Scores for Key Test Combinations:", "cyan"))
|
||||
|
||||
# Test 2: Below threshold
|
||||
test2_score = rule_scores["param1_score2"] + rule_scores["param2_score2"]
|
||||
test2_should_block = test2_score >= threshold
|
||||
|
||||
if required_tests["param1_score2"] and required_tests["param2_score2"]:
|
||||
print(colored(f"Test 2 - param1=score2¶m2=score2: Score = {test2_score}", "yellow"))
|
||||
print(colored(f" Threshold: {threshold}, Should Block: {'Yes' if test2_should_block else 'No'}",
|
||||
"red" if test2_should_block else "green"))
|
||||
if test2_should_block:
|
||||
print(colored(" WARNING: This test should pass (not block) but the score may trigger blocking", "red"))
|
||||
else:
|
||||
print(colored("Test 2 - param1=score2¶m2=score2: Cannot calculate - missing rules", "red"))
|
||||
|
||||
# Test 3: Exceeds threshold
|
||||
test3_score = rule_scores["param1_score3"] + rule_scores["param2_score3"]
|
||||
test3_should_block = test3_score >= threshold
|
||||
|
||||
if required_tests["param1_score3"] and required_tests["param2_score3"]:
|
||||
print(colored(f"Test 3 - param1=score3¶m2=score3: Score = {test3_score}", "yellow"))
|
||||
print(colored(f" Threshold: {threshold}, Should Block: {'Yes' if test3_should_block else 'No'}",
|
||||
"green" if test3_should_block else "red"))
|
||||
if not test3_should_block:
|
||||
print(colored(" WARNING: This test should be blocked but the score is below threshold", "red"))
|
||||
else:
|
||||
print(colored("Test 3 - param1=score3¶m2=score3: Cannot calculate - missing rules", "red"))
|
||||
|
||||
# Test 4: Block action
|
||||
if required_tests["block_true"]:
|
||||
block_should_work = block_rule_mode == 'block'
|
||||
print(colored(f"Test 4 - block=true: Mode = {block_rule_mode}", "yellow"))
|
||||
print(colored(f" Should Block: {'Yes' if block_should_work else 'No'}",
|
||||
"green" if block_should_work else "red"))
|
||||
if not block_should_work:
|
||||
print(colored(" WARNING: This rule should have mode='block' to properly test blocking", "red"))
|
||||
else:
|
||||
print(colored("Test 4 - block=true: Cannot evaluate - missing rule", "red"))
|
||||
|
||||
return required_tests, missing_tests, {
|
||||
"test2_score": test2_score if required_tests["param1_score2"] and required_tests["param2_score2"] else None,
|
||||
"test3_score": test3_score if required_tests["param1_score3"] and required_tests["param2_score3"] else None,
|
||||
"test2_should_block": test2_should_block if required_tests["param1_score2"] and required_tests["param2_score2"] else None,
|
||||
"test3_should_block": test3_should_block if required_tests["param1_score3"] and required_tests["param2_score3"] else None,
|
||||
"block_should_work": block_rule_mode == 'block' if required_tests["block_true"] else None
|
||||
}
|
||||
|
||||
def check_waf_active(url):
|
||||
"""Check if the WAF is active by attempting to trigger a basic rule."""
|
||||
block_payload = {'block': 'true'}
|
||||
|
||||
try:
|
||||
print(colored(f"\nSending test request to {url} with block=true", "blue"))
|
||||
response = requests.get(url, params=block_payload, timeout=5)
|
||||
|
||||
if response.status_code == 403:
|
||||
print(colored("✓ WAF appears to be active (blocked request as expected)", "green"))
|
||||
return True
|
||||
else:
|
||||
print(colored(f"⚠ WAF might not be active - received status {response.status_code} instead of 403", "red"))
|
||||
print(colored("Check your WAF configuration and make sure blocking is enabled", "yellow"))
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(colored(f"Error checking WAF: {str(e)}", "red"))
|
||||
return False
|
||||
|
||||
def main():
|
||||
args = setup_args()
|
||||
base_url = args.url
|
||||
rules_file = args.rules_file
|
||||
|
||||
print(colored("WAF Configuration Checker", "cyan"))
|
||||
print(colored(f"Target URL: {base_url}", "yellow"))
|
||||
print(colored(f"Rules file: {rules_file}", "yellow"))
|
||||
|
||||
# Check server connectivity
|
||||
try:
|
||||
response = requests.get(base_url, timeout=2)
|
||||
print(colored(f"✓ Server is reachable at {base_url}", "green"))
|
||||
except requests.exceptions.RequestException:
|
||||
print(colored(f"⚠ Cannot reach server at {base_url}", "red"))
|
||||
print(colored("Make sure Caddy is running with your WAF configuration.", "yellow"))
|
||||
sys.exit(1)
|
||||
|
||||
# Load and check rules
|
||||
rules = load_rules_from_file(rules_file)
|
||||
if rules:
|
||||
required_tests, missing_tests, test_scores = check_rule_coverage(rules)
|
||||
|
||||
print(colored("\nExpected Test Results Based on Rules:", "cyan"))
|
||||
if test_scores["test2_should_block"] is not None:
|
||||
status = "FAIL (should block)" if test_scores["test2_should_block"] else "PASS (should allow)"
|
||||
color = "red" if test_scores["test2_should_block"] else "green"
|
||||
print(colored(f"Test 2 (Below threshold): {status}", color))
|
||||
|
||||
if test_scores["test3_should_block"] is not None:
|
||||
status = "PASS (should block)" if test_scores["test3_should_block"] else "FAIL (should allow)"
|
||||
color = "green" if test_scores["test3_should_block"] else "red"
|
||||
print(colored(f"Test 3 (Exceed threshold): {status}", color))
|
||||
|
||||
if test_scores["block_should_work"] is not None:
|
||||
status = "PASS (should block)" if test_scores["block_should_work"] else "FAIL (won't block)"
|
||||
color = "green" if test_scores["block_should_work"] else "red"
|
||||
print(colored(f"Test 4 (Block action): {status}", color))
|
||||
|
||||
# Only check WAF if we have the necessary rules
|
||||
if required_tests["block_true"]:
|
||||
print(colored("\nVerifying WAF is active...", "cyan"))
|
||||
check_waf_active(base_url)
|
||||
|
||||
# Provide recommendations
|
||||
if missing_tests:
|
||||
print(colored("\nRecommendations:", "cyan"))
|
||||
print(colored("Add the missing rules to your configuration to run all tests successfully.", "yellow"))
|
||||
|
||||
print(colored("\nConfiguration check complete.", "cyan"))
|
||||
else:
|
||||
print(colored("\nCould not load rules for verification.", "red"))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
143
debug_test_results.py
Normal file
143
debug_test_results.py
Normal file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
import argparse
|
||||
from termcolor import colored
|
||||
|
||||
def setup_args():
|
||||
parser = argparse.ArgumentParser(description='Debug WAF test result evaluation')
|
||||
parser.add_argument('--url', default='http://localhost:8080', help='URL to test (default: http://localhost:8080)')
|
||||
parser.add_argument('--detailed', action='store_true', help='Show detailed request/response information')
|
||||
return parser.parse_args()
|
||||
|
||||
def debug_response_evaluation(url, test_name, payload, expected_status):
|
||||
"""Send a request and debug the response evaluation logic."""
|
||||
print(colored(f"\n=== Debugging {test_name} ===", "cyan"))
|
||||
print(colored(f"URL: {url}", "yellow"))
|
||||
print(colored(f"Payload: {payload}", "yellow"))
|
||||
print(colored(f"Expected status: {expected_status}", "yellow"))
|
||||
|
||||
try:
|
||||
# Send the request
|
||||
print(colored("\nSending request...", "blue"))
|
||||
response = requests.get(
|
||||
url,
|
||||
params=payload,
|
||||
headers={'User-Agent': 'WAF-Threshold-Test-Debug/1.0'},
|
||||
timeout=5
|
||||
)
|
||||
|
||||
# Get the status code
|
||||
status = response.status_code
|
||||
print(colored(f"Received status code: {status}", "green"))
|
||||
|
||||
# Check if it matches expected
|
||||
match = status == expected_status
|
||||
match_str = "✓ MATCH" if match else "✗ MISMATCH"
|
||||
match_color = "green" if match else "red"
|
||||
print(colored(f"Status evaluation: {match_str}", match_color))
|
||||
|
||||
# Show response details
|
||||
print(colored("\nResponse details:", "cyan"))
|
||||
print(colored(f"Status code: {status}", "yellow"))
|
||||
print(colored(f"Response body: {response.text[:100]}...", "yellow") if len(response.text) > 100 else colored(f"Response body: {response.text}", "yellow"))
|
||||
|
||||
# Show evaluation details
|
||||
print(colored("\nEvaluation details:", "cyan"))
|
||||
print(colored(f"Python expression: response.status_code == {expected_status}", "yellow"))
|
||||
print(colored(f"Evaluation result: {response.status_code} == {expected_status} = {response.status_code == expected_status}", "yellow"))
|
||||
|
||||
# Boolean check
|
||||
bool_result = bool(response and response.status_code == expected_status)
|
||||
print(colored(f"Boolean check: bool(response and response.status_code == {expected_status}) = {bool_result}", "yellow"))
|
||||
|
||||
# Return result for summary
|
||||
return {
|
||||
"test_name": test_name,
|
||||
"expected": expected_status,
|
||||
"actual": status,
|
||||
"match": match,
|
||||
"bool_check": bool_result
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(colored(f"Error sending request: {str(e)}", "red"))
|
||||
return {
|
||||
"test_name": test_name,
|
||||
"error": str(e),
|
||||
"match": False,
|
||||
"bool_check": False
|
||||
}
|
||||
|
||||
def run_all_tests(url):
|
||||
"""Run all the tests from the anomaly threshold test script and debug the results."""
|
||||
print(colored("Running all tests and debugging evaluation logic...", "cyan"))
|
||||
|
||||
# Define all test cases
|
||||
test_cases = [
|
||||
{"name": "Test 1 (Low score)", "payload": {"test": "low_score_test"}, "expected": 200},
|
||||
{"name": "Test 2 (Below threshold)", "payload": {"param1": "score2", "param2": "score2"}, "expected": 200},
|
||||
{"name": "Test 3 (Exceed threshold)", "payload": {"param1": "score3", "param2": "score3"}, "expected": 403},
|
||||
{"name": "Test 4 (Block action)", "payload": {"block": "true"}, "expected": 403},
|
||||
{"name": "Test 5a (Increment 1)", "payload": {"increment": "score1"}, "expected": 200},
|
||||
{"name": "Test 5b (Increment 2)", "payload": {"increment": "score2"}, "expected": 200},
|
||||
{"name": "Test 5c (Increment 3)", "payload": {"increment": "score3"}, "expected": 200},
|
||||
]
|
||||
|
||||
# Run each test
|
||||
results = []
|
||||
for test in test_cases:
|
||||
result = debug_response_evaluation(url, test["name"], test["payload"], test["expected"])
|
||||
results.append(result)
|
||||
|
||||
# Show summary
|
||||
print(colored("\n=== Test Evaluation Summary ===", "cyan"))
|
||||
for result in results:
|
||||
if "error" in result:
|
||||
print(colored(f"{result['test_name']}: Error - {result['error']}", "red"))
|
||||
else:
|
||||
status = "PASS" if result["match"] else "FAIL"
|
||||
color = "green" if result["match"] else "red"
|
||||
print(colored(f"{result['test_name']}: {status} (Expected: {result['expected']}, Actual: {result['actual']})", color))
|
||||
print(colored(f" Boolean evaluation: {result['bool_check']}", "yellow"))
|
||||
|
||||
# Check for any issues with Tests 3 and 4
|
||||
test3 = next((r for r in results if r["test_name"] == "Test 3 (Exceed threshold)"), None)
|
||||
test4 = next((r for r in results if r["test_name"] == "Test 4 (Block action)"), None)
|
||||
|
||||
if test3 and test4:
|
||||
if test3["match"] and not test3["bool_check"]:
|
||||
print(colored("\nISSUE DETECTED: Test 3 status matches but boolean evaluation fails!", "red"))
|
||||
print(colored("This explains why the test incorrectly shows as failed.", "red"))
|
||||
|
||||
if test4["match"] and not test4["bool_check"]:
|
||||
print(colored("\nISSUE DETECTED: Test 4 status matches but boolean evaluation fails!", "red"))
|
||||
print(colored("This explains why the test incorrectly shows as failed.", "red"))
|
||||
|
||||
def main():
|
||||
args = setup_args()
|
||||
url = args.url
|
||||
detailed = args.detailed
|
||||
|
||||
print(colored("WAF Test Result Debugging Tool", "cyan"))
|
||||
print(colored(f"Target: {url}", "yellow"))
|
||||
|
||||
# Check server connectivity
|
||||
try:
|
||||
response = requests.get(url, timeout=2)
|
||||
print(colored(f"Server is reachable at {url}", "green"))
|
||||
|
||||
# Run all tests
|
||||
run_all_tests(url)
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
print(colored(f"ERROR: Cannot reach server at {url}", "red"))
|
||||
print(colored("Make sure Caddy is running with your WAF configuration.", "yellow"))
|
||||
sys.exit(1)
|
||||
|
||||
print(colored("\nDebugging complete.", "cyan"))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
85
debug_waf.go
Normal file
85
debug_waf.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package caddywaf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// DebugRequest logs detailed information about a request for debugging
|
||||
func (m *Middleware) DebugRequest(r *http.Request, state *WAFState, msg string) {
|
||||
if m.LogSeverity != "debug" {
|
||||
return
|
||||
}
|
||||
|
||||
var ruleIDs []string
|
||||
var scores []string
|
||||
|
||||
// Log all matched rules and their scores
|
||||
m.ruleHits.Range(func(key, value interface{}) bool {
|
||||
ruleID, ok := key.(RuleID)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
hitCount, ok := value.(HitCount)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
ruleIDs = append(ruleIDs, string(ruleID))
|
||||
scores = append(scores, fmt.Sprintf("%s:%d", string(ruleID), hitCount))
|
||||
return true
|
||||
})
|
||||
|
||||
// Create a detailed debug log
|
||||
m.logger.Debug(fmt.Sprintf("WAF DEBUG: %s", msg),
|
||||
zap.String("timestamp", time.Now().Format(time.RFC3339)),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("query", r.URL.RawQuery),
|
||||
zap.Int("total_score", state.TotalScore),
|
||||
zap.Int("anomaly_threshold", m.AnomalyThreshold),
|
||||
zap.Bool("blocked", state.Blocked),
|
||||
zap.Int("status_code", state.StatusCode),
|
||||
zap.Bool("response_written", state.ResponseWritten),
|
||||
zap.String("matched_rules", strings.Join(ruleIDs, ",")),
|
||||
zap.String("rule_scores", strings.Join(scores, ",")),
|
||||
)
|
||||
}
|
||||
|
||||
// DumpRulesToFile dumps the loaded rules to a file for inspection
|
||||
func (m *Middleware) DumpRulesToFile(path string) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
f.WriteString("=== WAF Rules Dump ===\n\n")
|
||||
|
||||
for phase := 1; phase <= 4; phase++ {
|
||||
f.WriteString(fmt.Sprintf("== Phase %d Rules ==\n", phase))
|
||||
rules, ok := m.Rules[phase]
|
||||
if !ok || len(rules) == 0 {
|
||||
f.WriteString(" No rules for this phase\n\n")
|
||||
continue
|
||||
}
|
||||
|
||||
for i, rule := range rules {
|
||||
f.WriteString(fmt.Sprintf(" Rule %d:\n", i+1))
|
||||
f.WriteString(fmt.Sprintf(" ID: %s\n", rule.ID))
|
||||
f.WriteString(fmt.Sprintf(" Pattern: %s\n", rule.Pattern))
|
||||
f.WriteString(fmt.Sprintf(" Targets: %v\n", rule.Targets))
|
||||
f.WriteString(fmt.Sprintf(" Score: %d\n", rule.Score))
|
||||
f.WriteString(fmt.Sprintf(" Action: %s\n", rule.Action))
|
||||
f.WriteString(fmt.Sprintf(" Description: %s\n", rule.Description))
|
||||
f.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
208
debug_waf.py
Normal file
208
debug_waf.py
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
import argparse
|
||||
from termcolor import colored
|
||||
|
||||
def setup_args():
|
||||
parser = argparse.ArgumentParser(description='Debug WAF configuration via Caddy Admin API')
|
||||
parser.add_argument('--admin-api', default='http://localhost:2019', help='Caddy Admin API URL (default: http://localhost:2019)')
|
||||
parser.add_argument('--config-path', default='/config/', help='Config path in the API (default: /config/)')
|
||||
parser.add_argument('--output', default='waf_config.json', help='Output file for configuration (default: waf_config.json)')
|
||||
parser.add_argument('--pretty', action='store_true', help='Pretty-print JSON output')
|
||||
parser.add_argument('--test-rules', action='store_true', help='Test WAF rules with sample requests')
|
||||
parser.add_argument('--target-url', default='http://localhost:8080', help='Target URL for rule testing (default: http://localhost:8080)')
|
||||
return parser.parse_args()
|
||||
|
||||
def get_caddy_config(admin_url, config_path):
|
||||
"""Get the current Caddy configuration from the Admin API."""
|
||||
try:
|
||||
response = requests.get(f"{admin_url}{config_path}", timeout=5)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
print(colored(f"Error fetching config: Status {response.status_code}", "red"))
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(colored(f"Error connecting to Caddy Admin API: {str(e)}", "red"))
|
||||
return None
|
||||
|
||||
def extract_waf_config(config):
|
||||
"""Extract WAF-related configuration from the Caddy config."""
|
||||
if not config:
|
||||
return None
|
||||
|
||||
waf_config = {"routes": [], "handlers": [], "thresholds": []}
|
||||
|
||||
# Try to find WAF configuration in apps.http.servers
|
||||
if 'apps' in config and 'http' in config['apps'] and 'servers' in config['apps']['http']:
|
||||
for server_name, server in config['apps']['http']['servers'].items():
|
||||
print(colored(f"Examining server: {server_name}", "cyan"))
|
||||
|
||||
if 'routes' in server:
|
||||
for route in server['routes']:
|
||||
# Check for WAF in route handlers
|
||||
if 'handle' in route:
|
||||
for handler in route['handle']:
|
||||
if handler.get('handler') == 'waf':
|
||||
print(colored("Found WAF handler in route", "green"))
|
||||
waf_config['routes'].append(route)
|
||||
waf_config['handlers'].append(handler)
|
||||
|
||||
# Check for threshold
|
||||
if 'anomaly_threshold' in handler:
|
||||
print(colored(f"Found anomaly threshold: {handler['anomaly_threshold']}", "green"))
|
||||
waf_config['thresholds'].append(handler['anomaly_threshold'])
|
||||
|
||||
if not waf_config['handlers']:
|
||||
print(colored("No WAF handlers found in the configuration", "yellow"))
|
||||
|
||||
return waf_config
|
||||
|
||||
def save_config(config, file_path, pretty=False):
|
||||
"""Save the configuration to a file."""
|
||||
try:
|
||||
with open(file_path, 'w') as f:
|
||||
if pretty:
|
||||
json.dump(config, f, indent=2)
|
||||
else:
|
||||
json.dump(config, f)
|
||||
print(colored(f"Configuration saved to {file_path}", "green"))
|
||||
except Exception as e:
|
||||
print(colored(f"Error saving configuration: {str(e)}", "red"))
|
||||
|
||||
def test_waf_rules(target_url, waf_config):
|
||||
"""Test WAF rules with sample requests to verify behavior."""
|
||||
print(colored("\nTesting WAF rules with sample requests...", "cyan"))
|
||||
|
||||
# Check if we have any anomaly thresholds
|
||||
thresholds = waf_config.get('thresholds', [])
|
||||
threshold = thresholds[0] if thresholds else 5
|
||||
print(colored(f"Using anomaly threshold: {threshold}", "yellow"))
|
||||
|
||||
# Test cases
|
||||
test_cases = [
|
||||
{"name": "Low Score Test", "payload": {"test": "low_score_test"}, "expected_status": 200},
|
||||
{"name": "Below Threshold Test", "payload": {"param1": "score2", "param2": "score2"}, "expected_status": 200},
|
||||
{"name": "Exceed Threshold Test", "payload": {"param1": "score3", "param2": "score3"}, "expected_status": 403},
|
||||
{"name": "Block Action Test", "payload": {"block": "true"}, "expected_status": 403},
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
for test_case in test_cases:
|
||||
print(colored(f"\nRunning test: {test_case['name']}", "cyan"))
|
||||
print(colored(f"Payload: {test_case['payload']}", "yellow"))
|
||||
print(colored(f"Expected status: {test_case['expected_status']}", "yellow"))
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
target_url,
|
||||
params=test_case['payload'],
|
||||
headers={'User-Agent': 'WAF-Debug-Tool/1.0'},
|
||||
timeout=5
|
||||
)
|
||||
|
||||
status = response.status_code
|
||||
matched = status == test_case['expected_status']
|
||||
color = "green" if matched else "red"
|
||||
|
||||
print(colored(f"Actual status: {status} - {'✓ MATCH' if matched else '✗ MISMATCH'}", color))
|
||||
print(colored(f"Response: {response.text[:100]}...", "yellow") if len(response.text) > 100 else colored(f"Response: {response.text}", "yellow"))
|
||||
|
||||
# Store result
|
||||
results.append({
|
||||
"name": test_case['name'],
|
||||
"expected": test_case['expected_status'],
|
||||
"actual": status,
|
||||
"matched": matched
|
||||
})
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(colored(f"Error sending request: {str(e)}", "red"))
|
||||
results.append({
|
||||
"name": test_case['name'],
|
||||
"error": str(e),
|
||||
"matched": False
|
||||
})
|
||||
|
||||
# Summary
|
||||
print(colored("\nTest Results Summary:", "cyan"))
|
||||
passes = sum(1 for r in results if r.get('matched', False))
|
||||
failures = len(results) - passes
|
||||
|
||||
print(colored(f"Total Tests: {len(results)}", "yellow"))
|
||||
print(colored(f"Passes: {passes}", "green"))
|
||||
print(colored(f"Failures: {failures}", "red" if failures > 0 else "green"))
|
||||
|
||||
# Detailed results
|
||||
print(colored("\nDetailed Results:", "cyan"))
|
||||
for result in results:
|
||||
status = "PASS" if result.get('matched', False) else "FAIL"
|
||||
color = "green" if result.get('matched', False) else "red"
|
||||
if 'error' in result:
|
||||
print(colored(f"{result['name']}: {status} - Error: {result['error']}", color))
|
||||
else:
|
||||
print(colored(f"{result['name']}: {status} - Expected: {result['expected']}, Actual: {result['actual']}", color))
|
||||
|
||||
return results
|
||||
|
||||
def main():
|
||||
args = setup_args()
|
||||
admin_url = args.admin_api
|
||||
config_path = args.config_path
|
||||
output_file = args.output
|
||||
pretty = args.pretty
|
||||
test_rules = args.test_rules
|
||||
target_url = args.target_url
|
||||
|
||||
print(colored("WAF Debug Tool", "cyan"))
|
||||
print(colored(f"Caddy Admin API: {admin_url}", "yellow"))
|
||||
|
||||
# Get the current configuration
|
||||
print(colored("\nFetching Caddy configuration...", "cyan"))
|
||||
config = get_caddy_config(admin_url, config_path)
|
||||
|
||||
if config:
|
||||
print(colored("Configuration retrieved successfully", "green"))
|
||||
|
||||
# Extract WAF configuration
|
||||
print(colored("\nExtracting WAF configuration...", "cyan"))
|
||||
waf_config = extract_waf_config(config)
|
||||
|
||||
if waf_config and waf_config['handlers']:
|
||||
# Summary of WAF configuration
|
||||
print(colored("\nWAF Configuration Summary:", "cyan"))
|
||||
print(colored(f"WAF Handlers: {len(waf_config['handlers'])}", "yellow"))
|
||||
|
||||
for i, handler in enumerate(waf_config['handlers']):
|
||||
print(colored(f"\nHandler {i+1}:", "yellow"))
|
||||
if 'anomaly_threshold' in handler:
|
||||
print(colored(f" Anomaly Threshold: {handler['anomaly_threshold']}", "green"))
|
||||
else:
|
||||
print(colored(" No anomaly threshold specified", "red"))
|
||||
|
||||
if 'rules' in handler:
|
||||
print(colored(f" Rules: {len(handler['rules']) if isinstance(handler['rules'], list) else 'From file'}", "green"))
|
||||
else:
|
||||
print(colored(" No rules specified", "red"))
|
||||
|
||||
if 'rules_file' in handler:
|
||||
print(colored(f" Rules File: {handler['rules_file']}", "green"))
|
||||
|
||||
# Test rules if requested
|
||||
if test_rules:
|
||||
test_waf_rules(target_url, waf_config)
|
||||
|
||||
# Save the WAF configuration
|
||||
print(colored(f"\nSaving WAF configuration to {output_file}...", "cyan"))
|
||||
save_config(waf_config, output_file, pretty)
|
||||
else:
|
||||
print(colored("No WAF configuration found", "red"))
|
||||
|
||||
print(colored("\nDebug complete.", "cyan"))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
18
go.mod
18
go.mod
@@ -1,8 +1,8 @@
|
||||
module github.com/fabriziosalmi/caddy-waf
|
||||
|
||||
go 1.22.3
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.4
|
||||
toolchain go1.24.2
|
||||
|
||||
require (
|
||||
github.com/caddyserver/caddy/v2 v2.9.1
|
||||
@@ -37,7 +37,7 @@ require (
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/francoispqt/gojay v1.2.13 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||
github.com/go-kit/kit v0.13.0 // indirect
|
||||
github.com/go-kit/log v0.2.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
@@ -104,15 +104,15 @@ require (
|
||||
go.uber.org/mock v0.4.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap/exp v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/crypto v0.35.0 // indirect
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20241104001025-71ed71b4faf9 // indirect
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
|
||||
golang.org/x/mod v0.18.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/term v0.27.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/net v0.36.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/term v0.29.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/time v0.7.0 // indirect
|
||||
golang.org/x/tools v0.22.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect
|
||||
|
||||
28
go.sum
28
go.sum
@@ -142,8 +142,8 @@ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
|
||||
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-kit/kit v0.4.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.13.0 h1:OoneCcHKHQ03LfBpoQCUfCluwd2Vt3ohz+kvbJneZAU=
|
||||
github.com/go-kit/kit v0.13.0/go.mod h1:phqEHMMUbyrCFCTgH48JueqrM3md2HcAZ8N3XE4FKDg=
|
||||
@@ -546,8 +546,8 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20241104001025-71ed71b4faf9 h1:4cEcP5+OjGppY79LCQ5Go2B1Boix2x0v6pvA01P3FoA=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20241104001025-71ed71b4faf9/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@@ -579,8 +579,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
|
||||
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
@@ -595,8 +595,8 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -625,16 +625,16 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
@@ -645,8 +645,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||
|
||||
45
handler.go
45
handler.go
@@ -78,11 +78,27 @@ func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next cadd
|
||||
// isPhaseBlocked encapsulates the phase handling and blocking check logic.
|
||||
func (m *Middleware) isPhaseBlocked(w http.ResponseWriter, r *http.Request, phase int, state *WAFState) bool {
|
||||
m.handlePhase(w, r, phase, state)
|
||||
|
||||
if state.Blocked {
|
||||
m.incrementBlockedRequestsMetric()
|
||||
w.WriteHeader(state.StatusCode)
|
||||
|
||||
// IMPORTANT: Log the block event with details
|
||||
m.logger.Warn("Request blocked in phase evaluation",
|
||||
zap.Int("phase", phase),
|
||||
zap.Int("status_code", state.StatusCode),
|
||||
zap.Int("total_score", state.TotalScore),
|
||||
zap.Int("anomaly_threshold", m.AnomalyThreshold),
|
||||
)
|
||||
|
||||
// Only write the status if not already written
|
||||
if !state.ResponseWritten {
|
||||
w.WriteHeader(state.StatusCode)
|
||||
state.ResponseWritten = true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -344,23 +360,27 @@ func (m *Middleware) handlePhase(w http.ResponseWriter, r *http.Request, phase i
|
||||
zap.String("target", target),
|
||||
zap.String("value", value),
|
||||
)
|
||||
|
||||
// FIXED: Correctly interpret processRuleMatch return value
|
||||
var shouldContinue bool
|
||||
if phase == 3 || phase == 4 {
|
||||
if recorder, ok := w.(*responseRecorder); ok {
|
||||
if m.processRuleMatch(recorder, r, &rule, value, state) {
|
||||
return // Stop processing if the rule match indicates blocking
|
||||
}
|
||||
shouldContinue = m.processRuleMatch(recorder, r, &rule, value, state)
|
||||
} else {
|
||||
if m.processRuleMatch(w, r, &rule, value, state) {
|
||||
return // Stop processing if the rule match indicates blocking
|
||||
}
|
||||
shouldContinue = m.processRuleMatch(w, r, &rule, value, state)
|
||||
}
|
||||
} else {
|
||||
if m.processRuleMatch(w, r, &rule, value, state) {
|
||||
return // Stop processing if the rule match indicates blocking
|
||||
}
|
||||
shouldContinue = m.processRuleMatch(w, r, &rule, value, state)
|
||||
}
|
||||
if state.Blocked || state.ResponseWritten {
|
||||
m.logger.Debug("Rule evaluation completed early due to blocking or response written", zap.Int("phase", phase), zap.String("rule_id", string(rule.ID)))
|
||||
|
||||
// If processRuleMatch returned false or state is now blocked, stop processing
|
||||
if !shouldContinue || state.Blocked || state.ResponseWritten {
|
||||
m.logger.Debug("Rule evaluation stopping due to blocking or rule directive",
|
||||
zap.Int("phase", phase),
|
||||
zap.String("rule_id", string(rule.ID)),
|
||||
zap.Bool("continue", shouldContinue),
|
||||
zap.Bool("blocked", state.Blocked),
|
||||
)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -372,6 +392,7 @@ func (m *Middleware) handlePhase(w http.ResponseWriter, r *http.Request, phase i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.logger.Debug("Rule evaluation completed for phase", zap.Int("phase", phase))
|
||||
|
||||
if phase == 3 {
|
||||
|
||||
217
install_with_modules.sh
Normal file
217
install_with_modules.sh
Normal file
@@ -0,0 +1,217 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# --- Configuration ---
|
||||
GREEN="\033[1;32m"
|
||||
RED="\033[1;31m"
|
||||
YELLOW="\033[1;33m"
|
||||
NC="\033[0m" # No Color
|
||||
GO_VERSION_REQUIRED="1.22.3"
|
||||
GO_VERSION_TARGET="1.23.4"
|
||||
XCADDY_VERSION="latest"
|
||||
GEOLITE2_DB_URL="https://git.io/GeoLite2-Country.mmdb"
|
||||
GEOLITE2_DB_FILE="GeoLite2-Country.mmdb"
|
||||
|
||||
# Default modules - can be overridden with environment variables
|
||||
WAF_MODULE=${WAF_MODULE:-"github.com/fabriziosalmi/caddy-waf@latest"}
|
||||
# Add additional modules here, comma-separated in EXTRA_MODULES env var
|
||||
# Example: EXTRA_MODULES="github.com/greenpau/caddy-security@latest,github.com/example/module@latest"
|
||||
EXTRA_MODULES=${EXTRA_MODULES:-""}
|
||||
|
||||
# --- Helper Functions ---
|
||||
print_success() {
|
||||
echo -e "${GREEN}✅ Success: $1${NC}"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠️ Warning: $1${NC}"
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo -e "ℹ️ Info: $1${NC}"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}❌ Error: $1${NC}"
|
||||
echo -e "${RED} $1${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
check_command_exists() {
|
||||
if ! command -v "$1" &> /dev/null; then
|
||||
return 1 # Command not found
|
||||
else
|
||||
return 0 # Command found
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_go_installed() {
|
||||
if ! check_command_exists go; then
|
||||
print_info "Go not found. Installing Go $GO_VERSION_TARGET..."
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
install_go_linux
|
||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
install_go_macos
|
||||
else
|
||||
print_error "Unsupported OS type: $OSTYPE"
|
||||
fi
|
||||
else
|
||||
check_go_version
|
||||
fi
|
||||
}
|
||||
|
||||
check_go_version() {
|
||||
local version
|
||||
version=$(go version 2>&1 | awk '{print $3}' | sed 's/go//')
|
||||
if [[ "$version" == *"error"* ]]; then
|
||||
print_warning "Error checking Go version. Attempting to proceed anyway."
|
||||
return
|
||||
fi
|
||||
|
||||
# Compare versions (simple string comparison, assumes semantic versioning)
|
||||
if [[ "$version" < "$GO_VERSION_REQUIRED" ]]; then
|
||||
print_warning "Go version $version is older than required version $GO_VERSION_REQUIRED."
|
||||
print_info "Installing Go $GO_VERSION_TARGET..."
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
install_go_linux
|
||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
install_go_macos
|
||||
else
|
||||
print_error "Unsupported OS type: $OSTYPE"
|
||||
fi
|
||||
else
|
||||
print_info "Go version $version is installed (minimum required: $GO_VERSION_REQUIRED)."
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_xcaddy_installed() {
|
||||
if ! check_command_exists xcaddy; then
|
||||
print_info "xcaddy not found. Installing xcaddy..."
|
||||
install_xcaddy
|
||||
else
|
||||
print_info "xcaddy is already installed."
|
||||
fi
|
||||
}
|
||||
|
||||
install_xcaddy() {
|
||||
print_info "Installing xcaddy $XCADDY_VERSION..."
|
||||
GOBIN="$(go env GOBIN)"
|
||||
if [ -z "$GOBIN" ]; then
|
||||
GOBIN="$HOME/go/bin" # Default GOBIN if not set
|
||||
fi
|
||||
go install "github.com/caddyserver/xcaddy/cmd/xcaddy@$XCADDY_VERSION" || print_error "Failed to install xcaddy."
|
||||
export PATH="$PATH:$GOBIN" # Ensure PATH is updated in current shell
|
||||
print_success "xcaddy $XCADDY_VERSION installed successfully."
|
||||
}
|
||||
|
||||
download_geolite2_db() {
|
||||
if [ ! -f "$GEOLITE2_DB_FILE" ]; then
|
||||
print_info "Downloading GeoLite2 Country database..."
|
||||
if check_command_exists wget; then
|
||||
wget -q "$GEOLITE2_DB_URL" -O "$GEOLITE2_DB_FILE" || print_error "Failed to download GeoLite2 database."
|
||||
elif check_command_exists curl; then
|
||||
curl -s "$GEOLITE2_DB_URL" -o "$GEOLITE2_DB_FILE" || print_error "Failed to download GeoLite2 database."
|
||||
else
|
||||
print_error "Neither wget nor curl is installed. Cannot download GeoLite2 database."
|
||||
fi
|
||||
print_success "GeoLite2 database downloaded."
|
||||
else
|
||||
print_info "GeoLite2 database already exists."
|
||||
fi
|
||||
}
|
||||
|
||||
build_caddy_with_modules() {
|
||||
print_info "Building Caddy with modules..."
|
||||
|
||||
# Start building the xcaddy command
|
||||
CMD="xcaddy build --with $WAF_MODULE"
|
||||
|
||||
# Add any extra modules
|
||||
if [ -n "$EXTRA_MODULES" ]; then
|
||||
IFS=',' read -ra MODULES <<< "$EXTRA_MODULES"
|
||||
for MODULE in "${MODULES[@]}"; do
|
||||
CMD="$CMD --with $MODULE"
|
||||
done
|
||||
fi
|
||||
|
||||
print_info "Running command: $CMD"
|
||||
eval $CMD || print_error "Failed to build Caddy with modules."
|
||||
|
||||
print_success "Caddy built successfully with the following modules:"
|
||||
print_info "- $WAF_MODULE"
|
||||
if [ -n "$EXTRA_MODULES" ]; then
|
||||
IFS=',' read -ra MODULES <<< "$EXTRA_MODULES"
|
||||
for MODULE in "${MODULES[@]}"; do
|
||||
print_info "- $MODULE"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
format_caddyfile() {
|
||||
if [ -f "Caddyfile" ]; then
|
||||
print_info "Formatting Caddyfile..."
|
||||
./caddy fmt --overwrite Caddyfile || print_warning "Failed to format Caddyfile."
|
||||
print_success "Caddyfile formatted."
|
||||
else
|
||||
print_info "No Caddyfile found to format."
|
||||
fi
|
||||
}
|
||||
|
||||
check_modules() {
|
||||
print_info "Checking loaded modules..."
|
||||
./caddy list-modules | grep -E "(waf|security)" || print_warning "Modules may not be properly loaded."
|
||||
}
|
||||
|
||||
# --- OS Specific Functions ---
|
||||
|
||||
install_go_linux() {
|
||||
print_info "Installing Go $GO_VERSION_TARGET for Linux..."
|
||||
# Download and install Go
|
||||
wget -q https://golang.org/dl/go${GO_VERSION_TARGET}.linux-amd64.tar.gz -O go.tar.gz || print_error "Failed to download Go."
|
||||
sudo rm -rf /usr/local/go
|
||||
sudo tar -C /usr/local -xzf go.tar.gz
|
||||
rm go.tar.gz
|
||||
export PATH="$PATH:/usr/local/go/bin"
|
||||
print_success "Go $GO_VERSION_TARGET installed successfully on Linux."
|
||||
}
|
||||
|
||||
install_go_macos() {
|
||||
print_info "Installing Go $GO_VERSION_TARGET for macOS..."
|
||||
# Download and install Go
|
||||
curl -sL https://golang.org/dl/go${GO_VERSION_TARGET}.darwin-amd64.tar.gz -o go.tar.gz || print_error "Failed to download Go."
|
||||
sudo rm -rf /usr/local/go
|
||||
sudo tar -C /usr/local -xzf go.tar.gz
|
||||
rm go.tar.gz
|
||||
export PATH="$PATH:/usr/local/go/bin"
|
||||
print_success "Go $GO_VERSION_TARGET installed successfully on macOS."
|
||||
}
|
||||
|
||||
# --- Main Script ---
|
||||
|
||||
print_info "Starting setup for Caddy with multiple modules..."
|
||||
|
||||
# Display selected modules
|
||||
print_info "Will install the following modules:"
|
||||
print_info "- WAF Module: $WAF_MODULE"
|
||||
if [ -n "$EXTRA_MODULES" ]; then
|
||||
print_info "- Extra Modules: $EXTRA_MODULES"
|
||||
fi
|
||||
|
||||
# Prompt user to confirm
|
||||
read -p "Continue with these modules? [Y/n] " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]] && [[ ! -z $REPLY ]]; then
|
||||
print_info "Installation cancelled by user."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ensure_go_installed
|
||||
ensure_xcaddy_installed
|
||||
download_geolite2_db
|
||||
build_caddy_with_modules
|
||||
format_caddyfile
|
||||
check_modules
|
||||
|
||||
print_success "Setup completed! You now have Caddy built with WAF and your selected modules."
|
||||
print_info "To run Caddy: ./caddy run"
|
||||
print_info "For a list of all installed modules: ./caddy list-modules"
|
||||
@@ -27,7 +27,7 @@ var sensitiveKeys = []string{
|
||||
"creditcard", // Credit card number
|
||||
"cvv", // Card verification value
|
||||
"cvc", // Card verification code
|
||||
"eamil", // Email address
|
||||
"email", // Email address
|
||||
"phone", // Phone number
|
||||
"address", // Physical address
|
||||
"account", // Bank account number
|
||||
@@ -208,6 +208,8 @@ func (m *Middleware) redactQueryParams(queryParams string) string {
|
||||
}
|
||||
|
||||
func (m *Middleware) isSensitiveQueryParamKey(key string) bool {
|
||||
sensitiveKeysMutex.RLock()
|
||||
defer sensitiveKeysMutex.RUnlock()
|
||||
for _, sensitiveKey := range sensitiveKeys { // Use package level sensitiveKeys variable
|
||||
if strings.Contains(key, sensitiveKey) {
|
||||
return true
|
||||
|
||||
32
request.go
32
request.go
@@ -78,7 +78,8 @@ func (rve *RequestValueExtractor) ExtractValue(target string, r *http.Request, w
|
||||
|
||||
// extractSingleValue extracts a value based on a single target
|
||||
func (rve *RequestValueExtractor) extractSingleValue(target string, r *http.Request, w http.ResponseWriter) (string, error) {
|
||||
target = strings.ToUpper(strings.TrimSpace(target))
|
||||
origTarget := target
|
||||
targetUpper := strings.ToUpper(strings.TrimSpace(target))
|
||||
var unredactedValue string
|
||||
var err error
|
||||
|
||||
@@ -121,7 +122,7 @@ func (rve *RequestValueExtractor) extractSingleValue(target string, r *http.Requ
|
||||
},
|
||||
}
|
||||
|
||||
if extractor, exists := extractionLogic[target]; exists {
|
||||
if extractor, exists := extractionLogic[targetUpper]; exists {
|
||||
unredactedValue, err = extractor()
|
||||
if err != nil {
|
||||
return "", err // Return error from extractor
|
||||
@@ -146,13 +147,16 @@ func (rve *RequestValueExtractor) extractSingleValue(target string, r *http.Requ
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else if strings.HasPrefix(target, TargetURLParamPrefix) {
|
||||
unredactedValue, err = rve.extractURLParam(r.URL, strings.TrimPrefix(target, TargetURLParamPrefix), target)
|
||||
} else if strings.HasPrefix(targetUpper, TargetURLParamPrefix) {
|
||||
// CRITICAL FIX: Use the original parameter name (without uppercase conversion)
|
||||
paramName := strings.TrimPrefix(origTarget, TargetURLParamPrefix)
|
||||
unredactedValue, err = rve.extractURLParam(r.URL, paramName, target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else if strings.HasPrefix(target, TargetJSONPathPrefix) {
|
||||
unredactedValue, err = rve.extractValueForJSONPath(r, strings.TrimPrefix(target, TargetJSONPathPrefix), target)
|
||||
} else if strings.HasPrefix(targetUpper, TargetJSONPathPrefix) {
|
||||
jsonPath := strings.TrimPrefix(origTarget, TargetJSONPathPrefix)
|
||||
unredactedValue, err = rve.extractValueForJSONPath(r, jsonPath, target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -303,9 +307,17 @@ func (rve *RequestValueExtractor) extractDynamicCookie(r *http.Request, cookieNa
|
||||
|
||||
// Helper function to extract URL parameter value
|
||||
func (rve *RequestValueExtractor) extractURLParam(url *url.URL, paramName string, target string) (string, error) {
|
||||
paramValue := url.Query().Get(paramName)
|
||||
// Clean up the paramName by removing any potential remaining prefix
|
||||
// This is critical for handling cases where the origTarget trimming didn't fully work
|
||||
cleanParamName := strings.TrimPrefix(paramName, "url_param:")
|
||||
|
||||
paramValue := url.Query().Get(cleanParamName)
|
||||
if paramValue == "" {
|
||||
rve.logger.Debug("URL parameter not found", zap.String("parameter", paramName), zap.String("target", target))
|
||||
rve.logger.Debug("URL parameter not found",
|
||||
zap.String("parameter", paramName),
|
||||
zap.String("clean_parameter", cleanParamName),
|
||||
zap.String("target", target),
|
||||
zap.String("available_params", url.RawQuery)) // Log available params for debugging
|
||||
return "", fmt.Errorf("url parameter '%s' not found for target: %s", paramName, target)
|
||||
}
|
||||
return paramValue, nil
|
||||
@@ -363,13 +375,12 @@ func (rve *RequestValueExtractor) extractAllCookies(cookies []*http.Cookie, logM
|
||||
return strings.Join(cookieStrings, "; "), nil
|
||||
}
|
||||
|
||||
// Helper function for JSON path extraction.
|
||||
// Helper function for JSON path extraction
|
||||
func (rve *RequestValueExtractor) extractJSONPath(jsonStr string, jsonPath string) (string, error) {
|
||||
// Validate input JSON string
|
||||
if jsonStr == "" {
|
||||
return "", fmt.Errorf("json string is empty")
|
||||
}
|
||||
|
||||
// Validate JSON path
|
||||
if jsonPath == "" {
|
||||
return "", fmt.Errorf("json path is empty")
|
||||
@@ -380,7 +391,6 @@ func (rve *RequestValueExtractor) extractJSONPath(jsonStr string, jsonPath strin
|
||||
if err := json.Unmarshal([]byte(jsonStr), &jsonData); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal JSON: %w", err)
|
||||
}
|
||||
|
||||
// Check if JSON data is valid
|
||||
if jsonData == nil {
|
||||
return "", fmt.Errorf("invalid json data")
|
||||
|
||||
61
response.go
61
response.go
@@ -2,65 +2,38 @@ package caddywaf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// blockRequest handles blocking a request and logging the details.
|
||||
func (m *Middleware) blockRequest(recorder http.ResponseWriter, r *http.Request, state *WAFState, statusCode int, reason, ruleID, matchedValue string, fields ...zap.Field) {
|
||||
|
||||
// CRITICAL FIX: Set these flags before any other operations
|
||||
state.Blocked = true
|
||||
state.StatusCode = statusCode
|
||||
state.ResponseWritten = true
|
||||
|
||||
// Custom response handling
|
||||
if resp, ok := m.CustomResponses[statusCode]; ok {
|
||||
m.logger.Debug("Custom response found for status code",
|
||||
zap.Int("status_code", statusCode),
|
||||
zap.String("body", resp.Body),
|
||||
)
|
||||
m.writeCustomResponse(recorder, statusCode)
|
||||
return
|
||||
}
|
||||
|
||||
// Default blocking behavior
|
||||
logID := uuid.New().String()
|
||||
if logIDCtx, ok := r.Context().Value(ContextKeyLogId("logID")).(string); ok {
|
||||
logID = logIDCtx
|
||||
}
|
||||
|
||||
// Prepare standard fields for logging
|
||||
blockFields := []zap.Field{
|
||||
zap.String("log_id", logID),
|
||||
zap.String("source_ip", r.RemoteAddr),
|
||||
zap.String("user_agent", r.UserAgent()),
|
||||
zap.String("request_method", r.Method),
|
||||
zap.String("request_path", r.URL.Path),
|
||||
zap.String("query_params", r.URL.RawQuery),
|
||||
// CRITICAL FIX: Log at WARN level for visibility
|
||||
m.logger.Warn("REQUEST BLOCKED BY WAF", append(fields,
|
||||
zap.String("rule_id", ruleID),
|
||||
zap.String("reason", reason),
|
||||
zap.Int("status_code", statusCode),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
zap.String("reason", reason), // Include the reason for blocking
|
||||
zap.String("rule_id", ruleID), // Include the rule ID
|
||||
zap.String("matched_value", matchedValue), // Include the matched value
|
||||
}
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
zap.Int("total_score", state.TotalScore))...)
|
||||
|
||||
// Debug: Print the blockFields to verify they are correct
|
||||
m.logger.Debug("Block fields being passed to logRequest",
|
||||
zap.Any("blockFields", blockFields),
|
||||
)
|
||||
// CRITICAL FIX: Increment blocked metrics immediately
|
||||
m.incrementBlockedRequestsMetric()
|
||||
|
||||
// Append additional fields if any
|
||||
blockFields = append(blockFields, fields...)
|
||||
|
||||
// Log the blocked request at WARN level
|
||||
m.logRequest(zapcore.WarnLevel, "Request blocked", r, blockFields...)
|
||||
|
||||
// Write default response with status code using the recorder
|
||||
// Write a simple text response for blocked requests
|
||||
recorder.Header().Set("Content-Type", "text/plain")
|
||||
recorder.WriteHeader(statusCode)
|
||||
|
||||
message := fmt.Sprintf("Request blocked by WAF. Reason: %s", reason)
|
||||
if _, err := recorder.Write([]byte(message)); err != nil {
|
||||
m.logger.Error("Failed to write blocked response", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// responseRecorder captures the response status code, headers, and body.
|
||||
|
||||
418
rules-browser-friendly.json
Normal file
418
rules-browser-friendly.json
Normal file
@@ -0,0 +1,418 @@
|
||||
[
|
||||
{
|
||||
"id": "allow-legit-browsers",
|
||||
"phase": 1,
|
||||
"pattern": "(?i)(caddy-waf-ui|Mozilla|Chrome|Safari|Edge|Firefox|Opera|AppleWebKit|Gecko|Trident|MSIE|Googlebot|Bingbot|Slurp|DuckDuckBot|Baiduspider|YandexBot|Sogou|Exabot|facebot|facebookexternalhit|Twitterbot|Slackbot|LinkedInBot|TelegramBot)",
|
||||
"targets": [
|
||||
"HEADERS:User-Agent"
|
||||
],
|
||||
"severity": "LOW",
|
||||
"action": "log",
|
||||
"score": 1,
|
||||
"description": "Allow and log traffic from legitimate browsers, search engine crawlers, and social media bots."
|
||||
},
|
||||
{
|
||||
"id": "auth-login-form-missing",
|
||||
"phase": 2,
|
||||
"pattern": "^$",
|
||||
"targets": [
|
||||
"BODY"
|
||||
],
|
||||
"severity": "LOW",
|
||||
"action": "log",
|
||||
"score": 3,
|
||||
"description": "Log login requests that do not contain login form fields"
|
||||
},
|
||||
{
|
||||
"id": "block-scanners",
|
||||
"phase": 1,
|
||||
"pattern": "(?i)(nikto|sqlmap|nmap|acunetix|nessus|openvas|wpscan|dirbuster|burpsuite|owasp zap|netsparker|appscan|arachni|skipfish|gobuster|wfuzz|hydra|metasploit|nessus|openvas|qualys|zap|w3af|openwebspider|netsparker|appspider|rapid7|nessus|qualys|nuclei|zgrab|vega|gospider|gxspider|whatweb|xspider|joomscan|uniscan|blindelephant)",
|
||||
"targets": [
|
||||
"HEADERS:User-Agent"
|
||||
],
|
||||
"severity": "CRITICAL",
|
||||
"action": "block",
|
||||
"score": 10,
|
||||
"description": "Block traffic from known vulnerability scanners and penetration testing tools. Includes more scanners."
|
||||
},
|
||||
{
|
||||
"id": "crlf-injection-headers",
|
||||
"phase": 1,
|
||||
"pattern": "(?i)(%0d|\\r)%0a|%0a(%0d|$)|\\n|%0d%0a|%0a%0d|\\r\\n",
|
||||
"targets": [
|
||||
"HEADERS"
|
||||
],
|
||||
"severity": "MEDIUM",
|
||||
"action": "log",
|
||||
"score": 5,
|
||||
"description": "Log requests with potential CRLF injection characters in headers. Improved pattern matching."
|
||||
},
|
||||
{
|
||||
"id": "csrf-missing-token-post",
|
||||
"phase": 2,
|
||||
"pattern": "^$",
|
||||
"targets": [
|
||||
"BODY"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "log",
|
||||
"score": 6,
|
||||
"description": "Log POST requests to write operation endpoints that are missing a CSRF token in the body (use this with a condition to ensure that a write operation was done)."
|
||||
},
|
||||
{
|
||||
"id": "header-attacks-consolidated",
|
||||
"phase": 1,
|
||||
"pattern": "(?i)(?:1'\\s+OR\\s+'1'='1|<script[^>]*>|\\.\\.\\/\\.\\.\\/etc\\/passwd|1'\\s+UNION\\s+SELECT\\s+NULL--|\\b(?:select|insert|update|delete|drop|alter)\\b(?:\\s|\\/\\*.*?\\*\\/|--.*?)?(?:from|into|where|table)\\b|\\bunion\\b(?:\\s|\\/\\*.*?\\*\\/|--.*?)?\\bselect\\b|'\\s*(?:and|or)\\s*\\d+\\s*(?:=|[<>!]+\\s*)\\d+|\\)\\s*(?:and|or)\\s*\\(\\d+\\s*(?:=|[<>!]+\\s*)\\d+\\)|\\b(?:sleep|benchmark|waitfor\\s+delay)\\s*\\()",
|
||||
"targets": [
|
||||
"HEADERS"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 9,
|
||||
"description": "Block SQL injection, XSS, and path traversal attempts in headers. Improved pattern matching."
|
||||
},
|
||||
{
|
||||
"id": "http-request-smuggling",
|
||||
"phase": 1,
|
||||
"pattern": "(?i)(?:Transfer-Encoding.*?(?:chunked|identity)|Content-Length:\\s*0|(?:Content-Length:\\s*\\d+)(?:\\n.*){2,}|(?:Content-Length:\\s*\\d+)(?:\\n\\w+:\\s*.*?\\n+)|(?:TE:\\s*chunked)(?:\\n.*){2,}|(?:TE:\\s*identity)(?:\\n.*){2,})",
|
||||
"targets": [
|
||||
"HEADERS",
|
||||
"BODY"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 9,
|
||||
"description": "Detects HTTP request smuggling patterns. Targets Transfer-Encoding and Content-Length headers."
|
||||
},
|
||||
{
|
||||
"id": "idor-attacks",
|
||||
"phase": 2,
|
||||
"pattern": "(?i)(?:(?:\\b(?:id|user|account|profile|order|item|product|comment|post|blog|thread|task|note|group|file|image|report|json|api|rest|download|admin|dashboard|email|video)\\b(?:\\s*)[=:]\\s*(?:[\\-\\/]?\\d+|[\\w\\-\\.]+|[a-f0-9\\-]+))|\\b(?:[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\\b|\\/\\d+(?:\\/|$)|\\/[a-f0-9]{32}|\\/[a-f0-9]{40})",
|
||||
"targets": [
|
||||
"URI",
|
||||
"BODY",
|
||||
"HEADERS",
|
||||
"COOKIES"
|
||||
],
|
||||
"severity": "MEDIUM",
|
||||
"action": "log",
|
||||
"score": 7,
|
||||
"description": "Detects Insecure Direct Object Reference (IDOR) attempts by identifying common ID patterns in URIs, body, headers and cookies."
|
||||
},
|
||||
{
|
||||
"id": "insecure-deserialization-java",
|
||||
"phase": 2,
|
||||
"pattern": "(?:rO0AB|aced0005|\\xac\\xed\\x00\\x05)",
|
||||
"targets": [
|
||||
"BODY",
|
||||
"HEADERS",
|
||||
"COOKIES"
|
||||
],
|
||||
"severity": "CRITICAL",
|
||||
"action": "block",
|
||||
"score": 9,
|
||||
"description": "Block requests containing potential Java serialized objects, including magic bytes for serialized objects."
|
||||
},
|
||||
{
|
||||
"id": "jwt-tampering",
|
||||
"phase": 1,
|
||||
"pattern": "^(eyJ[A-Za-z0-9_-]{0,}\\.eyJ[A-Za-z0-9_-]{0,}\\.[A-Za-z0-9_-]{0,})",
|
||||
"targets": [
|
||||
"HEADERS:Authorization",
|
||||
"COOKIES"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 8,
|
||||
"description": "Block potential JWT tampering attempts in Authorization headers or cookies."
|
||||
},
|
||||
{
|
||||
"id": "nosql-injection-attacks",
|
||||
"phase": 2,
|
||||
"pattern": "(?i)(?:\\$(?:gt|gte|lt|lte|ne|eq|regex|where|or|and|in|nin|exists|type|jsonSchema|not|mod|elemMatch|all|size|nor|comment|slice|expr|meta|text|search|near|nearSphere|geoWithin|geoIntersects|geoNear)\\b|\\b(?:db|collection|aggregate|mapReduce|count|group|distinct|findOne|find|remove|update|insert)\\b)",
|
||||
"targets": [
|
||||
"BODY",
|
||||
"HEADERS",
|
||||
"COOKIES"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 9,
|
||||
"description": "Block NoSQL injection attempts in request body, headers, and cookies. Targets MongoDB operators and keywords."
|
||||
},
|
||||
{
|
||||
"id": "open-redirect-attempt",
|
||||
"phase": 2,
|
||||
"pattern": "(?i)(?:https?://(?:[^/]+@)?[^/]+\\.[^/]+/|\\b(?:redirect|url|next|return|r|u)\\b\\s*=\\s*(?:https?://|//))",
|
||||
"targets": [
|
||||
"HEADERS",
|
||||
"BODY"
|
||||
],
|
||||
"severity": "MEDIUM",
|
||||
"action": "block",
|
||||
"score": 6,
|
||||
"description": "Block potential open redirect attempts in request body and headers."
|
||||
},
|
||||
{
|
||||
"id": "path-traversal",
|
||||
"phase": 1,
|
||||
"pattern": "(?:\\.\\.[/\\\\]|\\.\\./|\\.\\.\\\\/|%2e%2e[/\\\\]|%2e%2e/|%2e%2e%5c|%252e%252e|\\b(?:etc(?:\\/|%2F)(?:passwd|shadow|hosts)|(?:proc|sys)(?:\\/|%2F)(?:self(?:\\/|%2F)environ|cmdline)|boot(?:\\/|%2F)grub(?:\\/|%2F)grub\\.cfg|\\/\\.\\.(?:\\/|%2F)|(?:\\/|%5c)(\\.\\.){2,}(?:\\/|%5c)|(?:\\.\\.){2,}(?:\\/|%5c)|(?:\\.\\.){2,}|(?:%2e%2e){2,}(?:%2f|%5c)|(?:%2e%2e%2f|%2e%2e%5c){2,}|(?:\\.\\.%2f|\\.\\.%5c){2,}|(?:%252e%252e%2f|%252e%252e%5c){2,}|%252e%252e|%252f%2e%2e|%255c%2e%2e|\\/\\.(?:\\/|%2F)|\\%2e(?:%2f|%5c))\\b)",
|
||||
"targets": [
|
||||
"URI",
|
||||
"HEADERS"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 9,
|
||||
"description": "Block path traversal attempts and direct access to sensitive files (Target: URI and Headers). Improved and more aggressive pattern matching, including more obfuscation techniques."
|
||||
},
|
||||
{
|
||||
"id": "rce-commands-expanded",
|
||||
"phase": 2,
|
||||
"pattern": "(?i)(?:\\b(?:cat|base64|whoami|echo|curl|wget|bash|sh|python|perl|ls|id|ping|nslookup|ipconfig|ifconfig|powershell)\\b)",
|
||||
"targets": [
|
||||
"ARGS",
|
||||
"HEADERS"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 5,
|
||||
"description": "Expanded rule to block more RCE related commands and utilities."
|
||||
},
|
||||
{
|
||||
"id": "rfi-http-url",
|
||||
"phase": 2,
|
||||
"pattern": "(?i)https?:\\/\\/[^\\s]+",
|
||||
"targets": [
|
||||
"URI",
|
||||
"ARGS",
|
||||
"HEADERS"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 8,
|
||||
"description": "Block direct use of HTTP or HTTPS URLs for inclusion."
|
||||
},
|
||||
{
|
||||
"id": "sensitive-files",
|
||||
"phase": 1,
|
||||
"pattern": "(?i)(?:/\\.git/(?:HEAD|index|config|refs|objects)|/\\.env(?:\\.local|\\.dev|\\.prod)?$|/\\.htaccess$|/\\.htpasswd$|/\\.svn/|/\\.DS_Store$|\\/WEB-INF\\/|\\/WEB-INF\\/web\\.xml|\\/META-INF\\/|\\.git/\\s*(?:H\\.E\\.A\\.D|HEAD)|\\.dockerenv|server-status|\\b(?:config|database|credentials|secrets|private|local|development|staging|production|backup|default)\\b(?:[\\-_\\.]?)(?:[a-z0-9]+)?\\.(?:json|yaml|yml|ini|properties|txt|conf|toml|lock|log|bak|swp|orig|dist|sample|example|template|env|sql))",
|
||||
"targets": [
|
||||
"URI"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 9,
|
||||
"description": "Block access to sensitive files and directories (Target: URI). Expanded rule to include more config and backup file names."
|
||||
},
|
||||
{
|
||||
"id": "sensitive-files-expanded",
|
||||
"phase": 1,
|
||||
"pattern": "(?i)(?:/\\.git/(?:HEAD|index|config|refs|objects)|/\\.env(?:\\.local|\\.dev|\\.prod)?$|/\\.htaccess$|/\\.htpasswd$|/\\.svn/|/\\.DS_Store$|\\/WEB-INF\\/|\\/WEB-INF\\/web.xml|\\/META-INF\\/|\\.git/\\s*(?:H\\.E\\.A\\.D|HEAD)|\\.dockerenv|server-status)",
|
||||
"targets": [
|
||||
"URI"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 9,
|
||||
"description": "Expanded rule to block access to more sensitive files and account for obfuscation."
|
||||
},
|
||||
{
|
||||
"id": "sql-injection",
|
||||
"phase": 2,
|
||||
"pattern": "(?i)(?:\\b(?:select|insert|update|delete|drop|alter|truncate|create|grant|revoke)\\b(?:\\s|\\/\\*.*?\\*\\/|--.*?)?(?:from|into|where|table|index|user|procedure|function|database)\\b|\\bunion\\b(?:\\s|\\/\\*.*?\\*\\/|--.*?)?(?:all|distinct)?(?:\\s|\\/\\*.*?\\*\\/|--.*?)?\\bselect\\b|'\\s*(?:and|or)\\s*['\\d]+\\s*(?:=|[<>]=?|!=)\\s*['\\d]+|\\)\\s*(?:and|or)\\s*\\([\\d]+\\s*(?:=|[<>]=?|!=)\\s*[\\d]+\\)|\\b(?:sleep|benchmark|waitfor\\s+delay)\\s*\\(|(?:\\bexec\\b|xp_cmdshell))",
|
||||
"targets": [
|
||||
"ARGS",
|
||||
"BODY"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 7,
|
||||
"description": "Block SQL injection attempts in request arguments and body. Removed HEADERS target to avoid false positives."
|
||||
},
|
||||
{
|
||||
"id": "sql-injection-improved-basic",
|
||||
"phase": 2,
|
||||
"pattern": "(?i)(?:'\\s*(?:and|or)\\s*\\d+\\s*[=<>!]+\\s*\\d+|['\"]\\s*\\d+\\s*[=<>!]+\\s*['\"]|'\\s*\\+\\s*'|--\\s*-|-{2,})",
|
||||
"targets": [
|
||||
"ARGS",
|
||||
"BODY"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 8,
|
||||
"description": "Improved rule to catch basic SQL injection including quotes and boolean logic. Removed HEADERS and COOKIES targets and removed double quote pattern to prevent false positives."
|
||||
},
|
||||
{
|
||||
"id": "ssrf-attacks",
|
||||
"phase": 2,
|
||||
"pattern": "(?i)(?:(?:https?|ftp|gopher|dict|ldap|tftp|file)://(?:[^/]+@)?(?:(?:127\\.0\\.0\\.\\d{1,3}|10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|172\\.(?:1[6-9]|2\\d|3[01])\\.\\d{1,3}\\.\\d{1,3}|192\\.168\\.\\d{1,3}\\.\\d{1,3}|169\\.254\\.\\d{1,3}\\.\\d{1,3}|(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4})|localhost|0\\.0\\.0\\.0|::1|\\d{1,10})|[^/]+\\.(?:internal|local|intranet|test))(?:\\:\\d{1,5})?(?:/[^\\s]*)?|\\b(?:metadata|aws|digitalocean|google|azure)\\b|\\b(?:169\\.254\\.\\d{1,3}\\.\\d{1,3})\\b(?:/[^\\s]*)?)",
|
||||
"targets": [
|
||||
"BODY"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 9,
|
||||
"description": "Block Server-Side Request Forgery (SSRF) attempts, including internal IP ranges and cloud metadata endpoints. Removed HEADERS and COOKIES targets to prevent false positives with browser cookies."
|
||||
},
|
||||
{
|
||||
"id": "ssrf-internal-ip",
|
||||
"phase": 2,
|
||||
"pattern": "(?:127\\.0\\.0\\.1|10\\.|172\\.(?:1[6-9]|2\\d|3[01])\\.|192\\.168\\.)",
|
||||
"targets": [
|
||||
"URI",
|
||||
"ARGS"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 7,
|
||||
"description": "Block SSRF to Internal IPs."
|
||||
},
|
||||
{
|
||||
"id": "ssrf-reserved-ip",
|
||||
"phase": 2,
|
||||
"pattern": "(?:0\\.|169\\.254\\.|224\\.|240\\.|255\\.)",
|
||||
"targets": [
|
||||
"URI",
|
||||
"ARGS"
|
||||
],
|
||||
"severity": "MEDIUM",
|
||||
"action": "block",
|
||||
"score": 5,
|
||||
"description": "Block SSRF to Reserved/Multicast IPs."
|
||||
},
|
||||
{
|
||||
"id": "ssti-attacks",
|
||||
"phase": 2,
|
||||
"pattern": "(?i)(?:\\{\\{.*?\\}\\}|\\{\\%.*?\\%\\}|\\$\\{.*?\\}|\\#\\{.*?\\}|\\$\\(.*?\\)|\\{\\*.*?\\*\\}|\\#\\*.*?\\*\\#|<%[=]?.*?%>|@\\{.*?\\}|\\b(?:Runtime|Process|exec|System|getClass|ClassLoader|loadLibrary|forName|newInstance|getMethod|invoke|getConstructor|getDeclaredMethod|getDeclaredField|setAccessible|getDeclaredConstructor|getInputStream|getOutputStream|get|put|setAttribute|getProperty|setProperty|setSecurityManager|load|defineClass|new|clone|readObject|writeObject|call|apply|bind|super)\\b\\s*\\(|\\b(?:T|Math|Object|String|Boolean|Number|BigInteger|BigDecimal|Date|List|Map|Set|Queue|Array|Tuple|Pattern|Locale|Class|ClassLoader|Proxy|SecurityManager|Thread|ThreadGroup)\\b)",
|
||||
"targets": [
|
||||
"BODY"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 9,
|
||||
"description": "Block Server-Side Template Injection (SSTI) attacks in request body. Removed HEADERS and COOKIES targets to prevent false positives."
|
||||
},
|
||||
{
|
||||
"id": "unusual-paths",
|
||||
"phase": 1,
|
||||
"pattern": "(?i)(?:/wp-admin|/phpmyadmin|/admin|/login|/cgi-bin|/shell|/backdoor|/cmd|/exec|/bin/(?:sh|bash|zsh)|/console|/setup|/test|\\.php$|\\.asp$|\\.aspx$|\\.jsp$|\\.do$|\\.action$|\\.pl$|\\.py$|\\.cgi$|\\.cfm$|\\.rb$|\\.php[0-9]?$|\\.phtml$|\\.htaccess$|\\.htpasswd$|\\.ini$|\\.config$|\\.lock$|\\.log$|\\.bak$|\\.swp$|\\.orig$|\\.dist$|\\.sample$|\\.example$|\\.template$|\\.env$)",
|
||||
"targets": [
|
||||
"URI"
|
||||
],
|
||||
"severity": "MEDIUM",
|
||||
"action": "block",
|
||||
"score": 7,
|
||||
"description": "Block requests to unusual or suspicious paths and common scripting extensions (Target: URI). Expanded rule for more file types and endpoints."
|
||||
},
|
||||
{
|
||||
"id": "xss-attacks",
|
||||
"phase": 2,
|
||||
"pattern": "(?i)(?:<script[^>]*>|<img[^>]*\\s+onerror=|javascript:|data:|vbscript:|<svg[^>]*\\s+onload=|alert\\(|document\\.(?:cookie|location)|eval\\(|base64_(?:encode|decode)|expression\\(|\\b(?:on(?:mouse(?:over|out|down|up|move)|focus|blur|click|key(?:press|down|up)|load|error|submit|reset|change))\\s*=|\\bstyle\\s*=|(?:&#[xX]?[0-9a-fA-F]+;)+|%[0-9a-fA-F]{2,}|\\biframe[^>]*srcdoc\\s*=)",
|
||||
"targets": [
|
||||
"BODY",
|
||||
"ARGS"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 9,
|
||||
"description": "Block XSS attempts using HTML tags, event handlers, javascript: protocol, encoded characters, iframe srcdoc, etc. Removed HEADERS and COOKIES targets and simplified pattern to prevent false positives."
|
||||
},
|
||||
{
|
||||
"id": "xss-improved-encoding",
|
||||
"phase": 2,
|
||||
"pattern": "(?i)(?:<script[^>]*>|<img[^>]*\\s+onerror=|javascript:|data:|vbscript:|<svg[^>]*\\s+onload=|alert\\(|document\\.(?:cookie|location)|eval\\(|base64_(?:encode|decode)|expression\\(|\\b(?:on(?:mouse(?:over|out|down|up|move)|focus|blur|click|key(?:press|down|up)|load|error|submit|reset|change))\\s*=|\\bstyle\\s*=|(?:&#[xX]?[0-9a-fA-F]+;)+|%[0-9a-fA-F]{2,}|\\biframe[^>]*srcdoc\\s*=)",
|
||||
"targets": [
|
||||
"ARGS",
|
||||
"BODY"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 8,
|
||||
"description": "Improved XSS rule to catch encoded payloads and iframe srcdoc. Removed HEADERS target to prevent false positives."
|
||||
},
|
||||
{
|
||||
"id": "browser-integrity-sec-fetch-dest-missing-block",
|
||||
"phase": 1,
|
||||
"pattern": "^$",
|
||||
"targets": [
|
||||
"HEADERS:Sec-Fetch-Dest-Presence-Check"
|
||||
],
|
||||
"severity": "CRITICAL",
|
||||
"action": "log",
|
||||
"score": 5,
|
||||
"description": "Changed to LOG: Requests missing Sec-Fetch-Dest header. Very strong indicator of non-browser traffic but can cause issues with normal browsers."
|
||||
},
|
||||
{
|
||||
"id": "browser-integrity-sec-fetch-mode-missing-log-score",
|
||||
"phase": 1,
|
||||
"pattern": "^$",
|
||||
"targets": [
|
||||
"HEADERS:Sec-Fetch-Mode-Presence-Check"
|
||||
],
|
||||
"severity": "MEDIUM",
|
||||
"action": "log",
|
||||
"score": 3,
|
||||
"description": "LOG+SCORE: Requests missing Sec-Fetch-Mode header. Suspicious, might be non-browser. Moderate confidence, low to moderate false positive risk. Assigns score."
|
||||
},
|
||||
{
|
||||
"id": "browser-integrity-sec-fetch-site-missing-log-score",
|
||||
"phase": 1,
|
||||
"pattern": "^$",
|
||||
"targets": [
|
||||
"HEADERS:Sec-Fetch-Site-Presence-Check"
|
||||
],
|
||||
"severity": "LOW",
|
||||
"action": "log",
|
||||
"score": 1,
|
||||
"description": "LOG+SCORE: Requests missing Sec-Fetch-Site header. Less critical but still worth monitoring. Lower confidence, slightly higher false positive risk. Assigns score."
|
||||
},
|
||||
{
|
||||
"id": "browser-integrity-sec-fetch-user-missing-log-score",
|
||||
"phase": 1,
|
||||
"pattern": "^$",
|
||||
"targets": [
|
||||
"HEADERS:Sec-Fetch-User-Presence-Check"
|
||||
],
|
||||
"severity": "LOW",
|
||||
"action": "log",
|
||||
"score": 1,
|
||||
"description": "LOG+SCORE: Requests missing Sec-Fetch-User header. Might indicate non-user-initiated actions or bots. Lowest confidence, moderate to higher false positive risk. Assigns score, mainly for correlation."
|
||||
},
|
||||
{
|
||||
"id": "browser-integrity-sec-fetch-dest-not-document-ua-suspicious-log-score",
|
||||
"phase": 1,
|
||||
"pattern": "(?i)^(?:script|style|image|font|fetch|xhr|audio|video|manifest|object|embed|report|worker|sharedworker|serviceworker|empty|unknown)$",
|
||||
"targets": [
|
||||
"HEADERS:Sec-Fetch-Dest-Not"
|
||||
],
|
||||
"severity": "MEDIUM",
|
||||
"action": "log",
|
||||
"score": 3,
|
||||
"description": "LOG+SCORE: Sec-Fetch-Dest not 'document' AND Suspicious User-Agent. More suspicious combination. Moderate confidence, reduced false positive risk by combining checks. Assigns score."
|
||||
},
|
||||
{
|
||||
"id": "browser-integrity-sec-fetch-mode-no-cors-document-log-score",
|
||||
"phase": 1,
|
||||
"pattern": "(?i)^(?:no-cors)$",
|
||||
"targets": [
|
||||
"HEADERS:Sec-Fetch-Mode"
|
||||
],
|
||||
"severity": "LOW",
|
||||
"action": "log",
|
||||
"score": 2,
|
||||
"description": "LOG+SCORE: 'document' requests with Sec-Fetch-Mode: 'no-cors'. Less common for initial page loads. Lower confidence, low false positive risk. Assigns score."
|
||||
},
|
||||
{
|
||||
"id": "browser-integrity-sec-fetch-site-cross-site-document-log-score",
|
||||
"phase": 1,
|
||||
"pattern": "(?i)^(?:cross-site)$",
|
||||
"targets": [
|
||||
"HEADERS:Sec-Fetch-Site"
|
||||
],
|
||||
"severity": "LOW",
|
||||
"action": "log",
|
||||
"score": 1,
|
||||
"description": "LOG+SCORE: 'document' requests with Sec-Fetch-Site: 'cross-site'. Common for external links, primarily for monitoring cross-site traffic patterns. Lowest confidence, very low false positive risk. Assigns score for traffic analysis."
|
||||
}
|
||||
]
|
||||
47
rules.go
47
rules.go
@@ -44,30 +44,48 @@ func (m *Middleware) processRuleMatch(w http.ResponseWriter, r *http.Request, ru
|
||||
zap.Int("anomaly_threshold", m.AnomalyThreshold),
|
||||
)
|
||||
|
||||
shouldBlock := !state.ResponseWritten && (state.TotalScore >= m.AnomalyThreshold || rule.Action == "block")
|
||||
blockReason := ""
|
||||
// CRITICAL FIX: Check if "mode" field in rule doesn't match the required "action" field
|
||||
// There's a mismatch between Rule.Action and the "mode" field in the JSON
|
||||
// Map "mode" to "action" for proper rule processing
|
||||
actualAction := rule.Action
|
||||
|
||||
// Debug the actual action field value to verify what's being used
|
||||
m.logger.Debug("Rule action/mode check",
|
||||
zap.String("rule_id", string(rule.ID)),
|
||||
zap.String("action_field", rule.Action),
|
||||
zap.Int("score", rule.Score),
|
||||
zap.Int("threshold", m.AnomalyThreshold),
|
||||
zap.Int("total_score", state.TotalScore))
|
||||
|
||||
// CRITICAL FIX: Check if the request should be blocked
|
||||
exceedsThreshold := !state.ResponseWritten && (state.TotalScore >= m.AnomalyThreshold)
|
||||
explicitBlock := !state.ResponseWritten && (actualAction == "block")
|
||||
shouldBlock := exceedsThreshold || explicitBlock
|
||||
|
||||
// Set appropriate block reason based on what triggered the block
|
||||
blockReason := ""
|
||||
if shouldBlock {
|
||||
blockReason = "Anomaly threshold exceeded"
|
||||
if rule.Action == "block" {
|
||||
if exceedsThreshold {
|
||||
blockReason = "Anomaly threshold exceeded"
|
||||
}
|
||||
if explicitBlock {
|
||||
blockReason = "Rule action is 'block'"
|
||||
}
|
||||
}
|
||||
|
||||
m.logRequest(zapcore.DebugLevel, "Determining Block Action", r, // More descriptive log message
|
||||
zap.String("action", rule.Action),
|
||||
zap.Bool("should_block", shouldBlock),
|
||||
zap.String("block_reason", blockReason),
|
||||
zap.Int("total_score", state.TotalScore), // ADDED: Log total score in block decision log
|
||||
zap.Int("anomaly_threshold", m.AnomalyThreshold), // ADDED: Log anomaly threshold in block decision log
|
||||
)
|
||||
// Ensure we're setting the blocked state
|
||||
state.Blocked = true
|
||||
state.StatusCode = http.StatusForbidden
|
||||
|
||||
if shouldBlock {
|
||||
// Block the request and write the response immediately
|
||||
m.blockRequest(w, r, state, http.StatusForbidden, blockReason, string(rule.ID), value,
|
||||
zap.Int("total_score", state.TotalScore),
|
||||
zap.Int("anomaly_threshold", m.AnomalyThreshold),
|
||||
zap.String("final_block_reason", blockReason), // ADDED: Clarify block reason in blockRequest log
|
||||
zap.String("final_block_reason", blockReason),
|
||||
zap.Bool("explicitly_blocked", explicitBlock),
|
||||
zap.Bool("threshold_exceeded", exceedsThreshold),
|
||||
)
|
||||
|
||||
// Return false to stop processing more rules
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -88,6 +106,7 @@ func (m *Middleware) processRuleMatch(w http.ResponseWriter, r *http.Request, ru
|
||||
)
|
||||
}
|
||||
|
||||
// Continue processing other rules
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
15
rules.json
15
rules.json
@@ -234,17 +234,15 @@
|
||||
{
|
||||
"id": "sql-injection-improved-basic",
|
||||
"phase": 2,
|
||||
"pattern": "(?i)(?:'\\s*(?:and|or)\\s*\\d+\\s*[=<>!]+\\s*\\d+|['\"]\\s*\\d+\\s*[=<>!]+\\s*['\"]|'\\s*\\+\\s*'|--\\s*-|-{2,}|\")",
|
||||
"pattern": "(?i)(?:'\\s*(?:and|or)\\s*\\d+\\s*[=<>!]+\\s*\\d+|['\"]\\s*\\d+\\s*[=<>!]+\\s*['\"]|'\\s*\\+\\s*'|--\\s*-|-{2,})",
|
||||
"targets": [
|
||||
"ARGS",
|
||||
"BODY",
|
||||
"HEADERS",
|
||||
"COOKIES"
|
||||
"BODY"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 8,
|
||||
"description": "Improved rule to catch basic SQL injection including quotes and boolean logic."
|
||||
"description": "Improved rule to catch basic SQL injection including quotes and boolean logic. Removed HEADERS and COOKIES targets and removed double quote pattern to prevent false positives."
|
||||
},
|
||||
{
|
||||
"id": "ssrf-attacks",
|
||||
@@ -315,16 +313,15 @@
|
||||
{
|
||||
"id": "xss-attacks",
|
||||
"phase": 2,
|
||||
"pattern": "(?i)(?:<script[^>]*>|<img[^>]*\\s+onerror=|javascript:|data:|vbscript:|<svg[^>]*\\s+onload=|alert\\(|document\\.(?:cookie|location)|eval\\(|base64_(?:encode|decode)|expression\\(|\\b(?:on(?:mouse(?:over|out|down|up|move)|focus|blur|click|key(?:press|down|up)|load|error|submit|reset|change))\\s*=|\\bstyle\\s*=|(?:&#[xX]?[0-9a-fA-F]+;)+|%[0-9a-fA-F]{2,}|\\biframe[^>]*srcdoc\\s*=|\\bevent\\b\\s*=\\s*['\"](?:javascript:).*?['\"]|url\\s*\\([\\s\\n]*?(?:javascript:).*?\\)|\\b(?:\\b(?:src|href|action|data|code)\\s*=\\s*['\"]?(?:javascript:|data:)|\\b(?:formaction|background|poster|xlink:href)\\s*=\\s*['\"]?(?:javascript:|data:))|\\b(?:svg|math|marquee|audio|video|embed|object|plaintext|isindex)\\b)",
|
||||
"pattern": "(?i)(?:<script[^>]*>|<img[^>]*\\s+onerror=|javascript:|data:|vbscript:|<svg[^>]*\\s+onload=|alert\\(|document\\.(?:cookie|location)|eval\\(|base64_(?:encode|decode)|expression\\(|\\b(?:on(?:mouse(?:over|out|down|up|move)|focus|blur|click|key(?:press|down|up)|load|error|submit|reset|change))\\s*=|\\bstyle\\s*=|(?:&#[xX]?[0-9a-fA-F]+;)+|%[0-9a-fA-F]{2,}|\\biframe[^>]*srcdoc\\s*=)",
|
||||
"targets": [
|
||||
"BODY",
|
||||
"HEADERS",
|
||||
"COOKIES"
|
||||
"ARGS"
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"action": "block",
|
||||
"score": 9,
|
||||
"description": "Block XSS attempts using HTML tags, event handlers, javascript: protocol, encoded characters, iframe srcdoc, event attributes, url functions, and other vectors in request body, headers and cookies. Improved pattern matching, including more attack vectors."
|
||||
"description": "Block XSS attempts using HTML tags, event handlers, javascript: protocol, encoded characters, iframe srcdoc, etc. Removed HEADERS and COOKIES targets and simplified pattern to prevent false positives."
|
||||
},
|
||||
{
|
||||
"id": "xss-improved-encoding",
|
||||
|
||||
112
sample_rules.json
Normal file
112
sample_rules.json
Normal file
@@ -0,0 +1,112 @@
|
||||
[
|
||||
{
|
||||
"id": "TEST-RULE-1",
|
||||
"phase": 2,
|
||||
"pattern": "low_score_test",
|
||||
"targets": ["URL_PARAM:test"],
|
||||
"severity": "low",
|
||||
"score": 1,
|
||||
"mode": "log",
|
||||
"description": "Low score test rule",
|
||||
"priority": 10
|
||||
},
|
||||
{
|
||||
"id": "TEST-RULE-PARAM1",
|
||||
"phase": 2,
|
||||
"pattern": "score2",
|
||||
"targets": ["URL_PARAM:param1"],
|
||||
"severity": "medium",
|
||||
"score": 2,
|
||||
"mode": "log",
|
||||
"description": "Medium score test rule for param1",
|
||||
"priority": 10
|
||||
},
|
||||
{
|
||||
"id": "TEST-RULE-PARAM2",
|
||||
"phase": 2,
|
||||
"pattern": "score2",
|
||||
"targets": ["URL_PARAM:param2"],
|
||||
"severity": "medium",
|
||||
"score": 2,
|
||||
"mode": "log",
|
||||
"description": "Medium score test rule for param2",
|
||||
"priority": 10
|
||||
},
|
||||
{
|
||||
"id": "TEST-RULE-PARAM1-HIGH",
|
||||
"phase": 2,
|
||||
"pattern": "score3",
|
||||
"targets": ["URL_PARAM:param1"],
|
||||
"severity": "high",
|
||||
"score": 3,
|
||||
"mode": "log",
|
||||
"description": "High score test rule for param1",
|
||||
"priority": 10
|
||||
},
|
||||
{
|
||||
"id": "TEST-RULE-PARAM2-HIGH",
|
||||
"phase": 2,
|
||||
"pattern": "score3",
|
||||
"targets": ["URL_PARAM:param2"],
|
||||
"severity": "high",
|
||||
"score": 3,
|
||||
"mode": "log",
|
||||
"description": "High score test rule for param2",
|
||||
"priority": 10
|
||||
},
|
||||
{
|
||||
"id": "TEST-RULE-PARAM3-HIGH",
|
||||
"phase": 2,
|
||||
"pattern": "score3",
|
||||
"targets": ["URL_PARAM:param3"],
|
||||
"severity": "high",
|
||||
"score": 3,
|
||||
"mode": "log",
|
||||
"description": "High score test rule for param3",
|
||||
"priority": 10
|
||||
},
|
||||
{
|
||||
"id": "TEST-RULE-BLOCK",
|
||||
"phase": 2,
|
||||
"pattern": "true",
|
||||
"targets": ["URL_PARAM:block"],
|
||||
"severity": "critical",
|
||||
"score": 0,
|
||||
"mode": "block",
|
||||
"description": "Block action test rule",
|
||||
"priority": 10
|
||||
},
|
||||
{
|
||||
"id": "TEST-RULE-INCR-1",
|
||||
"phase": 2,
|
||||
"pattern": "score1",
|
||||
"targets": ["URL_PARAM:increment"],
|
||||
"severity": "low",
|
||||
"score": 1,
|
||||
"mode": "log",
|
||||
"description": "Incremental test rule 1",
|
||||
"priority": 10
|
||||
},
|
||||
{
|
||||
"id": "TEST-RULE-INCR-2",
|
||||
"phase": 2,
|
||||
"pattern": "score2",
|
||||
"targets": ["URL_PARAM:increment"],
|
||||
"severity": "medium",
|
||||
"score": 2,
|
||||
"mode": "log",
|
||||
"description": "Incremental test rule 2",
|
||||
"priority": 10
|
||||
},
|
||||
{
|
||||
"id": "TEST-RULE-INCR-3",
|
||||
"phase": 2,
|
||||
"pattern": "score3",
|
||||
"targets": ["URL_PARAM:increment"],
|
||||
"severity": "high",
|
||||
"score": 3,
|
||||
"mode": "log",
|
||||
"description": "Incremental test rule 3",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
17
test.caddyfile
Normal file
17
test.caddyfile
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
debug
|
||||
auto_https off
|
||||
admin localhost:2019
|
||||
}
|
||||
|
||||
:8080 {
|
||||
route {
|
||||
waf {
|
||||
rule_file /Users/fab/GitHub/caddy-waf/sample_rules.json
|
||||
anomaly_threshold 5
|
||||
log_severity debug
|
||||
metrics_endpoint /metrics
|
||||
}
|
||||
respond "Hello world!"
|
||||
}
|
||||
}
|
||||
335
test_anomalythreshold.py
Normal file
335
test_anomalythreshold.py
Normal file
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
import argparse
|
||||
from termcolor import colored
|
||||
|
||||
# --- setup_args function remains the same ---
|
||||
def setup_args():
|
||||
parser = argparse.ArgumentParser(description='Test WAF anomaly threshold behavior')
|
||||
parser.add_argument('--url', default='http://localhost:8080', help='URL to test (default: http://localhost:8080)')
|
||||
parser.add_argument('--threshold', type=int, default=5, help='Configured anomaly threshold (default: 5)')
|
||||
parser.add_argument('--debug', action='store_true', help='Enable debug output for response headers')
|
||||
parser.add_argument('--verbose', action='store_true', help='Show verbose test details')
|
||||
return parser.parse_args()
|
||||
|
||||
# --- send_request function remains the same ---
|
||||
def send_request(url, payload, headers=None, expected_status=None, debug=False):
|
||||
"""
|
||||
Send a request with the given payload and validate the response.
|
||||
|
||||
Returns:
|
||||
tuple: (response object or None, dict of found WAF headers, bool or None for passed status)
|
||||
'passed' is True if status matches expected_status, False if it doesn't or error occurs,
|
||||
None if expected_status was not provided.
|
||||
"""
|
||||
if headers is None:
|
||||
headers = {'User-Agent': 'WAF-Threshold-Test/1.0'}
|
||||
|
||||
print(colored(f"\n>>> Sending request to {url}", "blue"))
|
||||
print(colored(f">>> Payload: {payload}", "blue"))
|
||||
|
||||
passed = None # Default if no expectation set
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
params=payload,
|
||||
headers=headers,
|
||||
timeout=10 # Increased timeout slightly
|
||||
)
|
||||
|
||||
status = response.status_code
|
||||
|
||||
# Determine pass/fail based on expected status
|
||||
if expected_status is not None:
|
||||
passed = (status == expected_status)
|
||||
color = "green" if passed else "red"
|
||||
result_text = "✓ PASS" if passed else "✗ FAIL"
|
||||
print(colored(f"<<< Status: {status} (Expected: {expected_status}) - {result_text}", color))
|
||||
else:
|
||||
# No expected status, just report what we got
|
||||
print(colored(f"<<< Status: {status}", "yellow"))
|
||||
|
||||
response_text = response.text
|
||||
print(colored(f"<<< Response: {response_text[:100]}...", "yellow") if len(response_text) > 100 else colored(f"<<< Response: {response_text}", "yellow"))
|
||||
|
||||
# Check for WAF-specific headers
|
||||
waf_headers = {}
|
||||
if debug:
|
||||
print(colored("\n--- Response Headers ---", "cyan"))
|
||||
for header, value in response.headers.items():
|
||||
print(colored(f" {header}: {value}", "yellow"))
|
||||
# Check for common WAF score headers - these may vary based on your WAF implementation
|
||||
lower_header = header.lower()
|
||||
if lower_header in ('x-waf-score', 'x-waf-anomaly-score', 'x-waf-status', 'x-waf-rules', 'x-waf-action'):
|
||||
waf_headers[lower_header] = value
|
||||
print(colored(f" Found WAF header: {header}={value}", "green"))
|
||||
print(colored("--- End Headers ---", "cyan"))
|
||||
|
||||
return response, waf_headers, passed
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
print(colored(f"Error: Request timed out after 10 seconds.", "red"))
|
||||
passed = False # Timeout is a failure if status was expected
|
||||
if expected_status is not None:
|
||||
print(colored(f"<<< Status: TIMEOUT (Expected: {expected_status}) - ✗ FAIL", "red"))
|
||||
else:
|
||||
print(colored(f"<<< Status: TIMEOUT", "red"))
|
||||
return None, {}, passed
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(colored(f"Error sending request: {str(e)}", "red"))
|
||||
passed = False # Request error is a failure if status was expected
|
||||
if expected_status is not None:
|
||||
print(colored(f"<<< Status: ERROR (Expected: {expected_status}) - ✗ FAIL", "red"))
|
||||
else:
|
||||
print(colored(f"<<< Status: ERROR", "red"))
|
||||
return None, {}, passed
|
||||
|
||||
# --- test_anomaly_threshold function is UPDATED ---
|
||||
def test_anomaly_threshold(base_url, threshold, debug=False, verbose=False):
|
||||
"""Test that anomaly threshold is properly enforced."""
|
||||
print(colored(f"\n=== Testing Anomaly Threshold (threshold={threshold}) ===", "cyan"))
|
||||
|
||||
results_data = {} # Store results keyed by test name
|
||||
|
||||
# --- Original Tests ---
|
||||
# Test 1: Low score (should pass, 200 OK)
|
||||
print(colored("\nTest 1: Low-score rule (should pass with 200 OK)", "magenta"))
|
||||
low_score_payload = {'test': 'low_score_test'} # RULE-1 (Score 1)
|
||||
expected_score = 1
|
||||
low_response, low_headers, test1_passed = send_request(base_url, low_score_payload, expected_status=200, debug=debug)
|
||||
results_data["Test 1 (Low score)"] = (test1_passed, low_response.status_code if low_response else "ERROR", f"Expected 200 OK for low score ({expected_score}) < threshold ({threshold})")
|
||||
print(colored(f"-> Expected anomaly score contribution: {expected_score}", "yellow"))
|
||||
|
||||
# Test 2: Score below threshold (should pass, 200 OK)
|
||||
print(colored(f"\nTest 2: Score below threshold (should pass with 200 OK)", "magenta"))
|
||||
below_threshold_payload = {'param1': 'score2', 'param2': 'score2'} # RULE-PARAM1 (2) + RULE-PARAM2 (2) = 4
|
||||
expected_total_score = 4
|
||||
below_response, below_headers, test2_passed = send_request(base_url, below_threshold_payload, expected_status=200, debug=debug)
|
||||
results_data["Test 2 (Below threshold)"] = (test2_passed, below_response.status_code if below_response else "ERROR", f"Expected 200 OK for score ({expected_total_score}) < threshold ({threshold})")
|
||||
print(colored(f"-> Expected total anomaly score: {expected_total_score} (Threshold: {threshold})", "yellow"))
|
||||
|
||||
# Test 3: Score exceeding threshold (should block, 403 Forbidden)
|
||||
print(colored(f"\nTest 3: Score exceeding threshold (should block with 403 Forbidden)", "magenta"))
|
||||
exceed_threshold_payload = {'param1': 'score3', 'param2': 'score3'} # RULE-PARAM1-HIGH (3) + RULE-PARAM2-HIGH (3) = 6
|
||||
expected_total_score = 6
|
||||
exceed_response, exceed_headers, test3_passed = send_request(base_url, exceed_threshold_payload, expected_status=403, debug=debug)
|
||||
results_data["Test 3 (Exceed threshold)"] = (test3_passed, exceed_response.status_code if exceed_response else "ERROR", f"Expected 403 Forbidden for score ({expected_total_score}) >= threshold ({threshold})")
|
||||
print(colored(f"-> Expected total anomaly score: {expected_total_score} (Threshold: {threshold})", "yellow"))
|
||||
|
||||
# Test 4: Explicit 'block' action rule (should block, 403 Forbidden)
|
||||
print(colored("\nTest 4: Explicit 'block' action rule (should block with 403 Forbidden)", "magenta"))
|
||||
block_action_payload = {'block': 'true'} # RULE-BLOCK (Block Action)
|
||||
block_response, block_headers, test4_passed = send_request(base_url, block_action_payload, expected_status=403, debug=debug)
|
||||
results_data["Test 4 (Block action)"] = (test4_passed, block_response.status_code if block_response else "ERROR", "Expected 403 Forbidden for explicit block action")
|
||||
print(colored("-> Score doesn't matter for this test - blocking action should take precedence", "yellow"))
|
||||
|
||||
# Test 5: Incremental scoring in separate requests (should pass, 200 OK)
|
||||
print(colored("\nTest 5: Incremental scoring in separate requests (should be isolated per request, pass with 200 OK)", "magenta"))
|
||||
incremental_results_passed = []
|
||||
incremental_status_codes = []
|
||||
for i in range(1, 4): # Tests INCR-1 (1), INCR-2 (2), INCR-3 (3)
|
||||
print(colored(f"--- Request {i} of incremental test ---", "cyan"))
|
||||
incremental_payload = {'increment': f'score{i}'}
|
||||
expected_score = i
|
||||
incremental_response, inc_headers, single_inc_passed = send_request(base_url, incremental_payload, expected_status=200, debug=debug)
|
||||
incremental_results_passed.append(single_inc_passed if single_inc_passed is not None else False)
|
||||
incremental_status_codes.append(incremental_response.status_code if incremental_response else "ERROR")
|
||||
print(colored(f"-> Expected anomaly score contribution for this request: {expected_score}", "yellow"))
|
||||
if i < 3: time.sleep(0.2) # Shorter delay
|
||||
test5_passed = all(incremental_results_passed)
|
||||
status_summary = ', '.join(map(str, incremental_status_codes))
|
||||
results_data["Test 5 (Incremental)"] = (test5_passed, status_summary, f"Expected 200 OK for all incremental tests (scores {', '.join(map(str,range(1,4)))}) < threshold ({threshold})")
|
||||
|
||||
# --- NEW TESTS ---
|
||||
|
||||
# Test 6: Score hitting exact threshold (should block, 403 Forbidden)
|
||||
print(colored(f"\nTest 6: Score hitting exact threshold (should block with 403 Forbidden)", "magenta"))
|
||||
exact_threshold_payload = {'param1': 'score2', 'param2': 'score3'} # RULE-PARAM1 (2) + RULE-PARAM2-HIGH (3) = 5
|
||||
expected_total_score = 5
|
||||
exact_response, exact_headers, test6_passed = send_request(base_url, exact_threshold_payload, expected_status=403, debug=debug)
|
||||
results_data["Test 6 (Exact threshold)"] = (test6_passed, exact_response.status_code if exact_response else "ERROR", f"Expected 403 Forbidden for score ({expected_total_score}) == threshold ({threshold})")
|
||||
print(colored(f"-> Expected total anomaly score: {expected_total_score} (Threshold: {threshold})", "yellow"))
|
||||
|
||||
# Test 7: Mix High/Low score below threshold (should pass, 200 OK)
|
||||
print(colored(f"\nTest 7: Mix High/Low score below threshold (should pass with 200 OK)", "magenta"))
|
||||
mix_below_payload = {'test': 'low_score_test', 'param1': 'score3'} # RULE-1 (1) + RULE-PARAM1-HIGH (3) = 4
|
||||
expected_total_score = 4
|
||||
mix_below_response, mix_below_headers, test7_passed = send_request(base_url, mix_below_payload, expected_status=200, debug=debug)
|
||||
results_data["Test 7 (Mix Below Threshold)"] = (test7_passed, mix_below_response.status_code if mix_below_response else "ERROR", f"Expected 200 OK for mixed score ({expected_total_score}) < threshold ({threshold})")
|
||||
print(colored(f"-> Expected total anomaly score: {expected_total_score} (Threshold: {threshold})", "yellow"))
|
||||
|
||||
# Test 8: Score greatly exceeding threshold (with Param3) (should block, 403 Forbidden)
|
||||
print(colored(f"\nTest 8: Score greatly exceeding threshold (should block with 403 Forbidden)", "magenta"))
|
||||
exceed_greatly_payload = {'param1': 'score3', 'param2': 'score3', 'param3': 'score3'} # RULE-PARAM1-HIGH (3) + RULE-PARAM2-HIGH (3) + RULE-PARAM3-HIGH (3) = 9
|
||||
expected_total_score = 9
|
||||
exceed_greatly_response, exceed_greatly_headers, test8_passed = send_request(base_url, exceed_greatly_payload, expected_status=403, debug=debug)
|
||||
results_data["Test 8 (Exceed Greatly)"] = (test8_passed, exceed_greatly_response.status_code if exceed_greatly_response else "ERROR", f"Expected 403 Forbidden for score ({expected_total_score}) >= threshold ({threshold})")
|
||||
print(colored(f"-> Expected total anomaly score: {expected_total_score} (Threshold: {threshold})", "yellow"))
|
||||
|
||||
# Test 9: Block action triggered with other scoring rules (should block, 403 Forbidden)
|
||||
print(colored(f"\nTest 9: Block action priority (should block with 403 Forbidden)", "magenta"))
|
||||
block_priority_payload = {'block': 'true', 'param1': 'score2'} # RULE-BLOCK (block) + RULE-PARAM1 (2)
|
||||
expected_total_score = 2 # Score is calculated but block action takes precedence
|
||||
block_priority_response, block_priority_headers, test9_passed = send_request(base_url, block_priority_payload, expected_status=403, debug=debug)
|
||||
results_data["Test 9 (Block Priority)"] = (test9_passed, block_priority_response.status_code if block_priority_response else "ERROR", "Expected 403 Forbidden due to explicit block action, regardless of score")
|
||||
print(colored(f"-> Calculated anomaly score: {expected_total_score}. Block action should override.", "yellow"))
|
||||
|
||||
# Test 10: No matching rules (should pass, 200 OK)
|
||||
print(colored(f"\nTest 10: No matching rules (should pass with 200 OK)", "magenta"))
|
||||
no_match_payload = {'vanilla': 'test', 'unknown': 'data'}
|
||||
expected_total_score = 0
|
||||
no_match_response, no_match_headers, test10_passed = send_request(base_url, no_match_payload, expected_status=200, debug=debug)
|
||||
results_data["Test 10 (No Match)"] = (test10_passed, no_match_response.status_code if no_match_response else "ERROR", f"Expected 200 OK when no rules match (score {expected_total_score})")
|
||||
print(colored(f"-> Expected total anomaly score: {expected_total_score}", "yellow"))
|
||||
|
||||
# Test 11: Parameter name match, value mismatch (should pass, 200 OK)
|
||||
print(colored(f"\nTest 11: Parameter name match, value mismatch (should pass with 200 OK)", "magenta"))
|
||||
value_mismatch_payload = {'param1': 'non_matching_value', 'test': 'another_value'} # Neither value matches RULE-PARAM1 or RULE-1 patterns
|
||||
expected_total_score = 0
|
||||
value_mismatch_response, value_mismatch_headers, test11_passed = send_request(base_url, value_mismatch_payload, expected_status=200, debug=debug)
|
||||
results_data["Test 11 (Value Mismatch)"] = (test11_passed, value_mismatch_response.status_code if value_mismatch_response else "ERROR", f"Expected 200 OK when parameter values don't match rule patterns (score {expected_total_score})")
|
||||
print(colored(f"-> Expected total anomaly score: {expected_total_score}", "yellow"))
|
||||
|
||||
|
||||
# Summarize results
|
||||
print(colored("\n=== Anomaly Threshold Test Summary ===", "cyan"))
|
||||
print(colored(f"Target URL: {base_url}", "yellow"))
|
||||
print(colored(f"Configured threshold: {threshold}", "yellow"))
|
||||
|
||||
all_passed_flag = True
|
||||
# Define the order tests should appear in the summary
|
||||
test_order = [
|
||||
"Test 1 (Low score)",
|
||||
"Test 2 (Below threshold)",
|
||||
"Test 7 (Mix Below Threshold)", # New test inserted logically
|
||||
"Test 5 (Incremental)", # Incremental scores are below threshold
|
||||
"Test 10 (No Match)",
|
||||
"Test 11 (Value Mismatch)",
|
||||
"Test 6 (Exact threshold)", # Blocking test
|
||||
"Test 3 (Exceed threshold)", # Blocking test
|
||||
"Test 8 (Exceed Greatly)", # Blocking test
|
||||
"Test 4 (Block action)", # Blocking test
|
||||
"Test 9 (Block Priority)" # Blocking test
|
||||
]
|
||||
|
||||
print(colored("\n--- Test Results ---", "cyan"))
|
||||
for test_name in test_order:
|
||||
if test_name not in results_data:
|
||||
print(colored(f"{test_name}: SKIPPED (Data not found)", "yellow"))
|
||||
all_passed_flag = False # Consider missing data a failure
|
||||
continue
|
||||
|
||||
passed, status_code, description = results_data[test_name]
|
||||
# Treat None passed status as False for summary
|
||||
passed = passed if passed is not None else False
|
||||
result_text = "PASS" if passed else "FAIL"
|
||||
color = "green" if passed else "red"
|
||||
print(colored(f"{test_name}: {result_text} (Status: {status_code})", color))
|
||||
|
||||
if not passed:
|
||||
all_passed_flag = False
|
||||
print(colored(f" Reason: {description}", "yellow"))
|
||||
elif verbose:
|
||||
print(colored(f" Details: {description} (Status: {status_code})", "yellow"))
|
||||
|
||||
|
||||
# Final Pass/Fail Summary
|
||||
print(colored("\n--- Overall Result ---", "cyan"))
|
||||
if all_passed_flag:
|
||||
print(colored("✓ All tests passed! Anomaly threshold and blocking logic appear to be working correctly based on expected status codes.", "green"))
|
||||
else:
|
||||
print(colored("✗ Some tests failed. Review the output above.", "red"))
|
||||
failed_tests = [name for name in test_order if name in results_data and not results_data[name][0]]
|
||||
print(colored(f"Failed tests: {', '.join(failed_tests)}", "red"))
|
||||
|
||||
# Provide troubleshooting tips based on failure patterns
|
||||
test3_failed = "Test 3 (Exceed threshold)" in failed_tests
|
||||
test4_failed = "Test 4 (Block action)" in failed_tests
|
||||
test6_failed = "Test 6 (Exact threshold)" in failed_tests
|
||||
test8_failed = "Test 8 (Exceed Greatly)" in failed_tests
|
||||
test9_failed = "Test 9 (Block Priority)" in failed_tests
|
||||
blocking_tests_failed = test3_failed or test4_failed or test6_failed or test8_failed or test9_failed
|
||||
|
||||
if blocking_tests_failed:
|
||||
print(colored("\nSuggestion: One or more blocking tests failed (expected 403).", "yellow"))
|
||||
if test6_failed : print(colored(" - Check if the WAF blocks exactly *at* the threshold score.", "yellow"))
|
||||
if test3_failed or test8_failed: print(colored(f" - Verify rules correctly contribute scores and the threshold ({threshold}) is enforced.", "yellow"))
|
||||
if test4_failed or test9_failed: print(colored(" - Ensure rules with 'block' action are correctly configured and take priority.", "yellow"))
|
||||
|
||||
if "Test 5 (Incremental)" in failed_tests:
|
||||
print(colored("\nSuggestion: One or more incremental tests failed (expected 200). This might indicate score accumulation across requests (incorrect) or unrelated blocking rules triggered.", "yellow"))
|
||||
if "Test 10 (No Match)" in failed_tests or "Test 11 (Value Mismatch)" in failed_tests :
|
||||
print(colored("\nSuggestion: Tests expecting no match failed (expected 200). Check for overly broad rules or default blocking actions.", "yellow"))
|
||||
|
||||
|
||||
# --- check_server function remains the same ---
|
||||
def check_server(url):
|
||||
"""Check if the server is reachable."""
|
||||
print(f"\nChecking server reachability at {url}...")
|
||||
try:
|
||||
# Use HEAD request for efficiency, or GET if HEAD is disallowed/problematic
|
||||
response = requests.head(url, timeout=3)
|
||||
# Allow any success or redirect status code as "reachable"
|
||||
if 200 <= response.status_code < 400:
|
||||
print(colored(f"Server is reachable (Status: {response.status_code}).", "green"))
|
||||
return True
|
||||
else:
|
||||
# Handle client/server errors differently
|
||||
if 400 <= response.status_code < 500:
|
||||
print(colored(f"Server responded with client error: {response.status_code}. Check URL path/config.", "yellow"))
|
||||
elif 500 <= response.status_code < 600:
|
||||
print(colored(f"Server responded with server error: {response.status_code}. Check server/WAF logs.", "red"))
|
||||
else:
|
||||
print(colored(f"Server responded with unexpected status: {response.status_code}.", "yellow"))
|
||||
return False # Treat non-success/redirect as potentially problematic
|
||||
except requests.exceptions.Timeout:
|
||||
print(colored(f"ERROR: Connection to {url} timed out.", "red"))
|
||||
print(colored("Check if the server/proxy is running and accessible.", "yellow"))
|
||||
return False
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(colored(f"ERROR: Cannot connect to server at {url}", "red"))
|
||||
print(colored("Make sure the server/proxy (e.g., Caddy) is running and the URL is correct.", "yellow"))
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(colored(f"ERROR: An unexpected network error occurred: {str(e)}", "red"))
|
||||
return False
|
||||
|
||||
# --- main function is UPDATED (info section) ---
|
||||
def main():
|
||||
args = setup_args()
|
||||
base_url = args.url.rstrip('/') # Remove trailing slash if present
|
||||
threshold = args.threshold
|
||||
debug = args.debug
|
||||
verbose = args.verbose
|
||||
|
||||
print(colored(f"WAF Anomaly Threshold Test Tool", "cyan", attrs=["bold"]))
|
||||
print(colored("-" * 30, "cyan"))
|
||||
print(f"Target URL: {base_url}")
|
||||
print(f"Expected Threshold: {threshold}")
|
||||
print(f"Debug Mode: {'ON' if debug else 'OFF'}")
|
||||
print(f"Verbose Mode: {'ON' if verbose else 'OFF'}")
|
||||
print(colored("-" * 30, "cyan"))
|
||||
|
||||
# UPDATED Test rule setup recommendations
|
||||
print(colored("\nINFO: This script assumes specific WAF rules are configured:", "yellow"))
|
||||
print(colored(" - Rule(s) matching 'test=low_score_test' contribute score=1.", "yellow"))
|
||||
print(colored(" - Rule(s) matching 'param1=score2' contribute score=2.", "yellow"))
|
||||
print(colored(" - Rule(s) matching 'param2=score2' contribute score=2.", "yellow"))
|
||||
print(colored(" - Rule(s) matching 'param1=score3' contribute score=3.", "yellow"))
|
||||
print(colored(" - Rule(s) matching 'param2=score3' contribute score=3.", "yellow"))
|
||||
print(colored(" - Rule(s) matching 'param3=score3' contribute score=3. (Used in Test 8)", "yellow")) # Added param3 rule info
|
||||
print(colored(" - Rule matching 'block=true' has an explicit 'block' action.", "yellow"))
|
||||
print(colored(" - Rule(s) matching 'increment=scoreX' contribute score=X (e.g., 'increment=score1' adds 1).", "yellow"))
|
||||
|
||||
if not check_server(base_url):
|
||||
sys.exit(1)
|
||||
|
||||
test_anomaly_threshold(base_url, threshold, debug, verbose)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
7
types.go
7
types.go
@@ -120,7 +120,7 @@ type Rule struct {
|
||||
Targets []string `json:"targets"`
|
||||
Severity string `json:"severity"` // Used for logging only
|
||||
Score int `json:"score"`
|
||||
Action string `json:"mode"` // Determines the action (block/log)
|
||||
Action string `json:"mode"` // CRITICAL FIX: This should map to the "mode" field in JSON
|
||||
Description string `json:"description"`
|
||||
regex *regexp.Regexp
|
||||
Priority int // New field for rule priority
|
||||
@@ -298,6 +298,11 @@ func (t *CIDRTrie) containsIPv6(ip net.IP) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Add this check to ensure ip is not empty
|
||||
if len(ip) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
node := t.ipv6Root
|
||||
for i := 0; i < len(ip)*8; i++ {
|
||||
bit := (ip[i/8] >> (7 - uint(i%8))) & 1
|
||||
|
||||
Reference in New Issue
Block a user