189 lines
12 KiB
Python
Executable File
189 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.1</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.1_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.")
|