Files
RustProxy/scripts/web_server_contracts.py
T
2026-10-05 09:30:21 +02:00

191 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Rust-independent 0.25 Web Server architecture/security contract checks."""
from pathlib import Path
import json
from rust_source import rust_source, rust_tree
from web_assets import read_app_js, read_vhosts_js
ROOT = Path(__file__).resolve().parents[1]
def need(text: str, *markers: str) -> None:
for marker in markers:
assert marker in text, marker
config = rust_source(ROOT, "src/config.rs")
vhost = rust_source(ROOT, "src/vhost.rs")
web_dir = ROOT / "src/web_server"
assert not (ROOT / "src/web_server.rs").exists(), "Web Server monolith must not coexist with src/web_server/mod.rs"
for module in ["mod.rs", "config.rs", "filesystem.rs", "security.rs", "response.rs", "request.rs", "routing.rs", "fastcgi.rs", "serve.rs", "htaccess.rs", "tests.rs"]:
assert (web_dir / module).is_file(), module
web = rust_tree(ROOT, "src/web_server")
proxy = rust_source(ROOT, "src/proxy/mod.rs")
admin = rust_source(ROOT, "src/admin/mod.rs")
ui = read_vhosts_js(ROOT)
web_ui = (ROOT / "src/admin/web/web-server.js").read_text()
app_ui = read_app_js(ROOT)
html = (ROOT / "src/admin/web/index.html").read_text()
settings_ui = (ROOT / "src/admin/web/settings.js").read_text()
logging = rust_source(ROOT, "src/logging.rs")
metrics = rust_source(ROOT, "src/metrics.rs")
acl = rust_source(ROOT, "src/acl.rs")
openapi = json.loads((ROOT / "src/admin/openapi.json").read_text())
need(config,
"pub web_services: Vec<crate::web_server::Service>",
"pub enum CompiledSource",
"CompiledSource::WebServer",
"unknown Web Service",
"web_server_main_source_needs_no_backend_pool",
"web_server_and_reverse_proxy_can_share_one_vhost",
"one_web_service_can_be_reused_by_multiple_vhosts",
"missing_source_fields_remain_reverse_proxy_for_old_configs")
need(vhost,
"pub enum Source",
"WebServer { web_service_id: String }",
"pub source: Source",
"vhost requires at least one backend member for Reverse Proxy sources",
"Web Server location cannot pin a reverse-proxy backend")
need(web,
"pub struct Service",
"pub document_root: PathBuf",
"pub index: Vec<String>",
"pub directory_listing: bool",
"pub mime_types: BTreeMap<String, String>",
"pub default_mime_type: String",
"pub cache_control: Option<String>",
"pub cache_rules: Vec<CacheRule>",
"pub response_headers: BTreeMap<String, String>",
"pub try_files: Vec<String>",
"pub rewrites: Vec<RewriteRule>",
"pub precompressed: Precompressed",
"pub download_extensions: Vec<String>",
"pub symlink_policy: SymlinkPolicy",
"pub php: Php",
"pub htaccess: HtaccessConfig",
"pub hardening: Hardening",
"hardening_blocks_common_probe_paths_and_trace",
"hardening_strips_fingerprint_headers_after_source_rules",
"HTTP_PROXY is a well-known CGI/FastCGI injection vector",
'if name.as_str() == "proxy"',
'"content-length"',
'"proxy-authenticate"',
'"proxy-authorization"',
"mod htaccess",
"htaccess::apply_rewrites",
"htaccess::protected_component",
"htaccess_wordpress_style_front_controller_and_protected_files_work",
"htaccess_directory_index_mime_headers_expires_and_nested_scope_work",
"htaccess_strict_mode_rejects_unsupported_directives",
"htaccess_security_sensitive_unsupported_directives_fail_closed",
"php_source_extensions_are_not_served_without_php_fpm",
"htaccess_php_addhandler_requires_php_fpm",
"FastCgiEndpoint",
"FCGI_BEGIN_REQUEST",
"try_acquire_owned",
"CONTENT_DISPOSITION",
"CONTENT_ENCODING",
"libc::openat",
"libc::O_NOFOLLOW",
"ReaderStream::new(file.take(body_length))",
"IF_NONE_MATCH", "IF_MODIFIED_SINCE", "IF_RANGE", "RANGE",
"StatusCode::NOT_MODIFIED", "StatusCode::PARTIAL_CONTENT", "StatusCode::RANGE_NOT_SATISFIABLE",
"listing_escapes_html_and_encodes_urls",
"root_directory_listing_is_repeatable_across_refreshes",
"index_precedes_listing_and_missing_index_falls_back_to_listing_per_directory",
"mime_overrides_default_mime_and_cache_control_are_applied_to_files",
'HeaderValue::from_static("no-store")',
"builtin_error_response(StatusCode::FORBIDDEN, &method)",
"empty_index_can_go_directly_to_directory_listing",
"RustProxy <b>0.25.5</b>",
"traversal_and_symlink_escape_are_not_served",
"try_files_rewrites_cache_rules_headers_and_downloads_work",
"precompressed_sidecars_keep_mime_validators_and_range_semantics",
"php_fpm_executes_index_and_try_files_front_controller_without_source_disclosure",
"php_fpm_executes_configured_directory_index",
"php_fpm_failure_returns_gateway_error_instead_of_php_source",
"inside_root_symlink_policy_allows_only_targets_beneath_root",
"secure filesystem confinement currently requires a Unix platform")
need(proxy,
"if let crate::config::CompiledSource::WebServer(service) = &route.source",
"let static_method = request.method().clone()",
"crate::web_server::serve(",
"static_headers",
"response.extensions_mut().insert(StaticSourceResponse)",
"let custom_error =",
"crate::pages::replace_error",
"apply_response_rules(",
"finish_headers(",
"crate::compression::encode(")
assert "crate::web_server::serve(service, &request" not in proxy
assert proxy.index("basic_identity =") < proxy.index("CompiledSource::WebServer(service)")
assert proxy.index("let identity =") < proxy.index("CompiledSource::WebServer(service)")
assert proxy.index("CompiledSource::WebServer(service)") < proxy.index("let (backend, backend_lease, affinity_cookie)")
custom_error_pos = proxy.index("let custom_error =")
assert proxy.find("apply_response_rules(", custom_error_pos) > custom_error_pos
need(acl, "ambiguous encoded path character", '"/%2e%2e/secret"', '"/a%2fb"', '"/a%5cb"', '"/bad%"')
need(admin, '"/web-server"', '"/assets/web-server.js"', '"web-services"', '"vhost_metrics"')
need(html, 'data-panel="web-server"', 'id="panel-web-server"', 'id="web-service-dialog"',
'id="web-service-tabs"', 'data-web-service-page="general"', 'data-web-service-page="routing"',
'data-web-service-page="mime"', 'data-web-service-page="cache"', 'data-web-service-page="delivery"',
'data-web-service-page="php"', 'data-web-service-page="security"', 'data-web-service-page="htaccess"',
'id="web-service-htaccess-enabled"', 'id="web-service-htaccess-help"', 'id="htaccess-help-dialog"', 'id="web-service-try-files"',
'id="web-service-cache-rules"', 'id="web-service-precompressed"', 'id="web-service-php-enabled"',
'id="web-service-php-pass-authorization"', 'id="web-service-php-body-limit"',
'id="web-service-php-header-limit"', 'id="web-service-php-stderr-limit"',
'id="web-service-php-custom-params"', 'id="web-service-php-values"', 'id="php-help-dialog"',
'id="web-service-symlink-policy"', 'id="web-service-preset"', 'id="web-service-preset-apply"',
'id="web-service-hardening-attacks"', 'id="web-service-hardening-headers"', 'data-logging-settings-tab="outputs"',
'data-logging-settings-tab="format"', 'data-logging-settings-tab="errors"')
need(settings_ui, "selectLoggingTab", 'data-logging-settings-page', 'selectLoggingTab("outputs")')
need(web_ui, 'saveSection("web-services"', "directory_listing", "web_service_id", "mime_types", "default_mime_type",
"cache_control", "cache_rules", "response_headers", "try_files", "rewrites", "precompressed",
"download_extensions", "symlink_policy", "php", "htaccess", "hardening", "presetConfig", "phpFrontController", "staticCacheRules",
"pass_authorization", "max_request_body_bytes", "max_response_header_bytes", "stderr_log_bytes", "custom_params", "php_values",
"syncHtaccess", "htaccess-help-dialog", "php-help-dialog", "parseFastCgiParams", "parsePhpValues", "validatePhpCustomData", "activateTab", "initTabs", "presetChanged",
'button = el("button", t(key), "quiet")', 'button.dataset.tab = name', 'button.setAttribute("role", "tab")', "vhost-detail",
'el("span", t(key), "small-label")', "if (envelope) render();")
need(logging, '"route"', '"cache_effective_enabled"', '"transform_effective_enabled"', 'value["cache_enabled"]', 'value["transform_enabled"]')
need(metrics, "pub struct VhostMetricsView", "ewma_duration_us", "pub fn view(&self) -> VhostMetricsView")
assert 'item.append(text("span"' not in web_ui
need(app_ui, 'function showStartupFailure(error)', '$("login").hidden = true', 'const info = await api("/api/auth/session")')
assert '$("login").hidden = false; document.documentElement.classList.remove("app-loading")' not in app_ui
need(ui, 'type: "web_server"', "web_service_id", "sourceReverseProxy", "vhBackendRequired", "backend-diagnostics-card", "status.vhost_metrics", "vhostTrafficRate")
assert "WebService" in openapi.get("components", {}).get("schemas", {})
web_service_schema = openapi["components"]["schemas"]["WebService"]
assert web_service_schema["properties"]["index"].get("minItems", 0) == 0
assert web_service_schema["properties"]["index"]["maxItems"] == 16
assert web_service_schema["properties"]["mime_types"]["maxProperties"] == 64
assert web_service_schema["properties"]["default_mime_type"]["default"] == "application/octet-stream"
assert web_service_schema["properties"]["cache_control"]["nullable"] is True
for prop in ["cache_rules", "response_headers", "try_files", "rewrites", "precompressed", "download_extensions", "symlink_policy", "php", "htaccess", "hardening"]:
assert prop in web_service_schema["properties"], prop
assert web_service_schema["properties"]["symlink_policy"]["default"] == "deny"
assert web_service_schema["properties"]["php"]["properties"]["enabled"]["default"] is False
php_schema = web_service_schema["properties"]["php"]["properties"]
assert php_schema["pass_authorization"]["default"] is True
assert php_schema["max_request_body_bytes"]["default"] == 0
assert php_schema["max_response_header_bytes"]["default"] == 65536
assert php_schema["stderr_log_bytes"]["default"] == 4096
assert php_schema["custom_params"]["maxProperties"] == 64
assert php_schema["php_values"]["maxProperties"] == 64
assert web_service_schema["properties"]["htaccess"]["properties"]["enabled"]["default"] is False
assert web_service_schema["properties"]["htaccess"]["properties"]["filename"]["default"] == ".htaccess"
assert web_service_schema["properties"]["hardening"]["properties"]["block_common_attacks"]["default"] is False
assert web_service_schema["properties"]["hardening"]["properties"]["strip_unnecessary_headers"]["default"] is False
htaccess_source = rust_source(ROOT, "src/web_server/htaccess.rs")
need(htaccess_source, "MAX_HTACCESS_FILES", "MAX_HTACCESS_TOTAL_BYTES", "RewriteEngine", "RewriteCond", "RewriteRule", "DirectoryIndex", "AddType", "AddHandler", "ExpiresByType", "Require all granted/denied", "general Files/FilesMatch semantics are not supported", "security/handler directive", "apply_response", "apply_rewrites")
assert "Source" in openapi.get("components", {}).get("schemas", {})
assert "client_ips" in openapi["components"]["schemas"]["TrafficCounters"]["properties"]
assert (ROOT / "docs/WEB_SERVER.md").is_file()
assert (ROOT / "docs/PHP_FPM.md").is_file()
assert (ROOT / "docs/V0.25.5_AUDIT.md").is_file()
assert (ROOT / "docs/V0.25.4_AUDIT.md").is_file()
assert (ROOT / "docs/V0.25.2_AUDIT.md").is_file()
assert (ROOT / "docs/V0.25.0_AUDIT.md").is_file()
assert (ROOT / "docs/TODO.md").is_file()
docs_html = (ROOT / "src/admin/web/docs.html").read_text()
docs_js = (ROOT / "src/admin/web/docs.js").read_text()
need(docs_html, "docs-tab-reader", "docs-reader-content", "docs-tab-api")
need(docs_js, 'get("/docs/content.json")', "renderMarkdown", "selectDocument")
print("PASS: 0.25 Web Server separation, pipeline, filesystem, HTTP, UI/API and regression contracts are present.")