Raiju/tests/security-check-smoke.py
2026-10-25 06:05:42 +03:00

391 lines
21 KiB
Python
Executable File

#!/usr/bin/env python3
"""Security self-check: probe files through an nginx-like proxy, cleanup, cache, findings and API access."""
import argparse
import fcntl
import functools
import http.client
import http.server
import json
import os
from pathlib import Path
import re
import shutil
import socket
import ssl
import subprocess
import tempfile
import threading
import time
import traceback
import urllib.parse
from support import Client, Site
# Extensions that HestiaCP's nginx serves from disk without asking Apache.
STATIC = {".json", ".txt", ".zip", ".jpg", ".mp3", ".mp4", ".pdf", ".css", ".js", ".svg", ".webp", ".png"}
def front_proxy(root, backend_port, deny=(), tls=None):
"""Serve existing static files itself, deny prefixes like `location ^~`, proxy the rest to PHP."""
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
path = urllib.parse.unquote(urllib.parse.urlsplit(self.path).path)
if any(path.startswith(prefix) for prefix in deny):
return self.reply(403, b"denied by front proxy")
file = Path(root) / path.lstrip("/")
if Path(path).suffix.lower() in STATIC and file.is_file():
return self.reply(200, file.read_bytes())
backend = http.client.HTTPConnection("127.0.0.1", backend_port, timeout=10)
backend.request("GET", self.path, headers={"Host": self.headers.get("Host", "")})
response = backend.getresponse()
self.reply(response.status, response.read(), response.getheader("Location"))
def reply(self, status, body, location=None):
self.send_response(status)
if location:
self.send_header("Location", location)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
if tls:
server.socket = tls.wrap_socket(server.socket, server_side=True)
threading.Thread(target=server.serve_forever, daemon=True).start()
return server
def redirect_server(target):
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
self.send_response(301)
self.send_header("Location", target + self.path)
self.send_header("Content-Length", "0")
self.end_headers()
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
return server
class QuietStaticHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, *args):
pass
def php(root, code, server=None, constants=None):
"""Run the helper in a fresh PHP process; any notice or warning fails the test."""
prelude = "$_SERVER = array_merge($_SERVER, json_decode(getenv('RJ_SERVER'), true));"
for name, value in (constants or {}).items():
prelude += f"define({name!r}, {json.dumps(value)});"
prelude += "require 'includes/security-check.php';"
result = subprocess.run(["php", "-d", "display_errors=stderr", "-d", "error_reporting=-1", "-r", prelude + code],
cwd=root, capture_output=True, text=True,
env=dict(os.environ, RJ_SERVER=json.dumps({"SCRIPT_NAME": "/admin/api.php", **(server or {})})))
assert result.returncode == 0 and not result.stderr, result.stderr + result.stdout
return json.loads(result.stdout)
def probe(root, base):
return php(root, f"echo json_encode(raijuSecurityRunProbe(getcwd(), {json.dumps(base)}));")
def leftovers(root):
return [str(path) for path in Path(root).rglob("raiju-access-check-*")]
def items(status):
return {item["id"]: item for item in status["items"]}
def free_port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def test_probe_through_proxy(site):
root = site.root
folders = ["content/", "backups/", "assets/images-trash/", "assets/audio-trash/", "assets/videos-trash/", "assets/documents-trash/"]
exposed_proxy = front_proxy(root, site.port)
try:
result = probe(root, f"http://127.0.0.1:{exposed_proxy.server_port}")
finally:
exposed_proxy.shutdown()
assert result["state"] == "exposed", result
assert [p["path"] for p in result["probes"]] == folders, result["probes"]
assert all(p["status"] == 200 and p["state"] == "exposed" for p in result["probes"]), result["probes"]
assert result["http"] == "skipped", "Plain http:// base must not run the redirect check"
assert not leftovers(root), leftovers(root)
# The generated nginx rules must cover every probed folder.
rules = php(root, "echo json_encode(raijuSecurityNginxRules(''));")
prefixes = re.findall(r"^location \^~ (\S+) \{ deny all; \}$", rules, re.M)
assert len(prefixes) == 8 and all(("/" + folder) in prefixes for folder in folders), rules
assert "location ^~ /sub/content/ { deny all; }" in php(root, "echo json_encode(raijuSecurityNginxRules('/sub'));")
fixed_proxy = front_proxy(root, site.port, deny=prefixes)
try:
result = probe(root, f"http://127.0.0.1:{fixed_proxy.server_port}")
finally:
fixed_proxy.shutdown()
assert result["state"] == "protected" and all(p["status"] == 403 for p in result["probes"]), result
# A proxy that still serves media itself (e.g. a Hestia template limited to assets) exposes only the trash folders.
media_proxy = front_proxy(root, site.port, deny=["/content/", "/backups/"])
try:
result = probe(root, f"http://127.0.0.1:{media_proxy.server_port}")
finally:
media_proxy.shutdown()
assert [p["path"] for p in result["probes"] if p["state"] == "exposed"] == folders[2:], result
# Without a proxy, .htaccess (Apache) or router.php (development server) blocks the folders.
result = probe(root, f"http://127.0.0.1:{site.port}")
assert result["state"] == "protected", result
assert not leftovers(root), leftovers(root)
def test_server_ignoring_htaccess(site):
# A bare PHP server resembles nginx + PHP-FPM without rules: files and CLI scripts are reachable.
port = free_port()
bare = subprocess.Popen(["php", "-d", "display_errors=0", "-S", f"127.0.0.1:{port}", "-t", str(site.root)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(100):
try:
with socket.create_connection(("127.0.0.1", port), timeout=.1):
break
except OSError:
time.sleep(.05)
result = probe(site.root, f"http://127.0.0.1:{port}")
assert result["state"] == "exposed" and "content/" in [p["path"] for p in result["probes"] if p["state"] == "exposed"], result
for name in ("backup", "make", "convert"):
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
connection.request("GET", f"/cli/{name}.php?x+--action=run")
response = connection.getresponse()
body = response.read()
# Web SAPIs echo a shebang line as text; nothing else may run or be printed.
assert response.status == 404 and body.strip() in (b"", b"#!/usr/bin/env php"), \
f"cli/{name}.php ran as a web request: {response.status} {body[:200]!r}"
assert not list((site.root / "backups").glob("*-backup-*.zip")), "Web request created a backup"
finally:
bare.terminate()
bare.wait(timeout=10)
assert not leftovers(site.root), leftovers(site.root)
def test_https_redirect_and_self_signed(site):
if not shutil.which("openssl"):
print("SKIP test_https_redirect_and_self_signed: openssl is not installed")
return
root = site.root
prefixes = re.findall(r"^location \^~ (\S+) \{ deny all; \}$", php(root, "echo json_encode(raijuSecurityNginxRules(''));"), re.M)
with tempfile.TemporaryDirectory(prefix="raiju-tls-") as folder:
cert, key = Path(folder) / "cert.pem", Path(folder) / "key.pem"
subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=127.0.0.1",
"-keyout", str(key), "-out", str(cert)], check=True, capture_output=True)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(cert, key)
# Staging servers often use self-signed certificates: the status-only probe still works.
secure = front_proxy(root, site.port, deny=prefixes, tls=context)
base = f"https://127.0.0.1:{secure.server_port}"
redirect = redirect_server(base)
plain = front_proxy(root, site.port, deny=prefixes)
try:
def run(plain_base):
return php(root, f"echo json_encode(raijuSecurityRunProbe(getcwd(), {json.dumps(base)}, {json.dumps(plain_base)}));")
result = run(f"http://127.0.0.1:{redirect.server_port}")
assert result["state"] == "protected" and result["http"] == "redirect", result
assert run(f"http://127.0.0.1:{plain.server_port}")["http"] == "open"
assert run(f"http://127.0.0.1:{free_port()}")["http"] == "closed"
# An https:// address with its own port has no known http:// counterpart.
assert probe(root, base)["http"] == "skipped"
finally:
for server in (secure, redirect, plain):
server.shutdown()
assert not leftovers(root), leftovers(root)
def test_wrong_address_and_cleanup(site):
root = site.root
stale = root / "content/raiju-access-check-0123456789abcdef.json"
unrelated = root / "content/raiju-access-check-notes.json"
stale.write_text("{}")
unrelated.write_text("{}")
# Another installation answers the address: its 404 for the control file must not count as protected.
with tempfile.TemporaryDirectory(prefix="raiju-other-") as other:
(Path(other) / "content").mkdir()
handler = functools.partial(QuietStaticHandler, directory=other)
other_server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=other_server.serve_forever, daemon=True).start()
try:
result = probe(root, f"http://127.0.0.1:{other_server.server_port}")
finally:
other_server.shutdown()
assert result["state"] == "unknown" and result["reason"] == "unreachable" and result["detail"] == "HTTP 404", result
assert not stale.exists(), "Stale probe file was not swept"
assert unrelated.exists(), "Sweep removed a file that is not a probe"
unrelated.unlink()
started = time.monotonic()
result = probe(root, f"http://127.0.0.1:{free_port()}")
assert result["state"] == "unknown" and result["reason"] == "unreachable" and result["detail"], result
assert time.monotonic() - started < 10, "Unreachable address was not answered quickly"
assert not leftovers(root), leftovers(root)
def test_findings():
with tempfile.TemporaryDirectory(prefix="raiju-security-findings-") as folder:
root = Site(folder).root
public = {"HTTP_HOST": "www.example.com", "HTTPS": "on", "REMOTE_ADDR": "203.0.113.9", "SERVER_ADDR": "198.51.100.1"}
status = php(root, "echo json_encode(raijuSecurityStatus('https'));", public,
{"RAIJU_INITIAL_DISPLAY_ERRORS": "0", "RAIJU_DEV_LOGIN": False})
found = items(status)
assert not status["local"] and not status["devServer"]
assert found["folders"]["state"] == "pending" and found["folders"]["stale"], found["folders"]
assert found["http"]["state"] == "pending", found["http"]
assert all(found[key]["state"] == "ok" for key in ("https", "cookie", "client_ip", "errors", "dev_login")), found
assert found["php"]["version"] and found["php"]["state"] in ("ok", "warning")
# Proxy without visitor IPs, unrecognised HTTPS, visible errors and the default development login.
found = items(php(root, "echo json_encode(raijuSecurityStatus('https'));",
{**public, "HTTPS": "", "REMOTE_ADDR": "127.0.0.1"}, {"RAIJU_INITIAL_DISPLAY_ERRORS": "On"}))
assert found["cookie"]["state"] == "warning" and found["errors"]["state"] == "warning", found
assert found["client_ip"] == {"id": "client_ip", "state": "warning", "ip": "127.0.0.1"}, found["client_ip"]
assert found["dev_login"]["state"] == "warning", found["dev_login"]
found = items(php(root, "echo json_encode(raijuSecurityStatus('https'));",
{**public, "REMOTE_ADDR": "198.51.100.1"}, {"RAIJU_INITIAL_DISPLAY_ERRORS": "stderr"}))
assert found["client_ip"]["state"] == "warning" and found["dev_login"]["state"] == "note", found
assert found["errors"]["state"] == "ok", "display_errors=stderr does not reach visitors"
found = items(php(root, "echo json_encode(raijuSecurityStatus('http'));", {**public, "HTTPS": ""},
{"RAIJU_INITIAL_DISPLAY_ERRORS": "stdout"}))
assert found["https"]["state"] == "warning" and found["errors"]["state"] == "warning", found
assert found["cookie"] == {"id": "cookie", "state": "skipped", "reason": "scheme"}, found["cookie"]
assert found["http"] == {"id": "http", "state": "skipped", "reason": "scheme"}, found["http"]
status = php(root, "echo json_encode(raijuSecurityStatus('http'));", {"HTTP_HOST": "localhost:3000", "REMOTE_ADDR": "127.0.0.1"})
found = items(status)
assert status["local"] and found["folders"]["state"] == "skipped" and found["folders"]["reason"] == "local", found
assert all(found[key]["state"] == "skipped" for key in ("https", "cookie", "client_ip", "errors", "dev_login")), found
found = items(php(root, "echo json_encode(raijuSecurityStatus('https'));", {**public, "HTTP_HOST": "evil.example/x"}))
assert found["folders"]["state"] == "unknown" and found["folders"]["reason"] == "host", found["folders"]
hosts = php(root, "echo json_encode(array_map('raijuSecurityIsLocalHost', ['localhost:3000', '127.0.0.1', '[::1]:8080', "
"'site.test', 'raiju.localhost', '192.168.1.20', 'intranet', 'www.example.com', '93.184.215.14:8443', "
"'beruehrungwirkt.mybyte.at']));")
assert hosts == [True] * 7 + [False] * 3, hosts
def test_cache_and_lock():
with tempfile.TemporaryDirectory(prefix="raiju-security-cache-") as folder:
root = Site(folder).root
cache = root / "content/security-check.json"
public = {"HTTP_HOST": "www.example.com", "HTTPS": "on", "REMOTE_ADDR": "203.0.113.9"}
entry = {"version": 1, "baseUrl": "https://www.example.com", "checkedAt": "2026-09-26T10:00:00+02:00",
"checkedTs": int(time.time()) - 60, "state": "exposed", "reason": "", "detail": "",
"probes": [{"path": "content/", "status": 200, "state": "exposed"}, {"path": "backups/", "status": 403, "state": "protected"}],
"http": "open"}
cache.write_text(json.dumps(entry))
# A recent result is reused without any network request, even when running is allowed.
found = items(php(root, "echo json_encode(raijuSecurityStatus('https', true));", public))
assert found["folders"]["state"] == "critical" and found["folders"]["exposed"] == ["content/"], found["folders"]
assert not found["folders"]["stale"] and "location ^~ /content/ { deny all; }" in found["folders"]["rules"]
assert found["http"] == {"id": "http", "state": "warning", "mode": "open"}, found["http"]
assert json.loads(cache.read_text())["checkedTs"] == entry["checkedTs"], "Fresh cache was re-run"
# Deleted media alone is a warning (no dashboard banner), still with the nginx rules.
trash = {**entry, "probes": [{"path": "content/", "status": 403, "state": "protected"},
{"path": "assets/images-trash/", "status": 200, "state": "exposed"}]}
cache.write_text(json.dumps(trash))
found = items(php(root, "echo json_encode(raijuSecurityStatus('https'));", public))["folders"]
assert found["state"] == "warning" and found["exposed"] == ["assets/images-trash/"] and found["rules"], found
cache.write_text(json.dumps(entry))
# Exposed results expire after ten minutes; protected ones after twelve hours.
cache.write_text(json.dumps({**entry, "checkedTs": int(time.time()) - 601}))
assert items(php(root, "echo json_encode(raijuSecurityStatus('https'));", public))["folders"]["stale"]
cache.write_text(json.dumps({**entry, "state": "protected", "checkedTs": int(time.time()) - 601}))
assert not items(php(root, "echo json_encode(raijuSecurityStatus('https'));", public))["folders"]["stale"]
# Another address (moved site, restored backup) must not inherit the result.
assert items(php(root, "echo json_encode(raijuSecurityStatus('https'));", {**public, "HTTP_HOST": "staging.example.com"}))["folders"]["state"] == "pending"
# A running check holds the lock: report it instead of starting a second probe.
cache.write_text(json.dumps(entry))
with open(str(cache) + ".lock", "a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
found = items(php(root, "echo json_encode(raijuSecurityStatus('https', true, true));", public))
fcntl.flock(lock, fcntl.LOCK_UN)
assert found["folders"]["running"] and found["folders"]["state"] == "critical", found["folders"]
def test_api(site):
guest = Client(site)
assert guest.api("security-check")[0] == 401
admin = Client(site).login()
editor = Client(site).login("editor")
assert editor.api("security-check")[0] == 403
# Only administrators get the warning banner and the system status badge.
for client, visible in ((admin, True), (editor, False)):
body = client.request("/admin/dashboard.php")[2]
assert (b'id="securityExposureWarning"' in body and b'id="systemBadge"' in body) == visible, client
status, _, _ = admin.request("/admin/api.php?action=security-check")
assert status == 405, "Probe run must require POST"
assert admin.api("security-check", csrf_token="wrong")[0] == 403
apache = hasattr(site, "fpm_process")
if apache:
# A local address is only probed on request, then through Apache and the real .htaccess.
status, result = admin.api("security-check", scheme="http")
assert status == 200 and items(result["data"])["folders"]["state"] == "skipped", result
status, result = admin.api("security-check", scheme="http", force="1")
assert status == 200 and result["success"], result
found = items(result["data"])
if apache:
assert found["folders"]["state"] == "ok" and all(p["status"] == 403 for p in found["folders"]["probes"]), found["folders"]
assert json.loads((site.root / "content/security-check.json").read_text())["state"] == "protected"
else:
# The development server is single-threaded and cannot request itself.
assert found["folders"] == {"id": "folders", "canRun": False, "url": f"http://127.0.0.1:{site.port}",
"state": "skipped", "reason": "dev_server"}, found["folders"]
assert found["http"]["state"] == "skipped" and found["php"]["version"], found
status, _, body = admin.request("/admin/api.php?action=system-status&scheme=http")
security = json.loads(body)["data"]["security"]
assert status == 200 and security["devServer"] != apache, security
assert items(security)["folders"]["state"] == ("ok" if apache else "skipped"), security
assert not leftovers(site.root)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--apache", action="store_true", help="use Apache/PHP-FPM with the real .htaccess behind the proxy")
args = parser.parse_args()
if args.apache:
from apache_support import ApacheSite as Backend
else:
Backend = Site
failures = []
for test in (test_findings, test_cache_and_lock):
try:
test()
print("PASS", test.__name__)
except Exception:
failures.append(test.__name__)
traceback.print_exc()
with tempfile.TemporaryDirectory(prefix="raiju-security-smoke-") as folder:
site = Backend(folder)
try:
site.start()
if args.apache:
print(site.server_version + "; " + site.php_version)
for test in (test_probe_through_proxy, test_server_ignoring_htaccess, test_https_redirect_and_self_signed,
test_wrong_address_and_cleanup, test_api):
try:
test(site)
print("PASS", test.__name__)
except Exception:
failures.append(test.__name__)
traceback.print_exc()
finally:
site.close()
if failures:
raise SystemExit("Failed: " + ", ".join(failures))