Files
RustProxy/tools/checks/security_contracts.py
T
2026-10-11 22:12:27 +02:00

107 lines
4.1 KiB
Python
Executable File

#!/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.")