From d040b06c4a1b6e99efc2f8fc2dd6be9a7376ce58 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:52:53 +0200 Subject: [PATCH] Merge pull request #11 from MartinRinas/copilot/php-support-script-implement-option-2 Add reversible web-PHP containment script --- runs/manage_web_php.sh | 233 +++++++++++++++++++ runs/manage_web_php_bash_test.py | 370 +++++++++++++++++++++++++++++++ runs/setup_apache2.sh | 7 + 3 files changed, 610 insertions(+) create mode 100755 runs/manage_web_php.sh create mode 100644 runs/manage_web_php_bash_test.py diff --git a/runs/manage_web_php.sh b/runs/manage_web_php.sh new file mode 100755 index 0000000000..b074d823c9 --- /dev/null +++ b/runs/manage_web_php.sh @@ -0,0 +1,233 @@ +#!/bin/bash +set -Eeuo pipefail +export PATH=/usr/sbin:/usr/bin:/sbin:/bin +for variable in ${!APACHE_@}; do unset "$variable"; done +BASE=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +APACHE=/etc/apache2 +STATE=/var/lib/openwb-web-php-bash +GUARD=disable-web-php + +usage() { + echo "Usage: sudo $0 [--debug] disable|enable" + echo "--debug: show troubleshooting stages and module-detection diagnostics." + echo "Standard openWB Debian mod_php only; custom Apache/FPM/CGI setups need administrator review." + echo "PHP CLI and charging services are unchanged. Recheck after updates; no startup/UI integration." + echo "Enable restores saved modules/API state." + echo "Power loss/SIGKILL during enable is not crash-atomic; rerun disable before restarting Apache." +} +die() { echo "ERROR: $* Result NOT confirmed." >&2; exit 1; } +debug_enabled=0 +mode= +debug() { if (( debug_enabled )); then echo "INFO: $*"; fi; } +step() { stage=$*; debug "$stage"; } +for argument in "$@"; do + case $argument in + --debug) debug_enabled=1 ;; + --help|-h) usage; exit 0 ;; + enable|disable) + [[ -z $mode ]] || { usage; exit 1; } + mode=$argument ;; + *) usage; exit 1 ;; + esac +done +[[ -n $mode ]] || { usage; exit 1; } +[[ $EUID == 0 ]] || die "Run with sudo." +echo "Standard openWB mod_php only; custom Apache/FPM/CGI setups require administrator review." + +sudo_lock=/run/openwb-apache +install -d -m 755 "$sudo_lock" +touch "$sudo_lock/configuration.lock" +chmod 644 "$sudo_lock/configuration.lock" +exec 9<"$sudo_lock/configuration.lock" +flock --exclusive 9 +[[ ! -L $STATE ]] || die "State directory must not be a symlink." +install -d -m 700 "$STATE" +[[ $(stat -c %u "$STATE") == 0 ]] || die "State directory must be root-owned." +umask 077 +probe= +transition=0 +stage=preflight +guard() { + local file="$APACHE/conf-available/$GUARD.conf" + # Never overwrite a custom deny rule. + [[ ! -L $file ]] || return 1 + cat >"$STATE/guard.tmp" <<'EOF' || return 1 +# Managed by openWB runs/manage_web_php.sh + + Require all denied + + + Require all denied + +EOF + if [[ -e $file ]]; then cmp -s "$STATE/guard.tmp" "$file" || return 1; fi + install -m 644 "$STATE/guard.tmp" "$file" || return 1 + a2enconf "$GUARD" +} +cleanup() { + local status=$? failed=0 active + trap - EXIT INT TERM + (( status == 0 )) || echo "ERROR: failed during $stage; requested result NOT confirmed." >&2 + if (( status != 0 && ! transition )); then echo "INFO: Apache/PHP configuration was not changed by this run." >&2; fi + if (( status != 0 && transition )); then + guard || failed=1 + timeout 60 systemctl stop apache2.service || failed=1 + active=$(timeout 60 systemctl show --property=ActiveState --value apache2.service) || failed=1 + [[ $active == inactive || $active == failed ]] || failed=1 + if (( failed )); then + echo "FAILED: cleanup unconfirmed; Apache may still expose PHP!" >&2 + else + echo "FAILED: Apache stopped with PHP deny guard enabled." >&2 + fi + echo "Rollback state retained. Correct the error and rerun." >&2 + fi + [[ -z $probe ]] || rm -f "$probe" + rm -f "$STATE/guard.tmp" "$STATE/state.tmp" + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +enabled() { [[ -e $APACHE/sites-enabled/$1.conf ]]; } +modules() { + local file name + for file in "$APACHE"/mods-enabled/php*.load; do + [[ -e $file ]] || continue + name=${file##*/}; name=${name%.load} + [[ $name =~ ^php[0-9]+(\.[0-9]+)?$ ]] || continue + echo "$name" + done +} +module_diagnostics() { + local directory loaded + if (( ! debug_enabled )); then + echo "Rerun with --debug for PHP module-detection diagnostics." >&2 + return 0 + fi + echo "DIAGNOSTIC: recognized enabled PHP modules: ${saved[*]:-(none)}" >&2 + echo "DIAGNOSTIC: looking for php[.].load; symlinks must resolve." >&2 + for directory in mods-enabled mods-available; do + echo "DIAGNOSTIC: $APACHE/$directory PHP entries (name -> symlink target):" >&2 + find "$APACHE/$directory" -maxdepth 1 -iname '*php*' -printf ' %f -> %l\n' >&2 || true + done + echo "DIAGNOSTIC: Apache PHP/CGI module report:" >&2 + if loaded=$(apache2ctl -M); then + grep -Ei 'php|cgi|actions' <<<"$loaded" >&2 || echo " (no PHP/CGI modules reported)" >&2 + else + echo " Unable to query Apache modules." >&2 + fi + echo "DIAGNOSTIC: HTTP 403 alone does not prove mod_php is unloaded; no module will be guessed." >&2 +} +check_modules() { + local loaded + loaded=$(apache2ctl -M) + ! grep -Eq '^[[:space:]]*(cgi|cgid|fcgid|fastcgi|proxy_fcgi|proxy_scgi|actions)_module' <<<"$loaded" || + die "Alternate CGI/FastCGI handler found; administrator review required." + if [[ $mode == disable ]]; then + ! grep -Eq '^[[:space:]]*php[^[:space:]]*_module' <<<"$loaded" || die "PHP module is still loaded." + else + grep -Eq '^[[:space:]]*php[^[:space:]]*_module' <<<"$loaded" || die "No PHP module is loaded." + fi +} +step "checking Apache configuration and validation endpoints" +apache2ctl configtest +urls=() +if enabled localhost; then urls+=(http://127.0.0.1:81); fi +if enabled 000-default; then urls+=(http://127.0.0.1); fi +if enabled apache-openwb-ssl; then urls+=(https://127.0.0.1); fi +(( ${#urls[@]} )) || die "No standard openWB website enabled for HTTP validation (localhost, 000-default or apache-openwb-ssl)." +debug "validation endpoints: ${urls[*]}" +[[ $(systemctl is-enabled apache2.service || true) != masked* ]] || die "Apache is masked." +systemctl is-active --quiet apache2.service || die "Apache is stopped; start it explicitly before live validation." +step "detecting PHP modules and rollback state" +if [[ -e $APACHE/conf-enabled/$GUARD.conf ]]; then + debug "PHP deny guard is already enabled; HTTP 403 may be due to this existing rule." +else + debug "PHP deny guard is not enabled." +fi +mapfile -t saved < <(modules) +debug "recognized enabled PHP modules: ${saved[*]:-(none)}" +api=false +enabled http-api-ssl && api=true +if [[ -e $STATE/state ]]; then + debug "reading saved module/API state from $STATE/state" + mapfile -t saved <"$STATE/state" + api=${saved[0]:-invalid}; saved=("${saved[@]:1}") + [[ $api == true || $api == false ]] || die "Invalid saved API state." +else + if [[ $mode == enable ]]; then + [[ ! -e $APACHE/conf-enabled/$GUARD.conf ]] || die "No Bash rollback state; guard retained." + fi + if (( ! ${#saved[@]} )); then + module_diagnostics + die "No enabled Debian mod_php module found. PHP may already be disabled or use a different handler/name." + fi + printf '%s\n' "$api" "${saved[@]}" >"$STATE/state.tmp" + mv "$STATE/state.tmp" "$STATE/state" +fi +(( ${#saved[@]} )) || die "No PHP module to restore or validate." +for module in "${saved[@]}"; do + [[ $module =~ ^php[0-9]+(\.[0-9]+)?$ ]] || die "Invalid saved PHP module." + [[ -f $APACHE/mods-available/$module.load ]] || die "PHP module $module is no longer installed." + dpkg-query -W '-f=${Package} ${Version}\n' "libapache2-mod-$module" 2>/dev/null || true +done +php -n -v 2>/dev/null | head -n 1 || true +echo "PHP2Shell patch status: UNKNOWN; check your vendor advisory for backports." +[[ $mode != enable ]] || echo "WARNING: enabling web PHP restores its attack surface; this is not a security patch." +transition=1 +step "stopping Apache and applying PHP $mode" +timeout 60 systemctl stop apache2.service +if [[ $mode == disable ]]; then + guard + mapfile -t current < <(modules) + for module in "${current[@]}"; do a2dismod "$module"; done + if enabled http-api-ssl; then a2dissite http-api-ssl; fi +else + for module in "${saved[@]}"; do a2enmod "$module"; done + if [[ -e $STATE/state ]]; then a2disconf "$GUARD"; fi + if [[ $api == true ]]; then a2ensite http-api-ssl + elif enabled http-api-ssl; then a2dissite http-api-ssl; fi +fi +step "checking changed configuration before starting Apache" +apache2ctl configtest +check_modules +timeout 60 systemctl start apache2.service +step "validating running Apache and PHP $mode" +systemctl is-active --quiet apache2.service +check_modules +if [[ $mode == disable ]]; then + if enabled http-api-ssl; then die "PHP API site is still enabled."; fi +else + if [[ $api == true ]]; then enabled http-api-ssl || die "PHP API site was not restored." + elif enabled http-api-ssl; then die "PHP API site should be disabled."; fi +fi +curl_local() { curl -ksS --noproxy '*' --connect-timeout 5 --max-time 10 "$@"; } +if [[ $mode == enable ]]; then + probe=$(mktemp "$BASE/web/openwb-php-check-XXXXXX.php") + token=$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n') + printf ""$probe" + chmod 644 "$probe" +fi +for url in "${urls[@]}"; do + step "checking static website and PHP response at $url" + [[ $(curl_local -o /dev/null -w '%{http_code}' "$url/openWB/index.html") == 200 ]] || + die "Static website check failed at $url." + if [[ $mode == disable ]]; then + [[ $(curl_local -o /dev/null -w '%{http_code}' "$url/openWB/web/settings/uploadFile.php") == 403 ]] || + die "PHP denial check failed at $url; expected HTTP 403." + else + [[ $(curl_local -w '%{http_code}' "$url/openWB/web/${probe##*/}") == "${token}200" ]] || + die "PHP execution check failed at $url." + fi + echo "CHECK $url: static HTTP 200; PHP $mode verified." +done +if [[ $mode == enable ]]; then rm -f "$STATE/state"; fi +transition=0 +echo "CONFIRMED: web PHP $mode verified on standard openWB endpoints; PHP CLI unchanged." +if [[ $mode == disable ]]; then + echo "Disabled to contain PHP2Shell: web PHP modules and optional HTTPS API (port 8443)." + echo "Unavailable: restore/migration uploads, PHP CSV export, Tesla/Mercedes authorization," + echo "legacy SmartHome settings and PHP firmware-update buttons. Static UI and /ws remain configured." +fi +echo "Verify dashboard updates, primary/secondary communication and charging manually." +echo "Recheck after updates/startup setup, which may re-enable the API site; the deny guard persists when disabled." diff --git a/runs/manage_web_php_bash_test.py b/runs/manage_web_php_bash_test.py new file mode 100644 index 0000000000..07ecd0c42b --- /dev/null +++ b/runs/manage_web_php_bash_test.py @@ -0,0 +1,370 @@ +"""Exercise the Bash CLI with isolated paths and mocked appliance commands.""" + +import fcntl +import os +from pathlib import Path +import subprocess +import shutil +import socket +import tempfile +import time +from urllib.error import HTTPError, URLError +from urllib.request import ProxyHandler, build_opener + +import pytest + + +SCRIPT = Path(__file__).with_name("manage_web_php.sh") + + +@pytest.fixture +def appliance(tmp_path): + root = tmp_path + apache = root / "apache" + for directory in ("mods-enabled", "mods-available", "sites-enabled", "conf-available", "conf-enabled"): + (apache / directory).mkdir(parents=True) + for module in ("php7.4", "php8.2"): + (apache / "mods-enabled" / (module + ".load")).touch() + (apache / "mods-available" / (module + ".load")).touch() + for site in ("localhost", "000-default", "apache-openwb-ssl", "http-api-ssl"): + (apache / "sites-enabled" / (site + ".conf")).touch() + (root / "web").mkdir() + (root / "active").touch() + (root / "bin").mkdir() + mock = root / "bin/mock" + mock.write_text(r'''#!/bin/bash +set -eu +echo "${0##*/} $*" >>"$ROOT/commands" +case ${0##*/} in +systemctl) + case $1 in + is-enabled) echo enabled ;; + is-active) test -e "$ROOT/active" ;; + stop) + if test -e "$ROOT/fail-stop"; then exit 1; fi + rm -f "$ROOT/active" ;; + start) touch "$ROOT/active" ;; + show) if test -e "$ROOT/active"; then echo active; else echo inactive; fi ;; + esac ;; +apache2ctl) + if [[ $1 == -M ]]; then + for file in "$ROOT"/apache/mods-enabled/php*.load; do + test -e "$file" || continue + echo " php_module (shared)" + done + if test -e "$ROOT/alternate"; then echo " proxy_fcgi_module (shared)"; fi + elif test -e "$ROOT/fail-config" && ! test -e "$ROOT/active"; then exit 1 + fi ;; +a2enconf) + test ! -e "$ROOT/fail-guard" || exit 1 + ln -sf "../conf-available/$1.conf" "$ROOT/apache/conf-enabled/$1.conf" ;; +a2disconf) rm -f "$ROOT/apache/conf-enabled/$1.conf" ;; +a2dismod) rm -f "$ROOT/apache/mods-enabled/$1.load" ;; +a2enmod) touch "$ROOT/apache/mods-enabled/$1.load" ;; +a2dissite) rm -f "$ROOT/apache/sites-enabled/$1.conf" ;; +a2ensite) touch "$ROOT/apache/sites-enabled/$1.conf" ;; +curl) + url=${!#} + if test -e "$ROOT/fail-http"; then printf '500' + elif [[ $url == */index.html ]]; then printf '200' + elif [[ $url == */uploadFile.php ]]; then printf '403' + else + sed -n "s/\nRequire all granted\n\n') + process = subprocess.Popen([apache, "-f", str(config), "-X"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + client = build_opener(ProxyHandler({})) + url = f"http://127.0.0.1:{port}" + try: + for _ in range(100): + try: + with client.open(url + "/index.html", timeout=1) as response: + assert response.read() == b"dashboard" + break + except URLError: + assert process.poll() is None, process.communicate() + time.sleep(0.05) + else: + pytest.fail("Apache did not start") + for suffix in ("php", "PHP", "php7", "phtml", "phar", "phps", "php.txt", "php/path"): + with pytest.raises(HTTPError) as error: + client.open(url + "/source." + suffix, timeout=2) + assert error.value.code == 403 + assert b"