v0.27.3
This commit is contained in:
1 parent
e84df445c6
commit
e87a382a7e
109 files changed
+1092
-2925
No files matched your search
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/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.")
|
||||
Reference in new issue
Block a user