107 lines
4.1 KiB
Python
107 lines
4.1 KiB
Python
#!/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.")
|