#!/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 < "$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 = ^ .* \"(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}admin${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 ~* \"(&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