a1a17e81a1
Includes updated JS challenge scripts with Claude-User whitelist, same-site referer bypass, Blackbox-Exporter allowed bot, and all new exporters, cheat sheets, and automation scripts.
487 lines
19 KiB
Bash
487 lines
19 KiB
Bash
#!/usr/bin/env bash
|
|
|
|
#########################################################################################
|
|
#### nginx-security-hardening.sh — Add rate limiting, bot honeypot, security ####
|
|
#### headers, query string filtering, and hotlink protection to nginx ####
|
|
#### ####
|
|
#### Supports standalone nginx (default) and HestiaCP/VestaCP ####
|
|
#### ####
|
|
#### Author: Phil Connor ####
|
|
#### Contact: contact@mylinux.work ####
|
|
#### License: MIT ####
|
|
#### Version 2.04 ####
|
|
#### ####
|
|
#### Usage: ####
|
|
#### sudo ./nginx-security-hardening.sh --domain example.com ####
|
|
#### sudo ./nginx-security-hardening.sh --domain example.com --dry-run ####
|
|
#### sudo ./nginx-security-hardening.sh --domain example.com --user admin ####
|
|
#### sudo ./nginx-security-hardening.sh --domain example.com --skip honeypot ####
|
|
#### ####
|
|
#### See --help for all options. ####
|
|
#########################################################################################
|
|
# v2.03 changes:
|
|
# - Fixed: grep in pipeline crashes under set -euo pipefail when no matches found. Added || true guard
|
|
#########################################################################################
|
|
|
|
set -euo pipefail
|
|
|
|
# ── Defaults ──────────────────────────────────────────────────────────
|
|
DOMAIN=""
|
|
USER=""
|
|
DRY_RUN=false
|
|
SKIP=()
|
|
RATE="5r/m"
|
|
BURST=10
|
|
BANTIME=86400
|
|
TRAP_PATH="/trap"
|
|
DOWNLOAD_PATH="/downloads/"
|
|
BACKUP_EXT=".bak.$(date +%Y%m%d%H%M%S)"
|
|
|
|
# ── Colors ────────────────────────────────────────────────────────────
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
CYAN='\033[0;36m'
|
|
NC='\033[0m'
|
|
|
|
# ── Usage ─────────────────────────────────────────────────────────────
|
|
SCRIPT_NAME="$(basename "$0")"
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
${SCRIPT_NAME} — Nginx security hardening (standalone or HestiaCP/VestaCP)
|
|
|
|
USAGE:
|
|
sudo ${SCRIPT_NAME} --domain DOMAIN [OPTIONS]
|
|
|
|
OPTIONS:
|
|
--domain DOMAIN Domain name (required)
|
|
--user USER HestiaCP/VestaCP username (omit for standalone nginx)
|
|
--skip FEATURES Comma-separated: rate-limit, honeypot, headers, query-filter, hotlink-protection
|
|
--rate RATE Rate limit (default: 5r/m)
|
|
--burst NUM Rate limit burst (default: 10)
|
|
--bantime SECS Honeypot ban duration (default: 86400)
|
|
--trap-path PATH Honeypot path (default: /trap)
|
|
--download-path PATH Rate-limited path (default: /downloads/)
|
|
--dry-run Preview changes without writing files
|
|
--help Show this help
|
|
|
|
EXAMPLES:
|
|
${SCRIPT_NAME} --domain example.com
|
|
${SCRIPT_NAME} --domain example.com --skip honeypot
|
|
${SCRIPT_NAME} --domain example.com --user admin
|
|
${SCRIPT_NAME} --domain example.com --dry-run
|
|
EOF
|
|
exit 1
|
|
}
|
|
|
|
# ── Logging ───────────────────────────────────────────────────────────
|
|
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
|
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
|
log_dry() { echo -e "${CYAN}[DRY-RUN]${NC} $*"; }
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────
|
|
should_skip() {
|
|
local feature="$1"
|
|
for skip in "${SKIP[@]+"${SKIP[@]}"}"; do
|
|
[[ "$skip" == "$feature" ]] && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
backup_file() {
|
|
local file="$1"
|
|
[[ -f "$file" ]] || return 0
|
|
local backup_dir="$(dirname "$file")/.backups"
|
|
local backup_dest="${backup_dir}/$(basename "$file")${BACKUP_EXT}"
|
|
if $DRY_RUN; then
|
|
log_dry "Would back up $file → ${backup_dest}"
|
|
else
|
|
mkdir -p "$backup_dir"
|
|
cp "$file" "$backup_dest"
|
|
log_info "Backed up $file"
|
|
fi
|
|
}
|
|
|
|
write_file() {
|
|
local file="$1"
|
|
local content="$2"
|
|
local desc="$3"
|
|
|
|
if $DRY_RUN; then
|
|
log_dry "Would create $file"
|
|
echo -e "${CYAN}--- $desc ---${NC}"
|
|
echo "$content"
|
|
echo ""
|
|
else
|
|
mkdir -p "$(dirname "$file")"
|
|
backup_file "$file"
|
|
echo "$content" > "$file"
|
|
log_info "Created $file"
|
|
fi
|
|
}
|
|
|
|
is_hestia_mode() { [[ -n "$USER" ]]; }
|
|
|
|
detect_backend() {
|
|
local conf_dir="/home/${USER}/conf/web/${DOMAIN}"
|
|
local http_backend=""
|
|
local https_backend=""
|
|
|
|
if [[ -f "${conf_dir}/nginx.conf" ]]; then
|
|
http_backend=$({ grep -m1 'proxy_pass' "${conf_dir}/nginx.conf" || true; } | awk '{print $2}' | tr -d ';')
|
|
fi
|
|
if [[ -f "${conf_dir}/nginx.ssl.conf" ]]; then
|
|
https_backend=$({ grep -m1 'proxy_pass' "${conf_dir}/nginx.ssl.conf" || true; } | awk '{print $2}' | tr -d ';')
|
|
fi
|
|
|
|
if [[ -z "$http_backend" || -z "$https_backend" ]]; then
|
|
log_error "Could not detect backend proxy_pass from ${conf_dir}/nginx.conf"
|
|
log_error "Check that the domain exists in HestiaCP/VestaCP"
|
|
exit 1
|
|
fi
|
|
|
|
echo "${http_backend}|${https_backend}"
|
|
}
|
|
|
|
# ── Parse Arguments ───────────────────────────────────────────────────
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--domain) DOMAIN="$2"; shift 2 ;;
|
|
--user) USER="$2"; shift 2 ;;
|
|
--dry-run) DRY_RUN=true; shift ;;
|
|
--skip) IFS=',' read -ra SKIP <<< "$2"; shift 2 ;;
|
|
--rate) RATE="$2"; shift 2 ;;
|
|
--burst) BURST="$2"; shift 2 ;;
|
|
--bantime) BANTIME="$2"; shift 2 ;;
|
|
--trap-path) TRAP_PATH="$2"; shift 2 ;;
|
|
--download-path) DOWNLOAD_PATH="$2"; shift 2 ;;
|
|
-h|--help) usage ;;
|
|
*) log_error "Unknown option: $1"; usage ;;
|
|
esac
|
|
done
|
|
|
|
# ── Validate ──────────────────────────────────────────────────────────
|
|
if [[ -z "$DOMAIN" ]]; then
|
|
log_error "Missing required --domain"
|
|
usage
|
|
fi
|
|
|
|
if [[ "$EUID" -ne 0 && "$DRY_RUN" == false ]]; then
|
|
log_error "Must run as root (or use --dry-run to preview)"
|
|
exit 1
|
|
fi
|
|
|
|
# ── Detect Mode ───────────────────────────────────────────────────────
|
|
if is_hestia_mode; then
|
|
CONF_DIR="/home/${USER}/conf/web/${DOMAIN}"
|
|
if [[ ! -d "$CONF_DIR" ]]; then
|
|
log_error "Directory not found: ${CONF_DIR}"
|
|
log_error "Check your --user and --domain values"
|
|
exit 1
|
|
fi
|
|
log_info "Mode: HestiaCP/VestaCP (user: ${USER}, domain: ${DOMAIN})"
|
|
|
|
BACKENDS=$(detect_backend)
|
|
HTTP_BACKEND=$(echo "$BACKENDS" | cut -d'|' -f1)
|
|
HTTPS_BACKEND=$(echo "$BACKENDS" | cut -d'|' -f2)
|
|
log_info "Detected HTTP backend: ${HTTP_BACKEND}"
|
|
log_info "Detected HTTPS backend: ${HTTPS_BACKEND}"
|
|
|
|
HTTPS_IS_SSL=false
|
|
if [[ "$HTTPS_BACKEND" == https://* ]]; then
|
|
HTTPS_IS_SSL=true
|
|
fi
|
|
else
|
|
CONF_DIR="/etc/nginx"
|
|
log_info "Mode: standalone nginx"
|
|
fi
|
|
|
|
echo ""
|
|
log_info "Domain: ${DOMAIN}"
|
|
log_info "Features to install:"
|
|
should_skip rate-limit || log_info " ✓ Rate limiting (${RATE}, burst ${BURST}) on ${DOWNLOAD_PATH}"
|
|
should_skip honeypot || log_info " ✓ Bot honeypot (${TRAP_PATH}, ban ${BANTIME}s)"
|
|
should_skip headers || log_info " ✓ Security headers"
|
|
should_skip query-filter || log_info " ✓ Query string filtering"
|
|
should_skip hotlink-protection || log_info " ✓ Hotlink protection (images)"
|
|
for skip in "${SKIP[@]+"${SKIP[@]}"}"; do
|
|
log_warn " ✗ Skipping: ${skip}"
|
|
done
|
|
echo ""
|
|
|
|
# =====================================================================
|
|
# 1. RATE LIMITING
|
|
# =====================================================================
|
|
if ! should_skip rate-limit; then
|
|
log_info "Setting up rate limiting..."
|
|
|
|
RATE_ZONE_CONF="# Rate limit zone for download paths
|
|
# Installed by nginx-security-hardening.sh
|
|
limit_req_zone \$binary_remote_addr zone=downloads_${DOMAIN//./_}:10m rate=${RATE};"
|
|
|
|
write_file "/etc/nginx/conf.d/rate-limit-${DOMAIN}.conf" "$RATE_ZONE_CONF" "Rate limit zone (http context)"
|
|
|
|
if is_hestia_mode; then
|
|
RATE_HTTP="# Rate limiting on ${DOWNLOAD_PATH}
|
|
# Installed by nginx-security-hardening.sh
|
|
location ${DOWNLOAD_PATH} {
|
|
limit_req zone=downloads_${DOMAIN//./_} burst=${BURST} nodelay;
|
|
limit_req_status 429;
|
|
proxy_pass ${HTTP_BACKEND};
|
|
}"
|
|
write_file "${CONF_DIR}/nginx.conf_rate_limit" "$RATE_HTTP" "Rate limit (HTTP)"
|
|
|
|
RATE_HTTPS="# Rate limiting on ${DOWNLOAD_PATH}
|
|
# Installed by nginx-security-hardening.sh
|
|
location ${DOWNLOAD_PATH} {
|
|
limit_req zone=downloads_${DOMAIN//./_} burst=${BURST} nodelay;
|
|
limit_req_status 429;"
|
|
if $HTTPS_IS_SSL; then
|
|
RATE_HTTPS+="
|
|
proxy_ssl_server_name on;
|
|
proxy_ssl_name \$host;"
|
|
fi
|
|
RATE_HTTPS+="
|
|
proxy_pass ${HTTPS_BACKEND};
|
|
}"
|
|
write_file "${CONF_DIR}/nginx.ssl.conf_rate_limit" "$RATE_HTTPS" "Rate limit (HTTPS)"
|
|
else
|
|
RATE_SNIPPET="# Rate limiting on ${DOWNLOAD_PATH}
|
|
# Installed by nginx-security-hardening.sh
|
|
# Include inside your server block: include snippets/rate-limit-${DOMAIN}.conf;
|
|
location ${DOWNLOAD_PATH} {
|
|
limit_req zone=downloads_${DOMAIN//./_} burst=${BURST} nodelay;
|
|
limit_req_status 429;
|
|
}"
|
|
write_file "/etc/nginx/snippets/rate-limit-${DOMAIN}.conf" "$RATE_SNIPPET" "Rate limit snippet"
|
|
log_info "Add to your server block: include snippets/rate-limit-${DOMAIN}.conf;"
|
|
fi
|
|
fi
|
|
|
|
# =====================================================================
|
|
# 2. BOT HONEYPOT
|
|
# =====================================================================
|
|
if ! should_skip honeypot; then
|
|
log_info "Setting up bot honeypot..."
|
|
|
|
HONEYPOT_LOG="/var/log/nginx/${DOMAIN}.honeypot.log"
|
|
|
|
if is_hestia_mode; then
|
|
HONEYPOT_LOG="/var/log/nginx/domains/${DOMAIN}.honeypot.log"
|
|
|
|
TRAP_HTTP="# Bot honeypot — any request to ${TRAP_PATH} gets logged and blocked
|
|
# Installed by nginx-security-hardening.sh
|
|
location ${TRAP_PATH} {
|
|
access_log ${HONEYPOT_LOG} combined;
|
|
return 403;
|
|
}"
|
|
write_file "${CONF_DIR}/nginx.conf_honeypot" "$TRAP_HTTP" "Honeypot (HTTP)"
|
|
|
|
TRAP_HTTPS="# Bot honeypot — any request to ${TRAP_PATH} gets logged and blocked
|
|
# Installed by nginx-security-hardening.sh
|
|
location ${TRAP_PATH} {
|
|
access_log ${HONEYPOT_LOG} combined;
|
|
return 403;
|
|
}"
|
|
write_file "${CONF_DIR}/nginx.ssl.conf_honeypot" "$TRAP_HTTPS" "Honeypot (HTTPS)"
|
|
else
|
|
TRAP_SNIPPET="# Bot honeypot — any request to ${TRAP_PATH} gets logged and blocked
|
|
# Installed by nginx-security-hardening.sh
|
|
# Include inside your server block: include snippets/honeypot-${DOMAIN}.conf;
|
|
location ${TRAP_PATH} {
|
|
access_log ${HONEYPOT_LOG} combined;
|
|
return 403;
|
|
}"
|
|
write_file "/etc/nginx/snippets/honeypot-${DOMAIN}.conf" "$TRAP_SNIPPET" "Honeypot snippet"
|
|
log_info "Add to your server block: include snippets/honeypot-${DOMAIN}.conf;"
|
|
fi
|
|
|
|
# Fail2ban filter
|
|
HONEYPOT_FILTER="# Bot honeypot filter
|
|
# Installed by nginx-security-hardening.sh
|
|
[Definition]
|
|
failregex = ^<HOST> .* \"(GET|POST|HEAD) ${TRAP_PATH}
|
|
ignoreregex ="
|
|
|
|
write_file "/etc/fail2ban/filter.d/nginx-honeypot.conf" "$HONEYPOT_FILTER" "Fail2ban honeypot filter"
|
|
|
|
# Fail2ban jail
|
|
HONEYPOT_JAIL="# Bot honeypot jail — ban on first hit
|
|
# Installed by nginx-security-hardening.sh
|
|
[nginx-honeypot]
|
|
enabled = true
|
|
port = http,https
|
|
filter = nginx-honeypot
|
|
logpath = ${HONEYPOT_LOG}
|
|
maxretry = 1
|
|
bantime = ${BANTIME}
|
|
findtime = ${BANTIME}"
|
|
|
|
write_file "/etc/fail2ban/jail.d/nginx-honeypot.conf" "$HONEYPOT_JAIL" "Fail2ban honeypot jail"
|
|
|
|
# Create the log file so fail2ban doesn't complain
|
|
if ! $DRY_RUN; then
|
|
touch "$HONEYPOT_LOG"
|
|
chmod 644 "$HONEYPOT_LOG"
|
|
fi
|
|
|
|
log_warn "Add a hidden link to your site template to attract bots:"
|
|
echo -e " ${CYAN}<a href=\"${TRAP_PATH}/\" style=\"display:none\" aria-hidden=\"true\" tabindex=\"-1\">admin</a>${NC}"
|
|
echo ""
|
|
fi
|
|
|
|
# =====================================================================
|
|
# 3. SECURITY HEADERS
|
|
# =====================================================================
|
|
if ! should_skip headers; then
|
|
log_info "Setting up security headers..."
|
|
|
|
HEADERS_CONF="# Security headers
|
|
# Installed by nginx-security-hardening.sh
|
|
|
|
# Prevent MIME type sniffing
|
|
add_header X-Content-Type-Options \"nosniff\" always;
|
|
|
|
# Prevent clickjacking (SAMEORIGIN allows AWStats/panel frames)
|
|
add_header X-Frame-Options \"SAMEORIGIN\" always;
|
|
|
|
# Control referrer information
|
|
add_header Referrer-Policy \"strict-origin-when-cross-origin\" always;
|
|
|
|
# Restrict browser features
|
|
add_header Permissions-Policy \"camera=(), microphone=(), geolocation=(), payment=()\" always;
|
|
|
|
# Prevent XSS in older browsers
|
|
add_header X-XSS-Protection \"1; mode=block\" always;"
|
|
|
|
if is_hestia_mode; then
|
|
write_file "${CONF_DIR}/nginx.conf_security_headers" "$HEADERS_CONF" "Security headers (HTTP)"
|
|
write_file "${CONF_DIR}/nginx.ssl.conf_security_headers" "$HEADERS_CONF" "Security headers (HTTPS)"
|
|
else
|
|
write_file "/etc/nginx/snippets/security-headers-${DOMAIN}.conf" "$HEADERS_CONF" "Security headers snippet"
|
|
log_info "Add to your server block: include snippets/security-headers-${DOMAIN}.conf;"
|
|
fi
|
|
fi
|
|
|
|
# =====================================================================
|
|
# 4. QUERY STRING FILTERING
|
|
# =====================================================================
|
|
if ! should_skip query-filter; then
|
|
log_info "Setting up query string filtering..."
|
|
|
|
QUERY_CONF="# Block malicious query strings (SQL injection, XSS, path traversal)
|
|
# Installed by nginx-security-hardening.sh
|
|
|
|
# SQL injection patterns
|
|
if (\$query_string ~* \"(union|select\s.*from|insert\s.*into|delete\s.*from|drop\s+table|update\s.*set|concat\s*\(|exec\s*\()\") {
|
|
return 403;
|
|
}
|
|
|
|
# XSS patterns
|
|
if (\$query_string ~* \"(<script|javascript:|on(?:error|load|click|mouseover)=)\") {
|
|
return 403;
|
|
}
|
|
|
|
# Path traversal
|
|
if (\$query_string ~* \"(\\.\\./|\\.\\.\\\\/)\") {
|
|
return 403;
|
|
}
|
|
|
|
# Common exploit tools
|
|
if (\$query_string ~* \"(base64_decode|eval\s*\\(|php://input)\") {
|
|
return 403;
|
|
}"
|
|
|
|
if is_hestia_mode; then
|
|
write_file "${CONF_DIR}/nginx.conf_query_filter" "$QUERY_CONF" "Query filter (HTTP)"
|
|
write_file "${CONF_DIR}/nginx.ssl.conf_query_filter" "$QUERY_CONF" "Query filter (HTTPS)"
|
|
else
|
|
write_file "/etc/nginx/snippets/query-filter-${DOMAIN}.conf" "$QUERY_CONF" "Query filter snippet"
|
|
log_info "Add to your server block: include snippets/query-filter-${DOMAIN}.conf;"
|
|
fi
|
|
fi
|
|
|
|
# =====================================================================
|
|
# 5. HOTLINK PROTECTION
|
|
# =====================================================================
|
|
if ! should_skip hotlink-protection; then
|
|
log_info "Setting up hotlink protection..."
|
|
|
|
HOTLINK_CONF="# Hotlink protection — block image requests from other domains
|
|
# Installed by nginx-security-hardening.sh
|
|
location ~* \.(png|jpg|jpeg|gif|webp|svg|ico)$ {
|
|
valid_referers none blocked ${DOMAIN} *.${DOMAIN};
|
|
if (\$invalid_referer) {
|
|
return 403;
|
|
}
|
|
}"
|
|
|
|
if is_hestia_mode; then
|
|
write_file "${CONF_DIR}/nginx.conf_hotlink_protection" "$HOTLINK_CONF" "Hotlink protection (HTTP)"
|
|
write_file "${CONF_DIR}/nginx.ssl.conf_hotlink_protection" "$HOTLINK_CONF" "Hotlink protection (HTTPS)"
|
|
else
|
|
write_file "/etc/nginx/snippets/hotlink-protection-${DOMAIN}.conf" "$HOTLINK_CONF" "Hotlink protection snippet"
|
|
log_info "Add to your server block: include snippets/hotlink-protection-${DOMAIN}.conf;"
|
|
fi
|
|
fi
|
|
|
|
# =====================================================================
|
|
# FINALIZE
|
|
# =====================================================================
|
|
echo ""
|
|
|
|
if $DRY_RUN; then
|
|
log_dry "No changes were made (dry-run mode)"
|
|
log_dry "Remove --dry-run to apply changes"
|
|
else
|
|
log_info "Testing nginx configuration..."
|
|
if nginx -t 2>&1; then
|
|
log_info "Nginx config test passed"
|
|
log_info "Reloading nginx..."
|
|
systemctl reload nginx
|
|
log_info "Nginx reloaded"
|
|
else
|
|
log_error "Nginx config test FAILED — check the errors above"
|
|
log_error "Your previous config is still active (no reload happened)"
|
|
log_error "Backup files are in .backups/ subdirectories"
|
|
exit 1
|
|
fi
|
|
|
|
if ! should_skip honeypot; then
|
|
if systemctl is-active --quiet fail2ban; then
|
|
log_info "Reloading fail2ban..."
|
|
fail2ban-client reload 2>/dev/null || true
|
|
log_info "Fail2ban reloaded"
|
|
else
|
|
log_warn "Fail2ban is not running — start it to enable the honeypot jail"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
log_info "Installation complete!"
|
|
echo ""
|
|
|
|
should_skip rate-limit || echo -e " ${GREEN}✓${NC} Rate limiting: ${RATE} on ${DOWNLOAD_PATH} (burst ${BURST})"
|
|
should_skip honeypot || echo -e " ${GREEN}✓${NC} Bot honeypot: ${TRAP_PATH} → ban ${BANTIME}s"
|
|
should_skip headers || echo -e " ${GREEN}✓${NC} Security headers: X-Frame-Options, CSP, Referrer-Policy"
|
|
should_skip query-filter || echo -e " ${GREEN}✓${NC} Query filter: SQL injection, XSS, path traversal blocked"
|
|
should_skip hotlink-protection || echo -e " ${GREEN}✓${NC} Hotlink protection: Image hotlinking blocked"
|
|
|
|
if ! is_hestia_mode; then
|
|
echo ""
|
|
log_info "Standalone nginx — include the snippets in your server block:"
|
|
should_skip rate-limit || echo -e " ${CYAN}include snippets/rate-limit-${DOMAIN}.conf;${NC}"
|
|
should_skip honeypot || echo -e " ${CYAN}include snippets/honeypot-${DOMAIN}.conf;${NC}"
|
|
should_skip headers || echo -e " ${CYAN}include snippets/security-headers-${DOMAIN}.conf;${NC}"
|
|
should_skip query-filter || echo -e " ${CYAN}include snippets/query-filter-${DOMAIN}.conf;${NC}"
|
|
should_skip hotlink-protection || echo -e " ${CYAN}include snippets/hotlink-protection-${DOMAIN}.conf;${NC}"
|
|
fi
|
|
|
|
if ! should_skip honeypot; then
|
|
echo ""
|
|
log_warn "Don't forget to add the hidden honeypot link to your site template!"
|
|
fi
|
|
fi
|