115 lines
5.8 KiB
Python
Executable File
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.')
|