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

115 lines
5.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Fail-closed source regression checks. Not a replacement for running Rust tests."""
from pathlib import Path
import re
root = Path(__file__).resolve().parents[2]
def read(name):
return (root / name).read_text(encoding="utf-8")
def must(source, *text):
for item in text:
if item not in source:
raise AssertionError(f"missing security invariant: {item}")
router = read('src/admin/router.rs')
access = read('src/admin/access.rs')
session = read('src/admin/session.rs')
proxy = read('src/proxy/forward_entry.rs')
maintenance = read('src/proxy/cache_http_invalidation.rs')
operations = read('src/admin/operations.rs')
cache_policy = read('src/cache/model/http_invalidation.rs')
app = read('src/runtime/types.rs') + read('src/runtime/constructors.rs')
must(router,
'fn anonymous_allowed', 'panel_document_path(path)',
'/api/auth/status', '/api/auth/setup', '/api/auth/login',
'.layer(middleware::from_fn_with_state(state.clone(), guard))',
'"content-security-policy"', '"x-frame-options"',
'"x-content-type-options"', '"no-referrer"',
'"/api/system-info", get(system_info)',
'"/api/events", get(events)',
'"/api/cache/invalidate"',
'"/api/config", get(configuration).merge(put(update))')
must(session,
'if !public && session.is_none()',
'if unsafe_method && !external_certificate_api',
'single_header(headers, "origin")',
'single_header(headers, "x-rustproxy-request") != Some("1")',
'single_header(headers, "x-csrf-token")',
'auth::equal_secret',
'session_at_with_browser_lifetime',
'secure(next.run(request).await)',
'h == "cross-site"')
must(access, 'admin_request_authority', 'admin_origin_matches',
'trusted_admin_proxy', 'single_header(headers, "x-forwarded-host")',
'certificate_api_credentials', 'valid_certificate_api_token')
must(session, 'verify_certificate_api_credential')
# Only the intended bootstrap/discovery APIs may appear inside the anonymous allowlist.
allowlist = router.split('fn anonymous_allowed(', 1)[1].split('\n}', 1)[0]
allowed_api_paths = set(re.findall(r'"(/api/[^" ]+)"', allowlist))
assert allowed_api_paths == {'/api/auth/status', '/api/auth/setup', '/api/auth/login'}, allowed_api_paths
must(maintenance,
'permits_http_invalidation(client_ip)',
'snapshot.forwarding.trusted(peer_ip)',
'return error(StatusCode::FORBIDDEN, "RustProxy: missing trusted proxy client identity',
'cache_http_invalidation_request(request.method()',
'duplicate BAN/PURGE control header',
'request.body().is_end_stream()',
'get_all(http::header::CONTENT_LENGTH)',
'http::header::TRANSFER_ENCODING',
'X-RustProxy-Confirm-Wide: 1',
'"{scheme}://{authority}{target}"',
'cache_invalidation_slots.clone().try_acquire_owned()',
'state.store.audit(',
'tokio::task::spawn_blocking',
'let _permit = permit;',
'StatusCode::UNPROCESSABLE_ENTITY',
'StatusCode::TOO_MANY_REQUESTS')
# Axum 0.8 requires the `forward` fallback future to be Send. Its Body is
# not Sync, so an async helper must own Request<Body> rather than retain
# &Request<Body> across the audit/cache await points.
async_maintenance = maintenance.split('async fn cache_http_invalidation(', 1)[1].split(') -> Response {', 1)[0]
assert 'request: Request,' in async_maintenance, 'cache maintenance must own Request<Body> in its async future'
assert 'request: &Request' not in async_maintenance, 'borrowed Request<Body> makes fallback handler non-Send'
must(maintenance, 'let method = request.method().clone();', 'drop(request);')
assert maintenance.index('drop(request);') < maintenance.index('state.store.audit('), 'drop body before asynchronous audit'
assert 'frontend, &host, request, context.client_ip' in proxy, 'forward must transfer request ownership'
must(proxy, 'snapshot.host_allowed(&host, context.client_ip)',
'crate::acl::allows_with_country(',
'context.client_ip, peer.0.ip(), context.scheme',
'return cache_http_invalidation(')
assert proxy.find('snapshot.host_allowed(&host, context.client_ip)') < proxy.find('return cache_http_invalidation(')
assert proxy.find('if cache_http_invalidation_method(request.method())') < proxy.find('return cache_http_invalidation(')
must(cache_policy, 'http_invalidation_allowed_cidrs.len() <= 32',
'net.prefix_len() > 0', 'pub fn permits_http_invalidation')
must(app, 'cache_invalidation_slots: Arc<Semaphore>',
'cache_invalidation_slots: Arc::new(Semaphore::new(2))')
must(operations, 'cache_invalidation_slots.clone().try_acquire_owned()',
'tokio::task::spawn_blocking(move ||',
'"cache.invalidate"', '"cache.purge"',
'"cache.ban.remove"')
start = operations.index('async fn cache_ban_delete(')
end = operations.index('async fn active_tunnels(', start)
assert operations[start:end].index('.audit(') < operations[start:end].index('remove_ban(id)'), 'BAN deletion must audit before mutation'
# Frontend: no HTML-string DOM injection or dynamic code execution in first-party scripts.
js_files = list((root/'src/admin/web').rglob('*.js'))
for path in js_files:
text = path.read_text(encoding='utf-8')
dangerous = (r'\.innerHTML\s*=', r'\.outerHTML\s*=', r'\.insertAdjacentHTML\s*\(',
r'\beval\s*\(', r'\bnew\s+Function\s*\(')
for pattern in dangerous:
if re.search(pattern, text):
raise AssertionError(f'unsafe DOM/code sink: {path.relative_to(root)} matches {pattern}')
html = read('src/admin/web/index.html')
for fragment in ['class="metric-info cache-maintenance-info"',
'cacheHttpInvalidationCurlLabel',
'curl -i -X PURGE', 'curl -i -X BAN']:
must(html, fragment)
print(f'PASS: admin/HTTP maintenance security source contracts; scanned {len(js_files)} first-party JS files. Actual Rust/wire behavior not tested.')