12 Commits

Author SHA1 Message Date
fabriziosalmi
e4c88a3956 Add installation script for Caddy with WAF and module support 2025-04-30 11:25:56 +02:00
fabriziosalmi
fe84fbb5c5 Add debugging tools for WAF configuration and anomaly threshold testing
- Implemented debug_test_results.py to evaluate WAF test results with detailed request/response logging.
- Created debug_waf.go for logging request details and dumping WAF rules to a file.
- Developed debug_waf.py to extract WAF configuration from Caddy Admin API and test WAF rules with sample requests.
- Added sample_rules.json containing test rules for WAF evaluation.
- Configured test.caddyfile for local testing of WAF with defined rules and logging.
- Enhanced test_anomalythreshold.py to validate anomaly threshold behavior with comprehensive test cases and detailed output.
2025-04-30 11:19:17 +02:00
fabriziosalmi
533020d5e6 Update WAF configuration and add browser-friendly rules
- Increased anomaly threshold to reduce false positives.
- Added new rules for browser integrity checks and logging.
- Improved SQL injection and XSS rules to prevent false positives.
- Introduced a new rules file for browser-friendly traffic handling.
2025-04-30 08:57:22 +02:00
fab
bf367b5c53 Merge pull request #53 from fabriziosalmi/dependabot/go_modules/go_modules-c153b83258
Bump golang.org/x/net from 0.33.0 to 0.36.0 in the go_modules group across 1 directory
2025-03-13 11:09:29 +01:00
dependabot[bot]
1989037c29 Bump golang.org/x/net in the go_modules group across 1 directory
Bumps the go_modules group with 1 update in the / directory: [golang.org/x/net](https://github.com/golang/net).


Updates `golang.org/x/net` from 0.33.0 to 0.36.0
- [Commits](https://github.com/golang/net/compare/v0.33.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-13 01:33:48 +00:00
fab
36464a222a Merge pull request #51 from fabriziosalmi/dependabot/go_modules/go_modules-e2b1dc0a79
Bump github.com/go-jose/go-jose/v3 from 3.0.3 to 3.0.4 in the go_modules group across 1 directory
2025-02-28 11:42:46 +01:00
dependabot[bot]
5c266d1665 Bump github.com/go-jose/go-jose/v3
Bumps the go_modules group with 1 update in the / directory: [github.com/go-jose/go-jose/v3](https://github.com/go-jose/go-jose).


Updates `github.com/go-jose/go-jose/v3` from 3.0.3 to 3.0.4
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Changelog](https://github.com/go-jose/go-jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-jose/go-jose/compare/v3.0.3...v3.0.4)

---
updated-dependencies:
- dependency-name: github.com/go-jose/go-jose/v3
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-26 22:21:08 +00:00
fab
cd959a2712 Update README.md
Rules generator powered by ChatGPT4 added: https://chatgpt.com/g/g-677d07dd07e48191b799b9e5d6da7828-caddy-waf-ruler
2025-02-25 21:39:07 +01:00
fab
a3fcc3a3f8 Update README.md 2025-02-23 22:31:35 +01:00
fabriziosalmi
1cbd739f7c minor imrpovements 2025-02-09 17:50:59 +01:00
fabriziosalmi
75d217f736 dockerfile glitch 2025-02-09 17:44:00 +01:00
fab
4f31673cb5 Update release.yml 2025-02-09 16:32:45 +01:00
23 changed files with 1983 additions and 121 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 :)_
![demo](https://github.com/fabriziosalmi/caddy-waf/blob/main/docs/caddy-waf-ui.png?raw=true)
@@ -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.

View File

@@ -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
View 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&param2=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&param2=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&param2=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&param2=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
View 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
View 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
View 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
View File

@@ -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
View File

@@ -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=

View File

@@ -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
View 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"

View File

@@ -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

View File

@@ -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")

View File

@@ -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
View 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."
}
]

View File

@@ -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
}

View File

@@ -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
View 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
View 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
View 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()

View File

@@ -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