#!/usr/bin/env python3 """Source-level security invariants that must hold even before Rust runtime tests.""" from __future__ import annotations from pathlib import Path from rust_source import rust_source, rust_tree ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: target = ROOT / path return rust_source(ROOT, path) if target.suffix == ".rs" else target.read_text(encoding="utf-8") def need(text: str, *needles: str) -> None: for needle in needles: if needle not in text: raise AssertionError(f"missing security invariant: {needle}") web = rust_tree(ROOT, "src/web_server") htaccess = read("src/web_server/htaccess.rs") acl = read("src/acl.rs") admin = read("src/admin/mod.rs") auth = read("src/auth/mod.rs") web_ui = read("src/admin/web/web-server.js") # Preset regression: selecting a preset must actually materialize normal config fields. need(web_ui, "function presetChanged()", 'if ($("web-service-preset").value !== "custom") applyPreset();', '$("web-service-preset").addEventListener("change", presetChanged);') # Request target/path canonicalization: reject double decoding, encoded separators and dot traversal. need(acl, "ambiguous encoded path character", "ambiguous path segments", "!matches!(decoded, b'/' | b'\\\\' | b'%' | b'?' | b'#' | b';')", "path.len() <= 8192") # FastCGI must not inherit client proxy settings (httpoxy) and must not control HTTP framing. need(web, "HTTP_PROXY is a well-known CGI/FastCGI injection vector", 'if name.as_str() == "proxy"', '"content-length"', '"transfer-encoding"', '"connection"', '"upgrade"', '"proxy-authenticate"', '"proxy-authorization"', "(200..=599).contains(&code)", "unsupported informational/invalid status", "reserved_fastcgi_param", '"PHP_ADMIN_VALUE"', "pass_authorization", "max_request_body_bytes", "StatusCode::PAYLOAD_TOO_LARGE", "max_response_header_bytes", "stderr_log_bytes") # File-system confinement and .htaccess loading must not follow attacker-controlled symlinks. need(web, "libc::openat", "libc::O_NOFOLLOW", "secure filesystem confinement") need(htaccess, "read_htaccess_nofollow", "libc::O_NOFOLLOW", "fs::symlink_metadata", ".htaccess scan escaped the Web Service document root", "must not be a symlink", "MAX_HTACCESS_FILES", "MAX_HTACCESS_TOTAL_BYTES") need(web, "htaccess_configuration_file_cannot_be_a_symlink") # PHP source must fail closed instead of becoming a static download when FPM is unavailable. need(web, "php_source_extensions_are_not_served_without_php_fpm", "php_fpm_failure_returns_gateway_error_instead_of_php_source", "fastcgi_never_forwards_proxy_header_as_http_proxy", "fastcgi_response_cannot_control_downstream_framing_or_hop_headers", "fastcgi_response_rejects_informational_status_codes", "php_custom_fastcgi_params_cannot_override_core_or_php_admin_values", "php_request_body_limit_returns_413_before_fastcgi_connect") # Admin plane: exact anonymous allowlist + cookie/CSRF/origin/browser marker + CSP/frame denial. need(admin, "fn anonymous_allowed", 'single_header(headers, "origin")', 'single_header(headers, "x-rustproxy-request") != Some("1")', 'single_header(headers, "x-csrf-token")', '"auth.csrf"', "frame-ancestors 'none'", "object-src 'none'", "anonymous_operational_paths_fail_closed_before_extractors", "duplicate_csrf_or_marker_is_rejected_and_session_survives") need(auth, "HttpOnly; SameSite=Strict", 'if secure { "; Secure" } else { "" }', "Argon2id", "(12..=128).contains(&value.len())") # Web serving/admin code must not execute shell commands as part of request/config handling. for path, text in { "src/web_server/*.rs": web, "src/web_server/htaccess.rs": htaccess, "src/admin/mod.rs": admin, }.items(): if "Command::new" in text or "std::process::Command" in text: raise AssertionError(f"unexpected process execution primitive in {path}") print("PASS: production security source invariants (paths, FastCGI, htaccess, admin auth/CSRF, no shell execution) are present.")