#!/bin/bash
# apache22-multipart-guard.sh - block multipart requests (file uploads) on legacy Apache 2.2 servers
# (Debian 6/7, Ubuntu 10.04-12.04; with or without ISPConfig). Emergency mitigation only - these systems
# get no PHP security fixes anymore, so migrate the sites.
# First used on Debian 7 / Apache 2.2.22 / ISPConfig 3.2.2. Requires mod_rewrite.
#
# Apache 2.2 has no global <If> and no RewriteOptions InheritDown, and a global ModSecurity rule
# would be skipped in the many vhosts that set "SecRuleEngine Off". So every <VirtualHost> block
# gets "Include /etc/apache2/multipart-guard.inc" and that file holds the mod_rewrite rule
# (active) or only a comment (inactive).
#
# Usage (as root):  bash apache22-multipart-guard.sh install|block|unblock|status|test|uninstall
# Allowlist: /etc/multipart-guard/allowlist - exact IPs only (Apache 2.2 regex match, no CIDR).
# ISPConfig regenerates a vhost file when that site is changed: run "install" again afterwards ("status" shows missing hooks).
# A vhost that contains its own "RewriteEngine Off" after the hook disables the rule for that vhost.

set -u
ACTION=${1:-status}
INC=/etc/apache2/multipart-guard.inc
ALLOW=/etc/multipart-guard/allowlist
BACKUP=/root/multipart-guard-backup
LINE="Include $INC"
MARK="# multipart-guard hook"   # own line: Apache 2.2 does not allow trailing comments
STATUS_CODE=403

[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; }
case "$ACTION" in install|block)
	apache2ctl -M 2>/dev/null | grep -q rewrite_module || { echo "[ERROR] mod_rewrite is not loaded - run: a2enmod rewrite" >&2; exit 1; } ;;
esac

vhost_files() { for f in /etc/apache2/sites-enabled/*; do [ -f "$f" ] && grep -q '^[[:space:]]*<VirtualHost' "$f" && readlink -f "$f"; done | sort -u; }

allow_ips() { [ -f "$ALLOW" ] && sed -e 's/#.*//' -e 's/[[:space:]]//g' "$ALLOW" | grep -E '^[0-9A-Fa-f:.]+$'; }

write_inc() {  # on|off
	if [ "$1" = on ]; then
		{
			echo "# multipart-guard ACTIVE - managed by apache22-multipart-guard.sh"
			echo "RewriteEngine On"
			echo "RewriteCond %{HTTP:Content-Type} multipart [NC]"
			local re; re=$(allow_ips | sed 's/[.]/\\./g' | paste -sd'|')
			[ -n "$re" ] && echo "RewriteCond %{REMOTE_ADDR} !^($re)\$"
			echo "RewriteRule .* - [F]"
		} > "$INC"
	else
		echo "# multipart-guard inactive - managed by apache22-multipart-guard.sh" > "$INC"
	fi
}

reload() {
	if apache2ctl configtest >/dev/null 2>&1; then apache2ctl graceful >/dev/null 2>&1; return 0; fi
	apache2ctl configtest
	return 1
}

install_hooks() {
	mkdir -p "$(dirname "$ALLOW")" "$BACKUP"
	[ -f "$ALLOW" ] || printf '# multipart-guard allowlist: one exact IP per line (no CIDR on Apache 2.2)\n# After changes run: apache22-multipart-guard.sh block\n' > "$ALLOW"
	[ -f "$INC" ] || write_inc off
	local f n
	for f in $(vhost_files); do
		n=$(grep -c '^[[:space:]]*<VirtualHost' "$f")
		[ "$(grep -cF "$LINE" "$f")" -ge "$n" ] && continue
		cp -p "$f" "$BACKUP/$(basename "$f").$(date +%Y%m%d%H%M%S)"
		remove_hook "$f"
		sed -i "s|^\([[:space:]]*\)<VirtualHost[^>]*>.*$|&\n\1    $MARK\n\1    $LINE|" "$f"
		echo "hooked: $f ($n VirtualHost blocks)"
	done
	if ! reload; then
		echo "[ERROR] configtest failed - restoring vhost files" >&2
		uninstall_hooks; reload; exit 1
	fi
}

remove_hook() { sed -i -e "\|^[[:space:]]*$MARK\$|d" -e "\|^[[:space:]]*$LINE\$|d" "$1"; }

uninstall_hooks() {
	local f
	for f in $(vhost_files); do grep -qF "$LINE" "$f" && remove_hook "$f"; done
}

status() {
	local total=0 hooked=0 f
	for f in $(vhost_files); do
		total=$((total + $(grep -c '^[[:space:]]*<VirtualHost' "$f")))
		hooked=$((hooked + $(grep -cF "$LINE" "$f")))
	done
	echo "state:     $(grep -q ACTIVE "$INC" 2>/dev/null && echo BLOCKING || echo off)"
	echo "hooks:     $hooked of $total VirtualHost blocks $([ "$hooked" -ge "$total" ] && echo '' || echo '- run install')"
	echo "allowlist: $ALLOW ($(allow_ips 2>/dev/null | wc -l) entries)"
}

run_test() {
	local active port name scheme code get fail=0
	grep -q ACTIVE "$INC" 2>/dev/null && active=1 || active=0
	[ "$active" = 1 ] && echo "guard ACTIVE - expecting HTTP $STATUS_CODE for multipart POSTs" \
		|| echo "guard off - multipart POSTs should behave like before (ModSecurity may also answer 403 on some vhosts)"
	apache2ctl -S 2>/dev/null | awk '/port [0-9]+ namevhost/ {print $2, $4} /^_default_:8081/ {print 8081, $2}' | sort -u |
	while read -r port name; do
		case $port in 443) scheme=https ;; *) scheme=http ;; esac
		# Host header instead of --resolve: the curl of Debian 6 (7.21.0) has no --resolve
		code=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 15 -H "Host: $name" -X POST \
			-H 'Content-Type: multipart/form-data; boundary=mpgprobe' --data-binary $'--mpgprobe--\r\n' "$scheme://127.0.0.1:$port/multipart-guard-probe.php")
		get=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 15 -H "Host: $name" "$scheme://127.0.0.1:$port/")
		if [ "$active" = 1 ] && [ "$code" != "$STATUS_CODE" ]; then
			echo "[FAIL] $scheme://$name:$port  multipart POST=$code  GET /=$get"
		else
			echo "[ OK ] $scheme://$name:$port  multipart POST=$code  GET /=$get"
		fi
	done
}

case "$ACTION" in
	install) install_hooks; status ;;
	block)   install_hooks; write_inc on; reload || { write_inc off; reload; echo "[ERROR] guard NOT active" >&2; exit 1; }
	         echo "multipart guard ACTIVE"; sleep 2; run_test ;;
	unblock) write_inc off; reload || exit 1; echo "multipart guard off"; sleep 2; run_test ;;
	status)  status ;;
	test)    run_test ;;
	uninstall) uninstall_hooks; rm -f "$INC"; reload && echo "removed (allowlist kept)" ;;
	*) echo "unknown action: $ACTION" >&2; exit 1 ;;
esac
