Add comprehensive anti-scan and brute force protection
All checks were successful
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 54s

Implement multi-layered security system to protect against exploit
scanning and brute force attacks while maintaining legitimate traffic flow.

Security Features:
- Attack detection for common exploit paths (WordPress, phpMyAdmin, shells)
- Malicious user agent filtering (sqlmap, nikto, metasploit, etc.)
- SQL injection and directory traversal pattern detection
- Progressive rate limiting (50 req/10s, 20 conn/10s, 10 err/10s)
- Three-tier response: tarpit → deny → repeat offender blocking
- Strict authentication endpoint protection (5 req/10s limit)
- Real IP detection through proxy headers (Cloudflare, X-Real-IP)

Management Tools:
- manage-blocked-ips.sh: Dynamic IP blocking/unblocking
- monitor-attacks.sh: Real-time threat monitoring
- API endpoints for security stats and temporary blocking
- Auto-expiring temporary blocks with cleanup endpoint

HAProxy 2.6 Compatibility:
- Removed silent-drop (not available in 2.6)
- Fixed stick table counter syntax
- Using standard tarpit and deny actions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-22 16:50:35 -07:00
parent 002e79b565
commit e2f350ce95
4 changed files with 388 additions and 12 deletions

View File

@@ -8,8 +8,9 @@ import socket
import psutil
import functools
import logging
from datetime import datetime
from datetime import datetime, timedelta
import json
import ipaddress
import shutil
import tempfile
@@ -119,6 +120,14 @@ def init_db():
''')
conn.commit()
def validate_ip_address(ip_string):
"""Validate if a string is a valid IP address"""
try:
ipaddress.ip_address(ip_string)
return True
except ValueError:
return False
def certbot_register():
"""Register with Let's Encrypt using the certbot client and agree to the terms of service"""
result = subprocess.run(['certbot', 'show_account'], capture_output=True)
@@ -894,6 +903,166 @@ def sync_blocked_ips():
log_operation('sync_blocked_ips', False, str(e))
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/api/security/stats', methods=['GET'])
@require_api_key
def get_security_stats():
"""Get current security statistics from HAProxy stick table"""
try:
if os.path.exists(HAPROXY_SOCKET_PATH):
socket_path = HAPROXY_SOCKET_PATH
else:
socket_path = '/tmp/haproxy-cli'
# Get stick table data
cmd = f'echo "show table web" | socat stdio {socket_path}'
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode != 0:
return jsonify({'status': 'error', 'message': 'Failed to get stick table data'}), 500
# Parse stick table output
lines = result.stdout.strip().split('\n')
threats = []
for line in lines[1:]: # Skip header
parts = line.split()
if len(parts) >= 8:
ip = parts[0]
try:
gpc0 = int(parts[3]) if len(parts) > 3 else 0
gpc1 = int(parts[4]) if len(parts) > 4 else 0
req_rate = int(parts[5]) if len(parts) > 5 else 0
err_rate = int(parts[6]) if len(parts) > 6 else 0
conn_rate = int(parts[7]) if len(parts) > 7 else 0
# Only include IPs with significant activity
if gpc0 > 0 or gpc1 > 0 or req_rate > 30 or err_rate > 5 or conn_rate > 10:
threat_level = 'low'
if gpc1 > 2:
threat_level = 'critical'
elif gpc0 > 0 or err_rate > 10:
threat_level = 'high'
elif req_rate > 40 or conn_rate > 15:
threat_level = 'medium'
threats.append({
'ip': ip,
'blocked': gpc0 > 0,
'repeat_offender': gpc1 > 2,
'offense_count': gpc1,
'request_rate': req_rate,
'error_rate': err_rate,
'connection_rate': conn_rate,
'threat_level': threat_level
})
except (ValueError, IndexError):
continue
# Sort by threat level
threats.sort(key=lambda x: (x['offense_count'], x['error_rate'], x['request_rate']), reverse=True)
return jsonify({
'status': 'success',
'total_tracked_ips': len(lines) - 1,
'active_threats': len(threats),
'threats': threats[:50] # Limit to top 50
})
except Exception as e:
log_operation('get_security_stats', False, str(e))
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/api/security/temporary-block', methods=['POST'])
@require_api_key
def temporary_block():
"""Temporarily block an IP address (auto-unblocks after specified time)"""
data = request.get_json()
ip_address = data.get('ip_address')
duration_minutes = data.get('duration_minutes', 60) # Default 1 hour
if not ip_address:
return jsonify({'status': 'error', 'message': 'IP address is required'}), 400
if not validate_ip_address(ip_address):
return jsonify({'status': 'error', 'message': 'Invalid IP address format'}), 400
try:
# Add to blocked IPs with expiration time
expiry_time = datetime.now() + timedelta(minutes=duration_minutes)
with sqlite3.connect(DB_FILE) as conn:
cursor = conn.cursor()
# Check if table has expiry column, add if not
cursor.execute("PRAGMA table_info(blocked_ips)")
columns = [column[1] for column in cursor.fetchall()]
if 'expiry_time' not in columns:
cursor.execute('ALTER TABLE blocked_ips ADD COLUMN expiry_time TEXT')
# Add or update the blocked IP with expiry
cursor.execute('''
INSERT OR REPLACE INTO blocked_ips (ip_address, reason, expiry_time)
VALUES (?, ?, ?)
''', (ip_address, f'Temporary block for {duration_minutes} minutes', expiry_time.isoformat()))
# Update map file and add to runtime
if not update_blocked_ips_map():
return jsonify({'status': 'error', 'message': 'Failed to update map file'}), 500
add_ip_to_runtime_map(ip_address)
log_operation('temporary_block', True, f'Temporarily blocked {ip_address} for {duration_minutes} minutes')
return jsonify({
'status': 'success',
'message': f'IP {ip_address} temporarily blocked for {duration_minutes} minutes',
'expires_at': expiry_time.isoformat()
})
except Exception as e:
log_operation('temporary_block', False, str(e))
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/api/security/clear-expired', methods=['POST'])
@require_api_key
def clear_expired_blocks():
"""Remove expired temporary IP blocks"""
try:
current_time = datetime.now()
expired_ips = []
with sqlite3.connect(DB_FILE) as conn:
cursor = conn.cursor()
# Check if expiry_time column exists
cursor.execute("PRAGMA table_info(blocked_ips)")
columns = [column[1] for column in cursor.fetchall()]
if 'expiry_time' in columns:
# Find and remove expired blocks
cursor.execute('''
SELECT ip_address FROM blocked_ips
WHERE expiry_time IS NOT NULL AND expiry_time < ?
''', (current_time.isoformat(),))
expired_ips = [row[0] for row in cursor.fetchall()]
# Remove expired IPs
for ip in expired_ips:
cursor.execute('DELETE FROM blocked_ips WHERE ip_address = ?', (ip,))
remove_ip_from_runtime_map(ip)
# Update map file if any IPs were removed
if expired_ips:
update_blocked_ips_map()
log_operation('clear_expired_blocks', True, f'Cleared {len(expired_ips)} expired IP blocks')
return jsonify({
'status': 'success',
'message': f'Cleared {len(expired_ips)} expired IP blocks',
'cleared_ips': expired_ips
})
except Exception as e:
log_operation('clear_expired_blocks', False, str(e))
return jsonify({'status': 'error', 'message': str(e)}), 500
def generate_config():
try:
conn = sqlite3.connect(DB_FILE)

68
scripts/manage-blocked-ips.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
# HAProxy IP blocking management script
# Usage: ./manage-blocked-ips.sh [block|unblock|list|clear] [IP_ADDRESS]
SOCKET="/tmp/haproxy-cli"
MAP_FILE="/etc/haproxy/blocked_ips.map"
# Ensure map file exists
if [ ! -f "$MAP_FILE" ]; then
touch "$MAP_FILE"
echo "# Blocked IPs - Format: IP_ADDRESS" > "$MAP_FILE"
fi
case "$1" in
block)
if [ -z "$2" ]; then
echo "Usage: $0 block IP_ADDRESS"
exit 1
fi
# Add IP to map file
grep -q "^$2" "$MAP_FILE" || echo "$2" >> "$MAP_FILE"
# Add to runtime map
echo "add map /etc/haproxy/blocked_ips.map $2 1" | socat stdio "$SOCKET"
echo "Blocked IP: $2"
;;
unblock)
if [ -z "$2" ]; then
echo "Usage: $0 unblock IP_ADDRESS"
exit 1
fi
# Remove from map file
sed -i "/^$2$/d" "$MAP_FILE"
# Remove from runtime map
echo "del map /etc/haproxy/blocked_ips.map $2" | socat stdio "$SOCKET"
echo "Unblocked IP: $2"
;;
list)
echo "Currently blocked IPs:"
echo "show map /etc/haproxy/blocked_ips.map" | socat stdio "$SOCKET" | awk '{print $1}'
;;
clear)
echo "Clearing all blocked IPs..."
echo "clear map /etc/haproxy/blocked_ips.map" | socat stdio "$SOCKET"
echo "# Blocked IPs - Format: IP_ADDRESS" > "$MAP_FILE"
echo "All IPs unblocked"
;;
stats)
echo "Stick table statistics (showing potential bad actors):"
echo "show table web" | socat stdio "$SOCKET" | head -50
;;
*)
echo "Usage: $0 {block|unblock|list|clear|stats} [IP_ADDRESS]"
echo ""
echo "Commands:"
echo " block IP - Block an IP address"
echo " unblock IP - Unblock an IP address"
echo " list - List all blocked IPs"
echo " clear - Clear all blocked IPs"
echo " stats - Show current stick table stats"
exit 1
;;
esac

73
scripts/monitor-attacks.sh Executable file
View File

@@ -0,0 +1,73 @@
#!/bin/bash
# Real-time attack monitoring for HAProxy
# Shows blocked requests and suspicious activity
LOG_FILE="/var/log/haproxy.log"
SOCKET="/tmp/haproxy-cli"
echo "==================================================="
echo "HAProxy Security Monitor - Real-time Attack Detection"
echo "==================================================="
echo ""
# Function to show current threats
show_threats() {
echo "Current Threat IPs (from stick table):"
echo "show table web" | socat stdio "$SOCKET" 2>/dev/null | \
awk '$4 > 0 || $5 > 0 || $6 > 30 || $7 > 5 || $8 > 10 {
printf "%-15s req_rate:%-3s err_rate:%-3s conn_rate:%-3s blocked:%s repeat:%s\n",
$1, $6, $7, $8, $4, $5
}' | head -20
echo "---------------------------------------------------"
}
# Function to show recent blocks
show_recent_blocks() {
echo "Recent Blocked Requests:"
tail -100 "$LOG_FILE" 2>/dev/null | \
grep -E "(scanner|exploit|ratelimit|repeat|tarpit|denied|dropped)" | \
tail -10 | \
awk '{
if (match($0, /[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+/)) {
ip = substr($0, RSTART, RLENGTH)
gsub(/:.*/, "", ip)
reason = ""
if ($0 ~ /scanner/) reason = "SCANNER"
else if ($0 ~ /exploit/) reason = "EXPLOIT"
else if ($0 ~ /ratelimit/) reason = "RATE_LIMIT"
else if ($0 ~ /repeat/) reason = "REPEAT_OFFENDER"
else if ($0 ~ /tarpit/) reason = "TARPIT"
else if ($0 ~ /denied/) reason = "DENIED"
else if ($0 ~ /dropped/) reason = "DROPPED"
printf "[%s] %-15s %s\n", strftime("%H:%M:%S"), ip, reason
}
}'
echo ""
}
# Monitor mode selection
if [ "$1" == "live" ]; then
echo "Live monitoring mode - Press Ctrl+C to exit"
echo ""
while true; do
clear
echo "==================================================="
echo "HAProxy Security Monitor - $(date '+%Y-%m-%d %H:%M:%S')"
echo "==================================================="
echo ""
show_threats
echo ""
show_recent_blocks
sleep 5
done
else
# Single run mode
show_threats
echo ""
show_recent_blocks
echo ""
echo "Tip: Run with 'live' parameter for continuous monitoring"
echo "Usage: $0 [live]"
fi

View File

@@ -4,20 +4,39 @@ frontend web
# crt can now be a path, so it will load all .pem files in the path
bind 0.0.0.0:443 ssl crt {{ crt_path }} alpn h2,http/1.1
# Stick table kept for potential future use or custom implementations
# Can be used by containers to track IPs if needed
stick-table type ip size 200k expire 1h store gpc0,gpc1,http_err_rate(10s)
# Stick tables for tracking and rate limiting
# Main tracking table: stores request rates, error rates, and abuse counters
stick-table type ip size 200k expire 30m store gpc0,gpc1,http_req_rate(10s),http_err_rate(10s),conn_rate(10s)
# Whitelist trusted networks and monitoring systems
acl trusted_networks src 127.0.0.1 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12
acl health_check path_beg /health /ping /status /.well-known/
acl common_missing path /favicon.ico /robots.txt /sitemap.xml /apple-touch-icon.png
# Allow trusted traffic to bypass all protection
http-request allow if trusted_networks or health_check
# Don't count common missing files against the error count
http-request return status 404 if common_missing
# ============================================
# SECURITY: Anti-Scan and Brute Force Protection
# ============================================
# 1. Detect common exploit scan patterns
acl scan_wordpress path_beg /wp-admin /wp-login /xmlrpc.php /wp-content/uploads/ /wp-includes/
acl scan_admin path_beg /admin /administrator /phpmyadmin /pma /mysql /cpanel /panel
acl scan_exploits path_end .sql .bak .backup .zip .tar.gz .rar .old .orig .save .swp .env .git .svn .DS_Store
acl scan_shells path_beg /shell.php /c99.php /r57.php /wso.php /alfa.php /eval.php /cmd.php
acl scan_dotfiles path_beg /. /.env /.git /.svn /.htaccess /.htpasswd /.ssh /.aws
acl scan_paths path_beg /cgi-bin /scripts /fckeditor /ckfinder /userfiles /console /api/v1/auth/login
# 2. Detect malicious user agents
acl bot_scanner hdr_sub(user-agent) -i sqlmap nikto nmap masscan zmap dirbuster gobuster wpscan joomscan acunetix nessus openvas metasploit burp zgrab
acl bot_generic hdr_sub(user-agent) -i bot crawler spider scraper scan probe
acl bot_empty hdr_len(user-agent) eq 0
# 3. Detect suspicious request patterns
acl suspicious_method method TRACE TRACK OPTIONS CONNECT
acl has_sql_chars url_sub -i select union insert update delete drop create alter exec script
acl has_traversal url_sub ../ ..\\ %2e%2e %252e
acl excessive_params url_len gt 2000
# Detect real client IP from proxy headers if they exist
# Priority: CF-Connecting-IP (Cloudflare) > X-Real-IP > X-Forwarded-For > src
@@ -31,10 +50,57 @@ frontend web
http-request set-var(txn.real_ip) req.hdr(X-Forwarded-For) if !has_cf_connecting_ip !has_x_real_ip has_x_forwarded_for
http-request set-var(txn.real_ip) src if !has_cf_connecting_ip !has_x_real_ip !has_x_forwarded_for
# Track the real client IP in stick table (not the proxy IP)
# Kept for potential container-level tracking integrations
# Track the real client IP in stick table for rate limiting
http-request track-sc0 var(txn.real_ip)
# ============================================
# APPLY SECURITY RULES
# ============================================
# 4. Rate limiting - Check if IP is exceeding limits
acl rate_abuse sc0_http_req_rate gt 50
acl conn_abuse sc0_conn_rate gt 20
acl error_abuse sc0_http_err_rate gt 10
acl marked_bad sc0_get_gpc0 gt 0
acl repeat_offender sc0_get_gpc1 gt 2
# 5. Mark bad actors in stick table
# gpc0: Current bad actor flag (0=good, 1=bad)
# gpc1: Offense counter (increments each time marked bad)
http-request sc-set-gpc0(0) 1 if scan_wordpress or scan_admin or scan_exploits or scan_shells or scan_dotfiles
http-request sc-set-gpc0(0) 1 if bot_scanner or suspicious_method or has_sql_chars or has_traversal
http-request sc-set-gpc0(0) 1 if rate_abuse or conn_abuse or error_abuse
http-request sc-inc-gpc1(0) 1 if marked_bad !repeat_offender
# 6. Progressive response based on threat level
# Level 1: Deny with tarpit for suspicious scanners (uses tarpit timeout from defaults)
http-request tarpit if scan_wordpress or scan_admin or scan_shells or bot_scanner
http-request tarpit if suspicious_method or has_sql_chars or has_traversal
# Level 2: Deny for rate abusers and marked bad actors
http-request deny if marked_bad
http-request deny if rate_abuse or conn_abuse or error_abuse
# Level 3: Reject repeat offenders completely
http-request deny if repeat_offender
# 7. Additional protections for login/auth endpoints
acl is_login path_end /login /signin /auth /authenticate
acl is_api_auth path_beg /api/login /api/auth /api/v1/auth /api/v2/auth
# Strict rate limit for authentication endpoints (max 5 requests per 10s)
acl auth_abuse sc0_http_req_rate gt 5
http-request deny if is_login auth_abuse
http-request deny if is_api_auth auth_abuse
# 8. Log security events for monitoring
http-request capture var(txn.real_ip) len 40
http-request capture req.hdr(user-agent) len 150
http-request set-var(txn.blocked) str(scanner) if bot_scanner
http-request set-var(txn.blocked) str(exploit) if scan_exploits or scan_shells
http-request set-var(txn.blocked) str(ratelimit) if rate_abuse
http-request set-var(txn.blocked) str(repeat) if repeat_offender
# IP blocking using map file (no word limit, runtime updates supported)
# Map file: /etc/haproxy/blocked_ips.map
# Runtime updates: echo "add map #0 IP_ADDRESS" | socat stdio /var/run/haproxy.sock