diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e3be15..9ea6d22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: tools/test_ai_tools.py \ tools/validate_edge_templates.py PYTHONPATH=tools python3 tools/test_ai_tools.py - python3 tools/validate_edge_templates.py --config configs/example.json + sudo python3 tools/validate_edge_templates.py --config configs/example.json python3 tools/assert_defense_bench.py \ --audit-tracked-artifacts benchmark_results \ --artifact-manifest benchmark_results/defense_artifacts_manifest.json diff --git a/Cargo.lock b/Cargo.lock index e366392..28e7f4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,6 +6,7 @@ version = 4 name = "altura-prot" version = "0.1.0" dependencies = [ + "blake3", "bytes", "clap", "http", @@ -68,18 +69,60 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.1" @@ -126,6 +169,21 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "errno" version = "0.3.14" @@ -136,6 +194,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "futures-channel" version = "0.3.32" @@ -374,6 +438,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" diff --git a/Cargo.toml b/Cargo.toml index ac954cf..b9f8a58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ license = "MIT" [dependencies] clap = { version = "4", features = ["derive"] } bytes = "1" +blake3 = "1" http = "1" http-body-util = "0.1" hyper = { version = "1", features = ["client", "http1", "server"] } diff --git a/README.md b/README.md index 378fc29..9e41c4f 100644 --- a/README.md +++ b/README.md @@ -59,13 +59,13 @@ deterministic generator otherwise. - HTTP/1 reverse proxy with per-client prefix, per-signature, per-path-shape route-family, trusted-proxy aggregate, and global token-bucket limits. - Bounded rate-limiter state that evicts stale buckets but denies new active keys when a shard is full, so high-cardinality rotation cannot reset existing hot IP, signature, or path-shape buckets. -- HTTP method allowlist, Host header validation, bounded trusted `X-Forwarded-For` parsing, forwarded-header/client-IP spoof sanitization, normalized request-signature and path-shape rate caps that collapse long dynamic tokens plus high-confidence short tokens without merging version segments such as `/api/v1` and `/api/v2`, bounded sibling-churn limiting for short lowercase token rotation under one parent route, trusted-proxy aggregate rate and in-flight caps, downstream keep-alive disabled by default, downstream write timeout for slow readers, raw initial HTTP/1 header/framing validation with incremental delimiter scanning plus hard byte, per-field, and count caps, request framing validation, request content-encoding policy, default chunked request-body rejection with opt-in, default `Expect` rejection with opt-in `100-continue`, bounded `Range` request policy, default origin `Accept-Encoding` stripping with opt-in passthrough, connection-open rate caps, active connection caps, sharded `SO_REUSEPORT` accept sockets, in-flight upstream request caps, upstream connect timeout, path-shape-scoped passive upstream failure circuit breaker, per-connection request caps, request and upstream response header byte/field/count caps and timeouts, default-stripped HTTP trailers with opt-in capped forwarding, request/upstream body recent-window minimum data-rate guards, request body guardrails, and upstream response body guardrails. +- HTTP method allowlist, Host header validation, bounded trusted `X-Forwarded-For` parsing, forwarded-header/client-IP spoof sanitization, normalized request-signature and path-shape rate caps that collapse long dynamic tokens plus high-confidence short tokens without merging version segments such as `/api/v1` and `/api/v2`, bounded sibling-churn limiting for short lowercase token rotation under one parent route, trusted-proxy aggregate rate and in-flight caps, downstream keep-alive disabled by default, downstream write timeout for slow readers, raw initial HTTP/1 header/framing validation with incremental delimiter scanning plus hard byte, per-field, and count caps, request framing validation, request content-encoding policy, default chunked request-body rejection with opt-in, default `Expect` rejection with opt-in `100-continue`, bounded `Range` request policy, default origin `Accept-Encoding` stripping with opt-in passthrough, connection-open rate caps, active connection caps, sharded `SO_REUSEPORT` accept sockets, in-flight upstream request caps, upstream connect timeout, path-shape-scoped passive upstream failure circuit breaker, per-connection request caps, request and upstream response header byte/field/count caps, framing validation, and timeouts, default-stripped HTTP trailers with opt-in capped forwarding, request/upstream body recent-window minimum data-rate guards, request body guardrails, and upstream response body guardrails. - Rate-limited admin health checks and token-protected Prometheus metrics. - Raw TCP proxy with per-client-prefix and global connection-rate limits, global/per-client-prefix concurrent-connection caps, sharded `SO_REUSEPORT` accept sockets, outbound connect timeout, idle timeout, optional per-direction minimum data-rate guard, and max connection duration. - Runtime `RLIMIT_NOFILE` preflight and capacity validation so configured connection caps are backed by an explicit file-descriptor floor. - Config preflight that rejects oversized/non-regular/malformed config inputs and out-of-range DDoS-critical knobs (rate caps, resource ceilings, header/body/trailer/metadata caps, timeouts, connection lifetimes, event-log and adaptive-window and filter-TTL bounds, circuit settings, method/Host allowlists, trusted-proxy and admin control-plane settings) so unsafe values fail startup instead of silently disabling protection; `0` remains the explicit per-rate-bucket disable value. - SIGINT/SIGTERM shutdown handling so service-manager stops enter the same listener-drain path as Ctrl-C. -- Static JSON filters for known bad HTTP patterns, plus bounded and validated static/runtime filter rules with compiled header/user-agent match data and snapshot-based rule evaluation on the request path. +- Static JSON filters for known bad HTTP patterns, plus bounded and validated static/runtime filter rules with response-header-safe rule IDs, compiled header/user-agent match data, and snapshot-based rule evaluation on the request path. - Adaptive learned filters that stay dormant, activate during matching floods through rolling token-bucket counters, reclaim idle signature/path-shape windows under capacity pressure, preserve recent evidence, and stop admitting fresh detector keys when a shard is full of recent windows. - JSONL attack event logs for offline/nearline analysis, with bounded user-controlled fields, a bounded nonblocking queue capped at `8192` owned events, worker-side JSON serialization, bounded flush cadence, and byte/backup-count-capped rotation. - Optional CodexSDGate analyzer that converts attack logs into constrained adaptive filter rules using either a subscription agent CLI you already logged into (Codex, Claude, OpenCode, Cursor, Grok — wrapped the way T3 Code does) or an API key (OpenAI, Anthropic, Gemini, OpenRouter). diff --git a/benchmark_results/defense_artifacts_manifest.json b/benchmark_results/defense_artifacts_manifest.json index ecd2783..cd0ee50 100644 --- a/benchmark_results/defense_artifacts_manifest.json +++ b/benchmark_results/defense_artifacts_manifest.json @@ -1,8 +1,93 @@ { "historical_artifacts": { + "defense_bench_advanced_codex_20260621.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_advanced_codex_20260621_v2.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_advanced_deterministic_20260621.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_advanced_deterministic_20260621_v2.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_absolute_form_host_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_admin_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_downstream_keepalive_disabled.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_event_log_flush.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_event_log_rotation.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_event_log_rotation_all.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_forwarded_header_sanitization.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_framing_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_guardrails.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_header_count.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_header_timeout.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_host_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_inflight_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_initial_framing_precheck.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_listen_backlog.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_log_suppression.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_method_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_rate_fairness.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_stream_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_tcp_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_tcp_idle_timeout.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_tracked_ip_cap.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_upstream_pool.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_upstream_response_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260621_uri_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_accept_encoding.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_adaptive_window_cap.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_body_min_rate.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_downstream_write_timeout.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_event_log_async_queue.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_event_log_field_bounds.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_expect_guard.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_extended_forwarded_header_sanitization.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_filter_hot_path.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_forwarded_header_bounds.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_http_connection_rate.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_range_guard.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_request_content_encoding.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_runtime_filter_bounds.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_runtime_nofile.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_runtime_nofile_capacity.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_sigterm_shutdown.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_tcp_global_rate.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_tcp_min_rate.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_trailer_policy.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_trusted_proxy_aggregate_rate.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_20260622_upstream_header_guard.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_absolute_form_host_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_downstream_keepalive_disabled.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_forwarded_header_sanitization.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_framing_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_header_count.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_header_timeout.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_host_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_initial_framing_precheck.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_listen_backlog.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_method_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_tcp_idle_timeout.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_tracked_ip_cap.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_upstream_pool.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_upstream_response_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_all_deterministic_core_20260621_uri_guard.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_ci_deterministic_20260622.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_ci_deterministic_20260623.json": "Historical debug or transitional benchmark snapshot retained as evidence but not current-verifiable under provenance and measured-benign assertions.", + "defense_bench_ci_deterministic_20260623_distributed_slow.json": "Historical debug or transitional benchmark snapshot retained as evidence but not current-verifiable under provenance and measured-benign assertions.", "defense_bench_codex_20260618_interpreted.json": "Historical Codex benchmark snapshot from before generated_at_utc/source_tree provenance and effective_target_score CI assertions.", + "defense_bench_codex_20260621.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", "defense_bench_codex_rerun_20260618.json": "Historical Codex benchmark snapshot from before generated_at_utc/source_tree provenance and effective_target_score CI assertions.", "defense_bench_codex_smart_detailed_20260618.json": "Historical Codex benchmark snapshot from before generated_at_utc/source_tree provenance and effective_target_score CI assertions.", - "defense_bench_codex_xff_poly_pathshape_20260618.json": "Historical Codex benchmark snapshot from before generated_at_utc/source_tree provenance and effective_target_score CI assertions." + "defense_bench_codex_xff_poly_pathshape_20260618.json": "Historical Codex benchmark snapshot from before generated_at_utc/source_tree provenance and effective_target_score CI assertions.", + "defense_bench_core_ci_deterministic_20260622.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_core_downstream_keepalive_disabled_accept_spray_xff.json": "Historical debug or transitional benchmark snapshot retained as evidence but not current-verifiable under provenance and measured-benign assertions.", + "defense_bench_core_downstream_keepalive_disabled_chunk1.json": "Historical debug or transitional benchmark snapshot retained as evidence but not current-verifiable under provenance and measured-benign assertions.", + "defense_bench_core_downstream_keepalive_disabled_chunk2.json": "Historical debug or transitional benchmark snapshot retained as evidence but not current-verifiable under provenance and measured-benign assertions.", + "defense_bench_core_downstream_keepalive_disabled_chunk3.json": "Historical debug or transitional benchmark snapshot retained as evidence but not current-verifiable under provenance and measured-benign assertions.", + "defense_bench_core_downstream_keepalive_disabled_chunk4.json": "Historical debug or transitional benchmark snapshot retained as evidence but not current-verifiable under provenance and measured-benign assertions.", + "defense_bench_core_downstream_keepalive_disabled_chunk5.json": "Historical debug or transitional benchmark snapshot retained as evidence but not current-verifiable under provenance and measured-benign assertions.", + "defense_bench_core_downstream_keepalive_disabled_chunk6.json": "Historical debug or transitional benchmark snapshot retained as evidence but not current-verifiable under provenance and measured-benign assertions.", + "defense_bench_core_downstream_keepalive_disabled_smart_api_mix.json": "Historical debug or transitional benchmark snapshot retained as evidence but not current-verifiable under provenance and measured-benign assertions.", + "defense_bench_core_event_log_flush_dictionary_2s.json": "Historical debug or transitional benchmark snapshot retained as evidence but not current-verifiable under provenance and measured-benign assertions.", + "defense_bench_deterministic_20260621.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_dictionary_slug_log_suppression_20260621.json": "Historical June 21 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_path_shape_layer_20260622.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions.", + "defense_bench_trusted_proxy_strong_20260622.json": "Historical June 22 benchmark snapshot from before generated_at_utc/source_tree provenance and current effective_target_score/measured-benign assertions." } } diff --git a/docs/AI_PROVIDERS.md b/docs/AI_PROVIDERS.md index c8a7c7d..aa6ed16 100644 --- a/docs/AI_PROVIDERS.md +++ b/docs/AI_PROVIDERS.md @@ -164,7 +164,7 @@ regardless of this setting — the threshold only governs the out-of-band AI lay CodexSDGate defaults to high-confidence learning. Deterministic fallback rules are created from strong evidence such as `per_ip_rate_limited`, `global_rate_limited`, `signature_rate_limited`, `path_shape_rate_limited`, `trusted_proxy_rate_limited`, `rate_limited`, or `filter_block` events. Observed-only high volume is treated as weak evidence because legitimate traffic can also be bursty. -Provider output is also merged with deterministic coverage. In strict mode this adds high-confidence strong-signal signatures. When `--learn-observed` is explicitly enabled, it also adds deterministic observed-learning coverage so a model omission does not leave a known high-volume path shape unprotected. Use `--disable-strong-coverage` only when evaluating raw provider behavior. +Provider output is also merged with deterministic coverage. In strict mode this adds high-confidence strong-signal signatures. When `--learn-observed` is explicitly enabled, it also adds deterministic observed-learning coverage so a model omission does not leave a known high-volume path shape unprotected. CodexSDGate drops empty, oversized, or malformed provider matchers, and normalizes plus deconflicts learned rule IDs before writing `runtime/filters.json`, so provider-generated names remain safe for the proxy's `x-altura-filter` response header and malformed optional matchers cannot make a whole reload fail. Use `--disable-strong-coverage` only when evaluating raw provider behavior. Learned adaptive filters are preserved in `runtime/filters.json` even when there are no new attack events. This lets the proxy keep a dormant library of previous attack signatures and reactivate the same filter quickly when the pattern returns. Use `--max-filters` to cap the retained filter library size; the proxy also enforces `filters.max_runtime_file_bytes` and `filters.max_runtime_filters` during reload. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index beda32f..f2951f4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -17,22 +17,22 @@ The hot path must remain deterministic. AI providers never run inline with clien ## HTTP Path - Accept HTTP/1 connections with Hyper on listener sockets created with explicit `listen_backlog`. `http.accept_shards` can create multiple `SO_REUSEPORT` listener sockets for the same address so Linux can distribute connection accepts during churn while all shards share the same userspace limiter state. -- Resolve the client identity from the TCP peer, or from the configured forwarded client-IP header only when the peer is in a configured trusted proxy range. Trusted forwarded chains are capped by byte length and hop count, and malformed or excessive chains are rejected before signature, filter, rate-limit, or upstream work. When trusted identity comes from `X-Forwarded-For`, AlturaProt canonicalizes duplicate XFF field lines into the same ordered comma-separated chain used for client identity before forwarding upstream. When trusted identity comes from a different configured header, such as `CF-Connecting-IP`, that header must be a single non-empty IP value: duplicate field lines and comma lists fail closed. For accepted custom identity headers, AlturaProt strips inbound XFF and synthesizes a clean `resolved-client, peer` XFF chain instead of preserving spoofable XFF values that were not used for identity. +- Resolve the client identity from the TCP peer, or from the configured forwarded client-IP header only when the peer is in a configured trusted proxy range. Trusted forwarded chains are capped by byte length and hop count, and malformed or excessive chains are rejected before signature, filter, rate-limit, or upstream work. When trusted identity comes from `X-Forwarded-For`, AlturaProt canonicalizes duplicate XFF field lines into the same ordered comma-separated chain used for client identity before forwarding upstream. When trusted identity comes from a different configured header, such as `CF-Connecting-IP`, that header must be a single non-empty raw IP literal: duplicate field lines, comma lists, port-bearing values, and bracketed address forms fail closed. For accepted custom identity headers, AlturaProt strips inbound XFF and synthesizes a clean `resolved-client, peer` XFF chain instead of preserving spoofable XFF values that were not used for identity. - Reject malformed, oversized, duplicate, or invalid trusted-proxy entries at config load, and reject global trusted-proxy ranges on non-loopback HTTP listeners so direct clients cannot rotate spoofed forwarded identities around per-client rate buckets. -- Build a stable request signature from method, normalized path, query shape, user-agent family, and accept header. Path-shape derivation collapses numeric, UUID, hex, long dynamic, and high-confidence short token segments while preserving obvious route versions such as `v1` and `v2`. Short all-lowercase two- and three-character segments stay distinct for normal routing precision, but also feed a bounded sibling-churn parent key used by the path-shape budget when many different short siblings appear under the same parent route. +- Build a stable request signature from method, normalized path, query shape, user-agent family, and accept header using a 128-bit BLAKE3 fingerprint; legacy FNV-1a signature filters are still recognized during matching and adaptive activation so existing learned rules keep working after upgrade. Path-shape derivation collapses numeric, UUID, hex, long dynamic, and high-confidence short token segments while preserving obvious route versions such as `v1` and `v2`. Short all-lowercase two- and three-character segments stay distinct for normal routing precision, but also feed a bounded sibling-churn parent key used by the path-shape budget when many different short siblings appear under the same parent route. - Observe the signature in a rolling adaptive detector with bounded signature and path-shape window maps. Signature activation uses token-bucket style counters rather than a tumbling one-second reset. Path-shape windows still track and sample observed route-family pressure for analyzer visibility, but broad path-shape filter activation requires strong evidence such as deterministic rate-limit, trusted-proxy aggregate, filter, or body-guard denial. There are no hardcoded benign route-family exemptions, and high-cardinality fresh signatures or path shapes cannot evict recent detector windows; full shards reclaim idle windows and otherwise stop admitting new detector keys while preserving existing evidence. - Write adaptive attack events to JSONL through a bounded queue and dedicated writer thread. User-controlled event fields are truncated before enqueueing, so a flood cannot turn one event into an unbounded memory or disk payload. `adaptive.event_log_queue_capacity` is capped at `8192` events; request workers use nonblocking enqueue of owned events, and when the queue is saturated or the writer discards an event after an open/rotate/write failure, `altura_event_log_dropped` increments instead of backpressuring the hot path. JSON serialization, file writes, flushes, and rotation happen in the writer thread. The first event flushes immediately for analyzer visibility; high-rate bursts flush at most once per positive `adaptive.event_log_flush_interval_ms`, idle flush timeouts still drain batched events, Unix log files are created/fixed to owner-only permissions, and the log rotates to numbered backups before positive `adaptive.event_log_max_bytes` is crossed. -- Enforce allowed HTTP methods, default rejection of method-override headers unless `http.allow_method_override_headers` is enabled, Host header validity/allowlist, absolute-form request-target authority validation with only `http` and `https` schemes accepted, forwarded-header and client-IP/proxy identity header sanitization, trusted-forwarded-header byte/hop bounds, downstream HTTP/1 keep-alive disabled by default, initial raw HTTP/1 framing precheck, request framing validation for exposed `Content-Length` and `Transfer-Encoding` ambiguity, default rejection of chunked request bodies unless `http.allow_chunked_request_bodies` is enabled, default rejection of compressed request `Content-Encoding` unless `http.allow_compressed_request_bodies` is enabled, default `Expect` rejection unless `http.allow_expect_continue` is enabled, bounded `Range` requests before origin work, default removal of client `Accept-Encoding` before origin work unless `http.forward_accept_encoding` is enabled, header read timeout capped at `60000` ms, max header bytes, max header field-line bytes, max header count, startup-capped request-target/query/path segment limits including absolute-form scheme and authority bytes, default-stripped request trailers with opt-in capped forwarding and startup-capped trailer limits, max request body by `Content-Length` and streamed bytes with startup values capped at `1073741824` bytes, request-body idle timeout capped at `60000` ms, request-body recent-window minimum data-rate floor capped at `1048576` B/s with post-grace accounting measured from the grace deadline, max request count per connection capped at `10000`, and max connection duration capped at `3600` seconds. The raw initial precheck searches for the header terminator only across the newly appended bytes plus the required three-byte overlap, treats a terminator that arrives after `http.max_header_bytes` as oversized, and rejects any first-request header field above `http.max_header_line_bytes`; parsed keep-alive follow-up requests run the same field-line check before client-IP, Host, signature, filter, rate-limit, or upstream work. +- Enforce allowed HTTP methods, default rejection of method-override headers unless `http.allow_method_override_headers` is enabled, Host header validity/allowlist, absolute-form request-target authority validation with only `http` and `https` schemes accepted, forwarded-header and client-IP/proxy identity header sanitization, byte-parsed `Connection` hop-by-hop stripping, trusted-forwarded-header byte/hop bounds, downstream HTTP/1 keep-alive disabled by default, initial raw HTTP/1 framing precheck, request framing validation for exposed `Content-Length` and `Transfer-Encoding` ambiguity including bounded empty transfer-coding list elements, default rejection of chunked request bodies unless `http.allow_chunked_request_bodies` is enabled, default rejection of compressed request `Content-Encoding` unless `http.allow_compressed_request_bodies` is enabled, default `Expect` rejection unless `http.allow_expect_continue` is enabled, bounded `Range` requests before origin work, default removal of client `Accept-Encoding` before origin work unless `http.forward_accept_encoding` is enabled, header read timeout capped at `60000` ms, max header bytes, max header field-line bytes, max header count, startup-capped request-target/query/path segment limits including absolute-form scheme and authority bytes, default-stripped request trailers with opt-in capped forwarding and startup-capped trailer limits, max request body by `Content-Length` and streamed bytes with startup values capped at `1073741824` bytes, request-body idle timeout capped at `60000` ms, request-body recent-window minimum data-rate floor capped at `1048576` B/s with post-grace accounting measured from the grace deadline, max request count per connection capped at `10000`, and max connection duration capped at `3600` seconds. The raw initial precheck searches for the header terminator only across the newly appended bytes plus the required three-byte overlap, treats a terminator that arrives after `http.max_header_bytes` as oversized, rejects invalid first-request field names, and rejects any first-request header field above `http.max_header_line_bytes`; parsed keep-alive follow-up requests run the same field-line check before client-IP, Host, signature, filter, rate-limit, or upstream work. - Apply per-peer and global HTTP connection-open token buckets before spawning the connection task, so short-lived churn that never reaches request parsing is still throttled. Obvious global connection saturation is checked before per-client state insertion, and per-client connection-open tokens are consumed only after the global connection-rate and active-connection gates also admit the open. - Serve `GET` admin health and metrics endpoints only after per-client, signature, global, and trusted-proxy request rate checks; metrics require exactly one `x-altura-admin-token` header matching `http.admin_token`. - Apply normalized request-signature, normalized path-shape, short-token sibling-churn, global, and per-client-prefix token-bucket limits with two-phase admission before filter evaluation, so over-budget traffic returns `429` before taking runtime/static filter read locks or scanning learned rules. A request denied by one rate dimension does not consume tokens from another dimension, and the global request bucket mutex is held only while checking or consuming that bucket, not across per-client shard cleanup. Client IPs are normalized with `http.limits.ipv4_prefix_len` and `http.limits.ipv6_prefix_len` before per-client request, connection-open, and upstream in-flight buckets, then hash-sharded so prefix-normalized IPv4 networks do not collapse into one low-bit shard; defaults keep IPv4 exact at `/32` and aggregate IPv6 by `/64`. The signature bucket catches one repeated method/path/query/header shape, while the path-shape bucket catches polymorphic floods that rotate long or high-confidence token segments inside the same route family. A separate bounded sibling-churn bucket uses the same `path_shape_rps`, `path_shape_burst`, and `max_tracked_path_shapes` settings to catch lowercase short-token rotation such as many distinct `/api/{ab,cd,ef}` siblings without blindly merging every stable short route. Short-token sibling denials are observed by the adaptive detector and event log using the parent descriptor such as `/api/:short-token`, and explicit filter rules with that `path_shape` can also match lowercase short siblings through the same parent descriptor. Strong adaptive path-shape activation is independent from the event sample cap, so throttled sample logging cannot suppress a learned block when strong evidence appears. When a trusted proxy supplies a forwarded client IP, also apply separate per-proxy aggregate request-rate and upstream in-flight caps keyed by the exact immediate peer IP so XFF rotation cannot bypass every client-prefix bucket or monopolize origin concurrency from one edge hop. -- Apply static and active adaptive filters only after the deterministic request-rate buckets admit the request. Loaded rules are count-capped, require at least one bounded matcher, require bounded block responses, keep validated header names and case-insensitive match needles in compiled form, and request path shape is cached once per evaluation so large learned rule sets do not repeatedly parse header names, allocate lowercase strings, or recompute the same derived path shape. Request workers clone the current rule-list snapshot under a short read lock, then scan rules without holding the rule-list lock; adaptive activation updates per-rule atomic deadlines, and reload swaps in a new snapshot while preserving active rule deadlines. This keeps learned-filter activation and reloads from blocking in-flight filter scans on a long-held writer lock. +- Apply static and active adaptive filters only after the deterministic request-rate buckets admit the request. Loaded rules are count-capped, require at least one bounded matcher, require response-header-safe rule IDs for `x-altura-filter`, require bounded block responses, keep validated header names and case-insensitive match needles in compiled form, and request path shape is cached once per evaluation so large learned rule sets do not repeatedly parse header names, allocate lowercase strings, or recompute the same derived path shape. Request workers clone the current rule-list snapshot under a short read lock, then scan rules without holding the rule-list lock; adaptive activation updates per-rule atomic deadlines, and reload swaps in a new snapshot while preserving active rule deadlines. This keeps learned-filter activation and reloads from blocking in-flight filter scans on a long-held writer lock. - Bound per-client, signature, and path-shape limiter state with `max_tracked_ips`, `max_tracked_signatures`, and `max_tracked_path_shapes`. Rate buckets evict stale entries but deny new keys when a shard is full of active buckets, so high-cardinality rotation cannot reset an existing hot bucket by forcing oldest-active eviction. Connection and in-flight limiters evict stale or inactive entries and deny new entries only when a shard is full of active entries; globally saturated opens or in-flight requests are rejected before creating avoidable per-client state. Limiter cleanup probes are capped per insert attempt, so a new attacker-controlled key cannot force a full large-shard scan while holding a limiter mutex. - Acquire a global/per-client in-flight upstream request permit before proxying. The permit is held until the upstream response body is dropped, so slow origin responses cannot consume unbounded proxy/origin capacity. - Bound new upstream TCP connection attempts with `upstream_connect_timeout_ms` before the broader upstream response-header wait budget applies. `upstream_connect_timeout_ms` and `upstream_timeout_ms` are both capped at `60000` ms at startup so a bad config cannot pin origin in-flight permits behind minute/hour connect or response-header waits. Passive upstream failure circuits are keyed by normalized request path shape and share the `http.limits.max_tracked_path_shapes` state cap, so repeated failures in one route family do not fail-fast unrelated route families. A shape circuit opens after `upstream_failure_threshold` consecutive upstream connect/header failures or upstream response timeouts, capped at `1024`, then sheds matching origin-bound requests locally with `503`, `Retry-After: 1`, `Cache-Control: no-store`, and `Connection: close` for `upstream_failure_open_ms`, capped at `300000` ms. The shape circuit resets on a successful upstream response header for the same path shape and increments `altura_http_upstream_circuit_open` for locally shed open-circuit requests. Circuit state preserves currently open entries under high-cardinality churn; full shards reclaim closed or expired entries and otherwise skip tracking a new failing shape instead of evicting an open fail-fast route family. - Bound the upstream HTTP client idle pool with `upstream_pool_max_idle_per_host` capped at `4096` and `upstream_pool_idle_timeout_ms` capped at `60000` so origin keep-alive sockets do not accumulate or linger without operator control. Set `upstream_pool_max_idle_per_host` to `0` only when intentionally disabling idle origin pooling. - Rewrite the request to the upstream and stream the response back. -- Bound upstream response-header wait time, response header bytes/field-line/count, default-stripped response trailers with opt-in capped forwarding, response body idle time capped at `60000` ms, response body recent-window minimum data-rate floor capped at `1048576` B/s with post-grace accounting measured from the grace deadline, and response body size with startup values capped at `1073741824` bytes so a slow, stuck, malformed, or unexpectedly large origin response does not consume proxy/origin capacity indefinitely. Oversized declared upstream `Content-Length` is rejected before upstream success is recorded; unknown-length responses still use the streaming response guard. +- Bound upstream response-header wait time, response header bytes/field-line/count, response framing for duplicate or invalid `Content-Length` and `Transfer-Encoding` ambiguity, default-stripped response trailers with opt-in capped forwarding, response body idle time capped at `60000` ms, response body recent-window minimum data-rate floor capped at `1048576` B/s with post-grace accounting measured from the grace deadline, and response body size with startup values capped at `1073741824` bytes so a slow, stuck, malformed, or unexpectedly large origin response does not consume proxy/origin capacity indefinitely. Invalid upstream response framing and oversized declared upstream `Content-Length` are rejected before upstream success is recorded; unknown-length responses still use the streaming response guard. - Wrap downstream writes with `downstream_write_timeout_ms`, capped at `60000` ms, so slow-reading clients cannot keep response streams and upstream in-flight permits pinned indefinitely. ## TCP Path @@ -48,7 +48,7 @@ AlturaProt's userspace proxy is the request-aware layer, not a replacement for n ## Filter Safety -Filters are allowlisted JSON rules. Provider output is sanitized before it reaches `runtime/filters.json`, and the Rust proxy only implements the supported condition/action fields. Static config filters and runtime filters share rule validation: non-empty IDs, at least one bounded matcher, no root-wide `path_prefix: "/"`, bounded method/header matcher counts, valid header names, `action.kind: "block"`, 4xx/5xx block statuses, and bounded block bodies. Runtime reloads reject non-regular files, oversized files, invalid rules, and files with too many runtime filters; reload failures keep the last good in-memory rules. Supported header and user-agent conditions are compiled on load so request evaluation stays allocation-light. Rule-list snapshots are immutable after publication except for per-rule atomic adaptive deadlines, so request evaluation, activation, and reload have narrow synchronization surfaces. Unsupported model output is ignored. Common-looking path shapes are not source-code exemptions; if a route family needs a different tolerance, tune its rate thresholds or learned/static filters explicitly. +Filters are allowlisted JSON rules. Provider output is sanitized before it reaches `runtime/filters.json`, and the Rust proxy only implements the supported condition/action fields. Static config filters and runtime filters share rule validation: non-empty IDs, at least one bounded matcher, canonical signature matchers (`32` lowercase hex chars for current BLAKE3 signatures or `16` lowercase hex chars for legacy FNV signatures), no root-wide `path_prefix: "/"`, bounded method/header matcher counts, valid header names, `action.kind: "block"`, 4xx/5xx block statuses, and bounded block bodies. Runtime reloads reject non-regular files, oversized files, invalid rules, and files with too many runtime filters; reload failures keep the last good in-memory rules. Supported header and user-agent conditions are compiled on load so request evaluation stays allocation-light. Rule-list snapshots are immutable after publication except for per-rule atomic adaptive deadlines, so request evaluation, activation, and reload have narrow synchronization surfaces. Unsupported model output is ignored. Common-looking path shapes are not source-code exemptions; if a route family needs a different tolerance, tune its rate thresholds or learned/static filters explicitly. Adaptive rules are dormant until their learned signature crosses the configured threshold. They then activate for a TTL and expire automatically. diff --git a/docs/EDGE_PROTECTION.md b/docs/EDGE_PROTECTION.md index 8872ac6..60972d7 100644 --- a/docs/EDGE_PROTECTION.md +++ b/docs/EDGE_PROTECTION.md @@ -69,16 +69,16 @@ AlturaProt handles request-aware controls: - Configurable HTTP method allowlist before signature, filter, or upstream work. - Host header validation and optional Host allowlist before signature, filter, or upstream work, with generated close/no-store rejections. - Absolute-form request-target authority validation so proxy-form requests cannot bypass the Host allowlist with a different URI authority. -- Forwarded-header and client-IP identity sanitization so direct clients cannot spoof `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Real-IP`, standard `Forwarded`, common CDN/proxy client-IP metadata, or hop-by-hop request metadata into the origin. Invalid trusted-proxy CIDRs fail config load, and non-loopback HTTP listeners refuse global trusted-proxy ranges such as `0.0.0.0/0` or `::/0`. Trusted forwarded client-IP chains are bounded by byte length and hop count before rate-limit identity is derived. Duplicate trusted `X-Forwarded-For` field lines are canonicalized into the same ordered chain sent upstream when XFF is the configured identity header; when a different trusted identity header is configured, duplicate or comma-list identity values fail closed and accepted identities replace inbound XFF with a clean `resolved-client, peer` chain. Trusted proxy peers have their own aggregate request-rate and upstream in-flight caps so rotating forwarded identities cannot fully bypass per-client buckets or monopolize origin concurrency from one edge hop. -- Upstream response hop-by-hop sanitization so origin `Connection` control fields and connection-listed extension headers are consumed by AlturaProt instead of being forwarded to downstream clients. +- Forwarded-header and client-IP identity sanitization so direct clients cannot spoof `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Real-IP`, standard `Forwarded`, common CDN/proxy client-IP metadata, or hop-by-hop request metadata into the origin. Invalid trusted-proxy CIDRs fail config load, and non-loopback HTTP listeners refuse global trusted-proxy ranges such as `0.0.0.0/0` or `::/0`. Trusted forwarded client-IP chains are bounded by byte length and hop count before rate-limit identity is derived. Duplicate trusted `X-Forwarded-For` field lines are canonicalized into the same ordered chain sent upstream when XFF is the configured identity header; when a different trusted identity header is configured, duplicate, comma-list, port-bearing, or bracketed identity values fail closed and accepted raw IP identities replace inbound XFF with a clean `resolved-client, peer` chain. Trusted proxy peers have their own aggregate request-rate and upstream in-flight caps so rotating forwarded identities cannot fully bypass per-client buckets or monopolize origin concurrency from one edge hop. +- Request and upstream response hop-by-hop sanitization so `Connection` control fields and connection-listed extension headers are consumed by AlturaProt instead of being forwarded across proxy boundaries. `Connection` options are parsed from raw header bytes, so valid listed field names are still stripped when another option in the same field is malformed. - Downstream HTTP/1 keep-alive disabled by default, so every accepted client request takes the connection-opening raw framing precheck path unless an operator explicitly opts back into persistent downstream connections. -- Initial raw HTTP/1 framing precheck before Hyper parsing, so connection-opening CL/TE ambiguity and obsolete folded header lines are rejected before parser normalization. The precheck scans only newly appended header bytes plus a three-byte overlap for `\r\n\r\n`, and still rejects with `431` when the terminator arrives after `http.max_header_bytes`, keeping initial-header floods linear and byte-capped. +- Initial raw HTTP/1 framing precheck before Hyper parsing, so connection-opening CL/TE ambiguity, invalid field names, and obsolete folded header lines are rejected before parser normalization. The precheck scans only newly appended header bytes plus a three-byte overlap for `\r\n\r\n`, and still rejects with `431` when the terminator arrives after `http.max_header_bytes`, keeping initial-header floods linear and byte-capped. - Initial raw HTTP/1 header byte cap before Hyper parsing, so oversized connection-opening header sections return generated close/no-store `431` responses and increment `altura_http_initial_header_too_large`. - Initial raw HTTP/1 header field-line cap before Hyper parsing, plus parsed request header field-line validation for opt-in keep-alive follow-up requests, so one oversized request field cannot consume the whole header buffer before client-IP, Host, signature, filter, rate-limit, or upstream work. - Initial raw HTTP/1 header-count cap before Hyper parsing, so too-many-header connection-opening requests return generated close/no-store `431` responses and increment `altura_http_initial_headers_too_many`. - Initial raw HTTP/1 header read timeout before Hyper parsing, so slow connection-opening header drips return generated close/no-store `408` responses and increment `altura_http_initial_header_timeouts`. - Initial raw HTTP/1 request-target pressure guard before Hyper parsing, so URI, query-byte, query-pair, and path-segment floods return generated close/no-store `414` responses and increment `altura_http_initial_request_target_rejected`. -- Request framing validation for exposed `Content-Length`/`Transfer-Encoding` ambiguity before signature, filter, or upstream work, with generated close/no-store `400` responses for both raw pre-parser and service-level framing rejects. +- Request framing validation for exposed `Content-Length`/`Transfer-Encoding` ambiguity before signature, filter, or upstream work, with generated close/no-store `400` responses for both raw pre-parser and service-level framing rejects. `Transfer-Encoding` tolerates a small number of empty list elements for compatibility, but rejects empty-element spray even when chunked request bodies are explicitly enabled. - Generated early-deny responses emitted before request content is consumed explicitly close the downstream connection, including method, filter, request-target, range, overload, rate-limit, and framing rejections on opt-in keep-alive connections. - Default rejection of chunked request bodies before upstream work, with explicit opt-in only for origins that need unknown-length HTTP/1.1 uploads and can tolerate relying on streaming body guards after admission. - Default rejection of compressed request `Content-Encoding` before origin work, with generated close/no-store `415` responses and explicit opt-in only for origins that enforce decoded-size and decompression-ratio limits. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 2942d54..72aa7a5 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -187,7 +187,7 @@ See [Edge Protection](EDGE_PROTECTION.md) for the provider, nftables, and sysctl - Leave `http.admin_token` unset only when metrics should fail closed with `403`; when configured, use a non-empty, non-blank, trimmed secret value without control characters and no more than 256 bytes. Startup rejects blank, padded, or oversized tokens so metrics auth cannot be weakened by an accidental empty shared secret. - Put metrics behind an admin token and private network; do not run public metrics without `http.admin_token`. - Keep default listeners bound to explicit addresses. -- Configure `http.client_ip.trusted_proxies` before trusting `X-Forwarded-For`; never trust forwarded, CDN, or proxy client-IP headers from arbitrary internet peers. AlturaProt validates `http.client_ip.header` as a non-empty, trimmed, bounded HTTP field name and rejects malformed trusted-proxy lists at config load: entries must be non-empty, trimmed, free of whitespace/control characters, no more than 64 bytes each, no more than 128 total, duplicate-free, and valid IP/CIDR ranges. Startup fails when a non-loopback HTTP listener trusts all peers with ranges such as `0.0.0.0/0` or `::/0`. The runtime resolver relies on validated config and does not silently fall back to a weaker default header or drop invalid proxy entries. AlturaProt overwrites spoofable forwarded metadata, strips common client-IP identity headers, and consumes standard plus `Connection`-listed hop-by-hop headers before forwarding request or response messages. Duplicate trusted `X-Forwarded-For` field lines are canonicalized into one ordered comma-separated chain before upstream forwarding when XFF is the configured identity header, so the origin-visible chain matches the values used for client-IP resolution. If a trusted deployment uses a different configured identity header such as `CF-Connecting-IP`, that header must be a single non-empty IP value; duplicate field lines and comma-list values return `400` and increment `altura_http_forwarded_rejected`. Accepted custom identity headers do not preserve inbound XFF; AlturaProt synthesizes a clean `resolved-client, peer` XFF chain and still sets `X-Real-IP` to the resolved client. Keep `http.client_ip.max_forwarded_for_bytes` and `http.client_ip.max_forwarded_for_hops` finite so trusted-forwarded parsing has bounded CPU and allocation cost. Keep `http.limits.trusted_proxy_rps`, `http.limits.trusted_proxy_burst`, and `http.limits.trusted_proxy_max_in_flight_requests` finite for trusted edge deployments so one proxy peer cannot carry unlimited rotated forwarded identities or monopolize origin concurrency. +- Configure `http.client_ip.trusted_proxies` before trusting `X-Forwarded-For`; never trust forwarded, CDN, or proxy client-IP headers from arbitrary internet peers. AlturaProt validates `http.client_ip.header` as a non-empty, trimmed, bounded HTTP field name and rejects malformed trusted-proxy lists at config load: entries must be non-empty, trimmed, free of whitespace/control characters, no more than 64 bytes each, no more than 128 total, duplicate-free, and valid IP/CIDR ranges. Startup fails when a non-loopback HTTP listener trusts all peers with ranges such as `0.0.0.0/0` or `::/0`. The runtime resolver relies on validated config and does not silently fall back to a weaker default header or drop invalid proxy entries. AlturaProt overwrites spoofable forwarded metadata, strips common client-IP identity headers, and consumes standard plus `Connection`-listed hop-by-hop headers before forwarding request or response messages. Duplicate trusted `X-Forwarded-For` field lines are canonicalized into one ordered comma-separated chain before upstream forwarding when XFF is the configured identity header, so the origin-visible chain matches the values used for client-IP resolution. If a trusted deployment uses a different configured identity header such as `CF-Connecting-IP`, that header must be a single non-empty raw IP literal; duplicate field lines, comma-list values, port-bearing values, and bracketed address forms return `400` and increment `altura_http_forwarded_rejected`. Accepted custom identity headers do not preserve inbound XFF; AlturaProt synthesizes a clean `resolved-client, peer` XFF chain and still sets `X-Real-IP` to the resolved client. Keep `http.client_ip.max_forwarded_for_bytes` and `http.client_ip.max_forwarded_for_hops` finite so trusted-forwarded parsing has bounded CPU and allocation cost. Keep `http.limits.trusted_proxy_rps`, `http.limits.trusted_proxy_burst`, and `http.limits.trusted_proxy_max_in_flight_requests` finite for trusted edge deployments so one proxy peer cannot carry unlimited rotated forwarded identities or monopolize origin concurrency. - Tune `http.limits.ipv4_prefix_len` and `http.limits.ipv6_prefix_len` for the client-identity granularity you want on HTTP per-client request, connection-open, and upstream in-flight limits. Defaults are IPv4 `/32` and IPv6 `/64`, which keeps IPv4 exact while grouping IPv6 privacy-address or same-subnet rotation into one protected bucket. Startup rejects prefix lengths outside `1..32` for IPv4 and `1..128` for IPv6. Trusted-proxy aggregate limits intentionally remain exact immediate-peer caps; use `trusted_proxy_rps`, `trusted_proxy_burst`, and `trusted_proxy_max_in_flight_requests` for the edge-hop aggregate budget. - Keep the host-edge nftables IPv6 `/64` SYN and connection-count masks aligned with the userspace default unless you intentionally choose a different IPv6 client-prefix policy. Changing `http.limits.ipv6_prefix_len` or `tcp[].limits.ipv6_prefix_len` away from `/64` should trigger a matching provider/host-edge review, because the shipped nftables template is intentionally hardcoded to `/64`. - Keep generic protected-port TCP nftables predicates and IPv6-specific predicates on `meta l4proto`, not bare transport matches or `ip6 nexthdr`. `ip6 nexthdr` only sees the immediate IPv6 next-header field, while `meta l4proto` follows extension headers to the real TCP or ICMPv6 transport protocol. diff --git a/src/adaptive.rs b/src/adaptive.rs index a66e0e3..9129b06 100644 --- a/src/adaptive.rs +++ b/src/adaptive.rs @@ -7,7 +7,9 @@ use std::{ }; use crate::{ - filter::{request_path_shape, signature_basis, FilterEngine, RequestContext}, + filter::{ + legacy_request_signature, request_path_shape, signature_basis, FilterEngine, RequestContext, + }, limiter::TokenBucket, telemetry::{unix_time_ms, AdaptiveWindowStats, AttackEvent, EventLogger}, }; @@ -151,6 +153,13 @@ impl AdaptiveDetector { let _ = self .engine .activate_signature(&ctx.signature, Some(self.activation_ttl)); + let legacy_signature = + legacy_request_signature(ctx.method, ctx.path, ctx.query, ctx.headers); + if legacy_signature != ctx.signature { + let _ = self + .engine + .activate_signature(&legacy_signature, Some(self.activation_ttl)); + } } if let Some(shape_observation) = &shape_observation { if shape_observation.activate { @@ -546,6 +555,7 @@ mod tests { #[tokio::test] async fn detector_activates_learned_signature() { + let signature = "0123456789abcdef0123456789abcdef"; let engine = FilterEngine::new( vec![FilterRule { id: "learned".to_string(), @@ -555,7 +565,7 @@ mod tests { ttl_seconds: Some(30), expires_at_unix_ms: None, condition: FilterCondition { - signature: Some("sig".to_string()), + signature: Some(signature.to_string()), ..Default::default() }, action: FilterAction::default(), @@ -577,7 +587,7 @@ mod tests { path: "/", query: None, headers: &headers, - signature: "sig".to_string(), + signature: signature.to_string(), }; assert!(engine.evaluate(&ctx).is_none()); detector.observe(&ctx, "test"); @@ -586,8 +596,61 @@ mod tests { assert!(engine.evaluate(&ctx).is_some()); } + #[tokio::test] + async fn detector_activates_legacy_learned_signature() { + let headers = HeaderMap::new(); + let legacy_signature = legacy_request_signature("GET", "/api/orders/123", None, &headers); + let current_signature = + crate::filter::request_signature("GET", "/api/orders/123", None, &headers); + assert_ne!(legacy_signature, current_signature); + let engine = FilterEngine::new( + vec![FilterRule { + id: "legacy-learned".to_string(), + enabled: true, + adaptive: true, + priority: 1, + ttl_seconds: Some(30), + expires_at_unix_ms: None, + condition: FilterCondition { + signature: Some(legacy_signature), + ..Default::default() + }, + action: FilterAction::default(), + }], + PathBuf::from("/tmp/altura-prot-nonexistent-legacy-adaptive-filters.json"), + Duration::from_secs(30), + ) + .await; + let logger = + Arc::new(EventLogger::new("/tmp/altura-prot-test-legacy-events.jsonl").unwrap()); + let detector = AdaptiveDetector::new( + test_detector_config(2, 8_192, 8_192), + Arc::clone(&engine), + logger, + ); + let ctx = RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + method: "GET", + path: "/api/orders/123", + query: None, + headers: &headers, + signature: current_signature, + }; + + assert!(engine.evaluate(&ctx).is_none()); + detector.observe(&ctx, "test"); + assert!(engine.evaluate(&ctx).is_none()); + detector.observe(&ctx, "test"); + assert_eq!( + engine.evaluate(&ctx).map(|decision| decision.rule_id), + Some("legacy-learned".to_string()) + ); + } + #[tokio::test] async fn detector_keeps_distinct_signatures_below_threshold() { + let signature_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let signature_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let engine = FilterEngine::new( vec![FilterRule { id: "learned".to_string(), @@ -597,7 +660,7 @@ mod tests { ttl_seconds: Some(30), expires_at_unix_ms: None, condition: FilterCondition { - signature: Some("sig-a".to_string()), + signature: Some(signature_a.to_string()), ..Default::default() }, action: FilterAction::default(), @@ -620,7 +683,7 @@ mod tests { path: "/", query: None, headers: &headers, - signature: "sig-a".to_string(), + signature: signature_a.to_string(), }; let ctx_b = RequestContext { client_ip: "127.0.0.1".parse().unwrap(), @@ -628,7 +691,7 @@ mod tests { path: "/", query: None, headers: &headers, - signature: "sig-b".to_string(), + signature: signature_b.to_string(), }; detector.observe(&ctx_a, "test"); detector.observe(&ctx_b, "test"); diff --git a/src/filter.rs b/src/filter.rs index 098af74..01abd2c 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -1,4 +1,5 @@ use std::{ + borrow::Cow, collections::{HashMap, HashSet}, io, net::IpAddr, @@ -26,6 +27,9 @@ pub const DEFAULT_FILTER_MAX_HEADERS: usize = 16; pub const DEFAULT_FILTER_MAX_MATCH_VALUE_BYTES: usize = 1024; pub const DEFAULT_FILTER_MAX_ACTION_BODY_BYTES: usize = 1024; pub const FILTER_TTL_MAX_SECONDS: u64 = 24 * 60 * 60; +const REQUEST_SIGNATURE_HASH_BYTES: usize = 16; +const CURRENT_SIGNATURE_HEX_CHARS: usize = REQUEST_SIGNATURE_HASH_BYTES * 2; +const LEGACY_SIGNATURE_HEX_CHARS: usize = 16; #[derive(Debug, Clone)] pub struct RequestContext<'a> { @@ -39,11 +43,9 @@ pub struct RequestContext<'a> { impl<'a> RequestContext<'a> { pub fn user_agent(&self) -> String { - self.headers - .get(http::header::USER_AGENT) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string() + combined_header_value(self.headers, &http::header::USER_AGENT) + .unwrap_or(Cow::Borrowed("")) + .into_owned() } } @@ -135,24 +137,25 @@ pub fn validate_filter_rules( ) .into()); } + validate_unique_filter_rule_ids(source, rules)?; + for (idx, rule) in rules.iter().enumerate() { + validate_filter_rule(&format!("{source}[{idx}]"), rule)?; + } + Ok(()) +} + +fn validate_unique_filter_rule_ids(source: &str, rules: &[FilterRule]) -> Result<(), BoxError> { let mut seen_ids = HashSet::new(); for (idx, rule) in rules.iter().enumerate() { if !seen_ids.insert(rule.id.as_str()) { return Err(format!("{source}[{idx}].id duplicates rule id {:?}", rule.id).into()); } } - for (idx, rule) in rules.iter().enumerate() { - validate_filter_rule(&format!("{source}[{idx}]"), rule)?; - } Ok(()) } fn validate_filter_rule(path: &str, rule: &FilterRule) -> Result<(), BoxError> { - validate_non_empty_bounded_string( - &format!("{path}.id"), - &rule.id, - DEFAULT_FILTER_MAX_RULE_ID_BYTES, - )?; + validate_filter_rule_id(&format!("{path}.id"), &rule.id)?; if let Some(ttl_seconds) = rule.ttl_seconds { validate_filter_ttl(&format!("{path}.ttl_seconds"), ttl_seconds)?; } @@ -161,6 +164,22 @@ fn validate_filter_rule(path: &str, rule: &FilterRule) -> Result<(), BoxError> { Ok(()) } +fn validate_filter_rule_id(path: &str, value: &str) -> Result<(), BoxError> { + validate_non_empty_bounded_string(path, value, DEFAULT_FILTER_MAX_RULE_ID_BYTES)?; + if value + .bytes() + .any(|byte| !byte.is_ascii_graphic() || byte == b',') + { + return Err(format!( + "{path} must contain only visible ASCII identifier bytes without commas" + ) + .into()); + } + HeaderValue::from_str(value) + .map_err(|err| format!("{path} must be safe to emit in x-altura-filter: {err}"))?; + Ok(()) +} + fn validate_filter_ttl(path: &str, ttl_seconds: u64) -> Result<(), BoxError> { if ttl_seconds == 0 { return Err(format!("{path} must be greater than zero").into()); @@ -206,7 +225,6 @@ fn validate_filter_condition(path: &str, condition: &FilterCondition) -> Result< "user_agent_contains", condition.user_agent_contains.as_ref(), ), - ("signature", condition.signature.as_ref()), ] { if let Some(value) = value { validate_non_empty_bounded_string( @@ -216,6 +234,9 @@ fn validate_filter_condition(path: &str, condition: &FilterCondition) -> Result< )?; } } + if let Some(signature) = &condition.signature { + validate_signature_matcher(&format!("{condition_path}.signature"), signature)?; + } if condition.path_prefix.as_deref() == Some("/") { return Err(format!("{condition_path}.path_prefix must be narrower than /").into()); } @@ -240,6 +261,23 @@ fn validate_filter_condition(path: &str, condition: &FilterCondition) -> Result< Ok(()) } +fn validate_signature_matcher(path: &str, value: &str) -> Result<(), BoxError> { + validate_non_empty_bounded_string(path, value, CURRENT_SIGNATURE_HEX_CHARS)?; + if value.len() != CURRENT_SIGNATURE_HEX_CHARS && value.len() != LEGACY_SIGNATURE_HEX_CHARS { + return Err(format!( + "{path} must be a {CURRENT_SIGNATURE_HEX_CHARS}-hex current signature or {LEGACY_SIGNATURE_HEX_CHARS}-hex legacy signature" + ) + .into()); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err(format!("{path} must contain only lowercase hexadecimal characters").into()); + } + Ok(()) +} + fn validate_filter_action(path: &str, action: &FilterAction) -> Result<(), BoxError> { let action_path = format!("{path}.action"); if action.kind != "block" { @@ -357,6 +395,7 @@ struct CompiledHeaderContains { struct FilterEvalScratch { path_shape: Option, short_token_parent_shape: Option>, + legacy_signature: Option, } impl FilterEvalScratch { @@ -375,6 +414,14 @@ impl FilterEvalScratch { .get_or_insert_with(|| short_token_parent_shape(path).map(|(shape, _)| shape)); parent.as_deref() == Some(expected) } + + fn legacy_signature(&mut self, ctx: &RequestContext<'_>) -> &str { + self.legacy_signature + .get_or_insert_with(|| { + legacy_request_signature(ctx.method, ctx.path, ctx.query, ctx.headers) + }) + .as_str() + } } #[derive(Debug)] @@ -431,12 +478,22 @@ impl FilterEngine { if let Some(file) = self.load_runtime_filter_file().await? { loaded.extend(file.filters); } + validate_unique_filter_rule_ids("merged filters", &loaded)?; + for (idx, rule) in loaded.iter().enumerate() { + validate_filter_rule(&format!("merged filters[{idx}]"), rule)?; + } loaded.sort_by(|a, b| b.priority.cmp(&a.priority).then_with(|| a.id.cmp(&b.id))); let now_ms = unix_time_ms(); + let loaded_rule_ids = loaded + .iter() + .map(|rule| rule.id.as_str()) + .collect::>(); let mut activation_deadlines = self.lock_activation_deadlines("reload activation state preservation"); - activation_deadlines.retain(|_, active_until_ms| *active_until_ms > now_ms); + activation_deadlines.retain(|rule_id, active_until_ms| { + *active_until_ms > now_ms && loaded_rule_ids.contains(rule_id.as_str()) + }); let mut runtime_rules = Vec::with_capacity(loaded.len()); for rule in loaded { let active_until_ms = activation_deadlines @@ -664,6 +721,19 @@ pub fn request_signature( path: &str, query: Option<&str>, headers: &HeaderMap, +) -> String { + let basis = signature_basis(method, path, query, headers); + hex_prefix( + blake3::hash(basis.as_bytes()).as_bytes(), + REQUEST_SIGNATURE_HASH_BYTES, + ) +} + +pub fn legacy_request_signature( + method: &str, + path: &str, + query: Option<&str>, + headers: &HeaderMap, ) -> String { let basis = signature_basis(method, path, query, headers); format!("{:016x}", fnv1a64(basis.as_bytes())) @@ -675,15 +745,11 @@ pub fn signature_basis( query: Option<&str>, headers: &HeaderMap, ) -> String { - let ua = headers - .get(http::header::USER_AGENT) - .and_then(|v| v.to_str().ok()) - .map(user_agent_family) + let ua = combined_header_value(headers, &http::header::USER_AGENT) + .map(|value| user_agent_family(value.as_ref())) .unwrap_or_else(|| "empty".to_string()); - let accept = headers - .get(http::header::ACCEPT) - .and_then(|v| v.to_str().ok()) - .map(header_class) + let accept = combined_header_value(headers, &http::header::ACCEPT) + .map(|value| header_class(value.as_ref())) .unwrap_or_else(|| "empty".to_string()); format!( "{}|{}|{}|{}|{}", @@ -715,7 +781,7 @@ fn rule_matches( return false; } if let Some(signature) = &rule.condition.signature { - if signature != &ctx.signature { + if signature != &ctx.signature && signature != scratch.legacy_signature(ctx) { return false; } } @@ -745,28 +811,63 @@ fn rule_matches( } } if let Some(needle) = &compiled.user_agent_contains { - let ua = ctx - .headers - .get(http::header::USER_AGENT) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if !ascii_contains_ignore_case(ua.as_bytes(), needle) { + if !header_values_contain_ignore_case(ctx.headers, &http::header::USER_AGENT, needle) { return false; } } for header in &compiled.headers { - let value = ctx - .headers - .get(&header.name) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if !ascii_contains_ignore_case(value.as_bytes(), &header.contains) { + if !header_values_contain_ignore_case(ctx.headers, &header.name, &header.contains) { return false; } } true } +fn combined_header_value<'a>( + headers: &'a HeaderMap, + name: &HeaderName, +) -> Option> { + let mut values = headers + .get_all(name) + .iter() + .filter_map(|value| value.to_str().ok()); + let first = values.next()?; + let Some(second) = values.next() else { + return Some(Cow::Borrowed(first)); + }; + let mut combined = String::with_capacity(first.len() + second.len() + 2); + combined.push_str(first); + combined.push_str(", "); + combined.push_str(second); + for value in values { + combined.push_str(", "); + combined.push_str(value); + } + Some(Cow::Owned(combined)) +} + +fn header_values_contain_ignore_case( + headers: &HeaderMap, + name: &HeaderName, + needle: &[u8], +) -> bool { + headers + .get_all(name) + .iter() + .filter_map(|value| value.to_str().ok()) + .any(|value| ascii_contains_ignore_case(value.as_bytes(), needle)) +} + +fn hex_prefix(bytes: &[u8], prefix_len: usize) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(prefix_len * 2); + for byte in bytes.iter().take(prefix_len) { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + fn ascii_contains_ignore_case(haystack: &[u8], needle: &[u8]) -> bool { if needle.is_empty() { return true; @@ -1128,6 +1229,20 @@ mod tests { assert!(err.contains("duplicates rule id"), "{err}"); } + #[test] + fn filter_rule_validation_rejects_ids_that_are_unsafe_response_headers() { + for id in ["bad\nid", "bad\rid", "bad\u{7f}id", "bad-☃", "bad,id"] { + let rule = path_rule(id, "/blocked"); + + let err = validate_filter_rules("filters.static_rules", &[rule], 4) + .unwrap_err() + .to_string(); + + assert!(err.contains("filters.static_rules[0].id"), "{err}"); + assert!(err.contains("visible ASCII identifier"), "{err}"); + } + } + #[test] fn filter_rule_validation_rejects_root_path_prefix() { let mut rule = path_rule("catchall-prefix", "/blocked"); @@ -1145,6 +1260,44 @@ mod tests { assert!(err.contains("narrower than /"), "{err}"); } + #[test] + fn filter_rule_validation_accepts_current_and_legacy_signature_formats() { + let mut current = path_rule("current-signature", "/unused"); + current.condition.path_exact = None; + current.condition.signature = Some("0123456789abcdef0123456789abcdef".to_string()); + let mut legacy = path_rule("legacy-signature", "/unused"); + legacy.condition.path_exact = None; + legacy.condition.signature = Some("0123456789abcdef".to_string()); + + validate_filter_rules("filters.static_rules", &[current, legacy], 4).unwrap(); + } + + #[test] + fn filter_rule_validation_rejects_malformed_signature_formats() { + for (signature, expected) in [ + ( + "abc", + "must be a 32-hex current signature or 16-hex legacy signature", + ), + ("0123456789abcdef0123456789abcdeg", "lowercase hexadecimal"), + ("0123456789ABCDEF", "lowercase hexadecimal"), + ] { + let mut rule = path_rule("bad-signature", "/unused"); + rule.condition.path_exact = None; + rule.condition.signature = Some(signature.to_string()); + + let err = validate_filter_rules("filters.static_rules", &[rule], 4) + .unwrap_err() + .to_string(); + + assert!( + err.contains("filters.static_rules[0].condition.signature"), + "{err}" + ); + assert!(err.contains(expected), "{err}"); + } + } + #[tokio::test] async fn static_filter_matches_path() { let engine = FilterEngine::new( @@ -1177,6 +1330,44 @@ mod tests { assert_eq!(engine.evaluate(&ctx).unwrap().rule_id, "test"); } + #[tokio::test] + async fn filter_signature_conditions_accept_legacy_fnv_signatures() { + let headers = HeaderMap::new(); + let legacy_signature = + legacy_request_signature("GET", "/api/users/123", Some("cachebust=1"), &headers); + let engine = FilterEngine::new( + vec![FilterRule { + id: "legacy-signature".to_string(), + enabled: true, + adaptive: false, + priority: 1, + ttl_seconds: None, + expires_at_unix_ms: None, + condition: FilterCondition { + signature: Some(legacy_signature), + ..Default::default() + }, + action: FilterAction::default(), + }], + PathBuf::from("/tmp/altura-prot-nonexistent-legacy-signature-filters.json"), + Duration::from_secs(30), + ) + .await; + let ctx = RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + method: "GET", + path: "/api/users/123", + query: Some("cachebust=1"), + headers: &headers, + signature: request_signature("GET", "/api/users/123", Some("cachebust=1"), &headers), + }; + + assert_eq!( + engine.evaluate(&ctx).map(|decision| decision.rule_id), + Some("legacy-signature".to_string()) + ); + } + #[test] fn ascii_contains_ignore_case_matches_without_allocating_lowercase_strings() { assert!(ascii_contains_ignore_case( @@ -1232,6 +1423,63 @@ mod tests { assert_eq!(engine.evaluate(&ctx).unwrap().rule_id, "compiled-header"); } + #[tokio::test] + async fn filter_matches_duplicate_user_agent_and_header_values() { + let engine = FilterEngine::new( + vec![FilterRule { + id: "duplicate-header-values".to_string(), + enabled: true, + adaptive: false, + priority: 1, + ttl_seconds: None, + expires_at_unix_ms: None, + condition: FilterCondition { + user_agent_contains: Some("floodbot".to_string()), + headers: vec![HeaderContains { + name: "X-Altura-Signal".to_string(), + contains: "burst".to_string(), + }], + ..Default::default() + }, + action: FilterAction::default(), + }], + PathBuf::from("/tmp/altura-prot-nonexistent-duplicate-header-filters.json"), + Duration::from_secs(30), + ) + .await; + let mut headers = HeaderMap::new(); + headers.append( + http::header::USER_AGENT, + HeaderValue::from_static("legit browser"), + ); + headers.append( + http::header::USER_AGENT, + HeaderValue::from_static("floodbot/1.0"), + ); + headers.append( + HeaderName::from_static("x-altura-signal"), + HeaderValue::from_static("warmup"), + ); + headers.append( + HeaderName::from_static("x-altura-signal"), + HeaderValue::from_static("coordinated burst"), + ); + let ctx = RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + method: "GET", + path: "/", + query: None, + headers: &headers, + signature: "sig".to_string(), + }; + + assert_eq!( + engine.evaluate(&ctx).map(|decision| decision.rule_id), + Some("duplicate-header-values".to_string()) + ); + assert_eq!(ctx.user_agent(), "legit browser, floodbot/1.0"); + } + #[tokio::test] async fn filter_with_invalid_header_condition_never_matches() { let engine = FilterEngine::new( @@ -1270,6 +1518,7 @@ mod tests { #[tokio::test] async fn adaptive_filter_only_matches_when_active() { + let signature = "0123456789abcdef0123456789abcdef"; let engine = FilterEngine::new( vec![FilterRule { id: "adaptive".to_string(), @@ -1279,7 +1528,7 @@ mod tests { ttl_seconds: Some(30), expires_at_unix_ms: None, condition: FilterCondition { - signature: Some("abc".to_string()), + signature: Some(signature.to_string()), ..Default::default() }, action: FilterAction::default(), @@ -1295,10 +1544,10 @@ mod tests { path: "/", query: None, headers: &headers, - signature: "abc".to_string(), + signature: signature.to_string(), }; assert!(engine.evaluate(&ctx).is_none()); - assert!(engine.activate_signature("abc", None)); + assert!(engine.activate_signature(signature, None)); assert!(engine.evaluate(&ctx).is_some()); } @@ -1315,7 +1564,7 @@ mod tests { ttl_seconds: Some(30), expires_at_unix_ms: Some(expired_at), condition: FilterCondition { - signature: Some("expired".to_string()), + signature: Some("11111111111111111111111111111111".to_string()), ..Default::default() }, action: FilterAction::default(), @@ -1345,10 +1594,10 @@ mod tests { path: "/api/abcdefghij", query: None, headers: &headers, - signature: "expired".to_string(), + signature: "11111111111111111111111111111111".to_string(), }; - assert!(!engine.activate_signature("expired", None)); + assert!(!engine.activate_signature("11111111111111111111111111111111", None)); assert!(!engine.activate_path_shape("/api/:token", None)); assert_eq!(engine.active_rule_count(), 0); assert!(engine.evaluate(&ctx).is_none()); @@ -1356,6 +1605,7 @@ mod tests { #[tokio::test] async fn active_rule_count_matches_evaluation_gates() { + let adaptive_signature = "22222222222222222222222222222222"; let mut expired_static = path_rule("expired-static", "/expired"); expired_static.expires_at_unix_ms = Some(unix_time_ms().saturating_sub(1)); let mut disabled_static = path_rule("disabled-static", "/disabled"); @@ -1373,7 +1623,7 @@ mod tests { ttl_seconds: Some(30), expires_at_unix_ms: None, condition: FilterCondition { - signature: Some("adaptive".to_string()), + signature: Some(adaptive_signature.to_string()), ..Default::default() }, action: FilterAction::default(), @@ -1385,12 +1635,13 @@ mod tests { .await; assert_eq!(engine.active_rule_count(), 1); - assert!(engine.activate_signature("adaptive", None)); + assert!(engine.activate_signature(adaptive_signature, None)); assert_eq!(engine.active_rule_count(), 2); } #[tokio::test] async fn adaptive_activation_does_not_wait_for_rule_snapshot_readers() { + let signature = "33333333333333333333333333333333"; let engine = FilterEngine::new( vec![FilterRule { id: "adaptive-nonblocking".to_string(), @@ -1400,7 +1651,7 @@ mod tests { ttl_seconds: Some(30), expires_at_unix_ms: None, condition: FilterCondition { - signature: Some("abc".to_string()), + signature: Some(signature.to_string()), ..Default::default() }, action: FilterAction::default(), @@ -1414,7 +1665,7 @@ mod tests { let engine_for_thread = Arc::clone(&engine); std::thread::spawn(move || { - tx.send(engine_for_thread.activate_signature("abc", None)) + tx.send(engine_for_thread.activate_signature(signature, None)) .unwrap(); }); @@ -1428,7 +1679,7 @@ mod tests { path: "/", query: None, headers: &headers, - signature: "abc".to_string(), + signature: signature.to_string(), }; assert_eq!( engine.evaluate(&ctx).map(|decision| decision.rule_id), @@ -1438,16 +1689,17 @@ mod tests { #[tokio::test] async fn adaptive_activation_clamps_unvalidated_ttl_before_instant_math() { + let signature = "44444444444444444444444444444444"; let engine = FilterEngine::new( vec![FilterRule { id: "adaptive-huge-ttl".to_string(), enabled: true, adaptive: true, priority: 10, - ttl_seconds: Some(u64::MAX), + ttl_seconds: None, expires_at_unix_ms: None, condition: FilterCondition { - signature: Some("huge".to_string()), + signature: Some(signature.to_string()), ..Default::default() }, action: FilterAction::default(), @@ -1463,10 +1715,10 @@ mod tests { path: "/", query: None, headers: &headers, - signature: "huge".to_string(), + signature: signature.to_string(), }; - assert!(engine.activate_signature("huge", Some(Duration::from_secs(u64::MAX)))); + assert!(engine.activate_signature(signature, Some(Duration::from_secs(u64::MAX)))); assert!(engine.evaluate(&ctx).is_some()); } @@ -1505,6 +1757,47 @@ mod tests { let _ = std::fs::remove_file(path); } + #[tokio::test] + async fn runtime_filter_reload_prunes_activation_deadlines_for_removed_rules() { + let path = temp_runtime_filter_path("activation-prune-runtime-filters"); + let first_signature = "11111111111111111111111111111111"; + let second_signature = "22222222222222222222222222222222"; + let mut first = path_rule("first", "/first"); + first.adaptive = true; + first.ttl_seconds = Some(60); + first.condition.signature = Some(first_signature.to_string()); + std::fs::write(&path, runtime_filter_json(&[first])).unwrap(); + let engine = FilterEngine::new_with_limits( + Vec::new(), + path.clone(), + Duration::from_secs(60), + 4096, + 4, + ) + .await; + + assert!(engine.activate_signature(first_signature, None)); + { + let activation_deadlines = + engine.lock_activation_deadlines("test activation deadline inserted"); + assert!(activation_deadlines.contains_key("first")); + } + + let mut second = path_rule("second", "/second"); + second.adaptive = true; + second.ttl_seconds = Some(60); + second.condition.signature = Some(second_signature.to_string()); + std::fs::write(&path, runtime_filter_json(&[second])).unwrap(); + + engine.reload().await.unwrap(); + + let activation_deadlines = + engine.lock_activation_deadlines("test activation deadline pruning"); + assert!(!activation_deadlines.contains_key("first")); + assert!(activation_deadlines.is_empty()); + let _ = std::fs::remove_file(path); + } + #[tokio::test] async fn runtime_filter_reload_rejects_oversized_file_and_preserves_last_good_rules() { let path = temp_runtime_filter_path("oversized-runtime-filters"); @@ -1613,6 +1906,130 @@ mod tests { let _ = std::fs::remove_file(path); } + #[tokio::test] + async fn runtime_filter_reload_rejects_unsafe_rule_ids_and_preserves_last_good_rules() { + let path = temp_runtime_filter_path("unsafe-id-runtime-filters"); + std::fs::write(&path, runtime_filter_json(&[path_rule("good", "/blocked")])).unwrap(); + let engine = FilterEngine::new_with_limits( + Vec::new(), + path.clone(), + Duration::from_secs(30), + 4096, + 4, + ) + .await; + + let headers = HeaderMap::new(); + let ctx = RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + method: "GET", + path: "/blocked", + query: None, + headers: &headers, + signature: "sig".to_string(), + }; + assert_eq!(engine.evaluate(&ctx).unwrap().rule_id, "good"); + + std::fs::write(&path, runtime_filter_json(&[path_rule("bad\nid", "/bad")])).unwrap(); + + let err = engine.reload().await.unwrap_err().to_string(); + + assert!(err.contains("[0].id"), "{err}"); + assert!(err.contains("visible ASCII identifier"), "{err}"); + assert_eq!(engine.evaluate(&ctx).unwrap().rule_id, "good"); + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn filter_reload_rejects_invalid_static_rules_before_publish() { + let path = temp_runtime_filter_path("invalid-static-filters"); + let _ = std::fs::remove_file(&path); + let mut invalid = path_rule("bad-static", "/blocked"); + invalid.action.status = 200; + let engine = FilterEngine::new_with_limits( + vec![invalid], + path.clone(), + Duration::from_secs(30), + 4096, + 4, + ) + .await; + + let err = engine.reload().await.unwrap_err().to_string(); + + assert!(err.contains("merged filters[0].action.status"), "{err}"); + let headers = HeaderMap::new(); + let ctx = RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + method: "GET", + path: "/blocked", + query: None, + headers: &headers, + signature: "sig".to_string(), + }; + assert!(engine.evaluate(&ctx).is_none()); + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn runtime_filter_reload_rejects_static_runtime_duplicate_ids_and_preserves_rules() { + let path = temp_runtime_filter_path("duplicate-merged-runtime-filters"); + let static_rule = path_rule("shared-id", "/static-blocked"); + std::fs::write( + &path, + runtime_filter_json(&[path_rule("runtime-good", "/runtime-blocked")]), + ) + .unwrap(); + let engine = FilterEngine::new_with_limits( + vec![static_rule], + path.clone(), + Duration::from_secs(30), + 4096, + 4, + ) + .await; + + let headers = HeaderMap::new(); + let static_ctx = RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + method: "GET", + path: "/static-blocked", + query: None, + headers: &headers, + signature: "sig".to_string(), + }; + let runtime_ctx = RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + method: "GET", + path: "/runtime-blocked", + query: None, + headers: &headers, + signature: "sig".to_string(), + }; + assert_eq!(engine.evaluate(&static_ctx).unwrap().rule_id, "shared-id"); + assert_eq!( + engine.evaluate(&runtime_ctx).unwrap().rule_id, + "runtime-good" + ); + + std::fs::write( + &path, + runtime_filter_json(&[path_rule("shared-id", "/runtime-shadow")]), + ) + .unwrap(); + + let err = engine.reload().await.unwrap_err().to_string(); + + assert!(err.contains("merged filters[1].id"), "{err}"); + assert!(err.contains("duplicates rule id \"shared-id\""), "{err}"); + assert_eq!(engine.evaluate(&static_ctx).unwrap().rule_id, "shared-id"); + assert_eq!( + engine.evaluate(&runtime_ctx).unwrap().rule_id, + "runtime-good" + ); + let _ = std::fs::remove_file(path); + } + #[tokio::test] async fn runtime_filter_reload_rejects_non_regular_files() { let path = std::env::temp_dir().join(format!( @@ -1634,6 +2051,23 @@ mod tests { let _ = std::fs::remove_dir_all(path); } + #[test] + fn request_signature_uses_blake3_fingerprint_with_legacy_helper() { + let headers = HeaderMap::new(); + let first = request_signature("GET", "/users/123/profile", None, &headers); + let second = request_signature("GET", "/users/456/profile", None, &headers); + let legacy = legacy_request_signature("GET", "/users/123/profile", None, &headers); + + assert_eq!(first, second); + assert_eq!(first, "3b24cf3645105622c3fc4fc45e5a7ed2"); + assert_eq!(legacy, "ca65ef3c06c65c1b"); + assert_eq!(first.len(), REQUEST_SIGNATURE_HASH_BYTES * 2); + assert_eq!(legacy.len(), 16); + assert_ne!(first, legacy); + assert!(first.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert!(legacy.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + #[test] fn signatures_normalize_numeric_paths() { let headers = HeaderMap::new(); @@ -1676,6 +2110,32 @@ mod tests { assert_eq!(a, b); } + #[test] + fn signatures_classify_combined_duplicate_header_values() { + let mut first_only = HeaderMap::new(); + first_only.append( + http::header::USER_AGENT, + HeaderValue::from_static("randomized-browser"), + ); + first_only.append( + http::header::ACCEPT, + HeaderValue::from_static("application/json"), + ); + let mut duplicate_values = first_only.clone(); + duplicate_values.append( + http::header::USER_AGENT, + HeaderValue::from_static("Python-Requests/2.32"), + ); + duplicate_values.append(http::header::ACCEPT, HeaderValue::from_static("text/html")); + + let baseline = signature_basis("GET", "/api/catalog", None, &first_only); + let combined = signature_basis("GET", "/api/catalog", None, &duplicate_values); + + assert_ne!(baseline, combined); + assert!(combined.contains("|python|")); + assert!(combined.ends_with("|application/json")); + } + #[test] fn path_shape_generalizes_long_tokens_without_changing_signature_basis() { let headers = HeaderMap::new(); diff --git a/src/http_proxy.rs b/src/http_proxy.rs index 2c3a0eb..ce84b66 100644 --- a/src/http_proxy.rs +++ b/src/http_proxy.rs @@ -67,6 +67,7 @@ const GENERATED_RETRY_AFTER_SECONDS: &str = "1"; const GENERATED_CACHE_CONTROL: &str = "no-store"; const UPSTREAM_CIRCUIT_SHARDS: usize = 64; const UPSTREAM_CIRCUIT_EVICTION_SCAN_LIMIT: usize = 32; +const MAX_EMPTY_TRANSFER_ENCODING_ELEMENTS: usize = 8; static HTTP_ACCEPT_ERROR_LOG: LogLimiter = LogLimiter::new(); static HTTP_CONNECTION_REJECTED_LOG: LogLimiter = LogLimiter::new(); @@ -967,7 +968,7 @@ fn validate_raw_http1_header_block( return Err("invalid header line"); }; let name = &line[..colon]; - if name.is_empty() || name.iter().any(|byte| byte.is_ascii_whitespace()) { + if HeaderName::from_bytes(name).is_err() { return Err("invalid header name"); } let value = trim_header_value(&line[colon + 1..]); @@ -1022,13 +1023,24 @@ fn validate_raw_content_length(value: &[u8]) -> Result<(), &'static str> { fn validate_raw_transfer_encoding( value: &[u8], allow_chunked_request_bodies: bool, +) -> Result<(), &'static str> { + validate_transfer_encoding_value(value, allow_chunked_request_bodies) +} + +fn validate_transfer_encoding_value( + value: &[u8], + allow_chunked_request_bodies: bool, ) -> Result<(), &'static str> { let mut saw_coding = false; - for coding in value - .split(|byte| *byte == b',') - .map(trim_header_value) - .filter(|coding| !coding.is_empty()) - { + let mut empty_elements = 0usize; + for coding in value.split(|byte| *byte == b',').map(trim_header_value) { + if coding.is_empty() { + empty_elements += 1; + if empty_elements > MAX_EMPTY_TRANSFER_ENCODING_ELEMENTS { + return Err("too many empty transfer-encoding elements"); + } + continue; + } if saw_coding || !coding.eq_ignore_ascii_case(b"chunked") { return Err("unsupported transfer-encoding"); } @@ -1342,6 +1354,21 @@ async fn handle_http( "upstream response headers too large\n", )); } + if let Err(reason) = validate_upstream_response_framing(resp.headers()) { + Stats::inc(&state.stats.http_upstream_header_rejected); + state.upstream_circuit.record_failure(&path_shape); + Stats::inc(&state.stats.http_upstream_errors); + log_limited( + &HTTP_UPSTREAM_ERROR_LOG, + HOT_PATH_LOG_INTERVAL, + |suppressed| { + eprintln!("upstream response rejected: {reason}{suppressed}"); + }, + ); + return Ok(upstream_bad_gateway_response( + "upstream response framing invalid\n", + )); + } if let Some(length) = content_length(resp.headers()) { if state.cfg.max_upstream_body_bytes > 0 && length > state.cfg.max_upstream_body_bytes @@ -1440,7 +1467,7 @@ fn content_length(headers: &HeaderMap) -> Option { headers .get(CONTENT_LENGTH) .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()) + .and_then(|value| value.trim().parse::().ok()) } fn validate_request_framing( @@ -1453,16 +1480,7 @@ fn validate_request_framing( return Err("multiple content-length headers"); } if let Some(value) = content_length { - let value = value - .to_str() - .map_err(|_| "invalid content-length header")? - .trim(); - if value.is_empty() - || value.contains(',') - || !value.bytes().all(|byte| byte.is_ascii_digit()) - { - return Err("invalid content-length header"); - } + validate_parsed_content_length(value, "invalid content-length header")?; } let mut transfer_encodings = headers.get_all(TRANSFER_ENCODING).iter(); @@ -1475,27 +1493,44 @@ fn validate_request_framing( if transfer_encodings.next().is_some() { return Err("multiple transfer-encoding headers"); } - let value = transfer_encoding - .to_str() - .map_err(|_| "invalid transfer-encoding header")?; - let mut saw_coding = false; - for coding in value - .split(',') - .map(str::trim) - .filter(|coding| !coding.is_empty()) - { - if saw_coding || !coding.eq_ignore_ascii_case("chunked") { - return Err("unsupported transfer-encoding"); - } - saw_coding = true; + validate_transfer_encoding_value(transfer_encoding.as_bytes(), allow_chunked_request_bodies) +} + +fn validate_upstream_response_framing( + headers: &HeaderMap, +) -> Result<(), &'static str> { + let mut content_lengths = headers.get_all(CONTENT_LENGTH).iter(); + let content_length = content_lengths.next(); + if content_lengths.next().is_some() { + return Err("multiple upstream content-length headers"); } - if !saw_coding { - return Err("invalid transfer-encoding header"); + if let Some(value) = content_length { + validate_parsed_content_length(value, "invalid upstream content-length header")?; } - if !allow_chunked_request_bodies { - return Err("chunked request body not allowed"); + + let mut transfer_encodings = headers.get_all(TRANSFER_ENCODING).iter(); + let Some(transfer_encoding) = transfer_encodings.next() else { + return Ok(()); + }; + if content_length.is_some() { + return Err("ambiguous upstream transfer-encoding and content-length"); } + if transfer_encodings.next().is_some() { + return Err("multiple upstream transfer-encoding headers"); + } + validate_transfer_encoding_value(transfer_encoding.as_bytes(), true) + .map_err(|_| "unsupported upstream transfer-encoding") +} +fn validate_parsed_content_length( + value: &HeaderValue, + invalid_reason: &'static str, +) -> Result<(), &'static str> { + let value = value.to_str().map_err(|_| invalid_reason)?.trim(); + if value.is_empty() || value.contains(',') || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(invalid_reason); + } + let _ = value.parse::().map_err(|_| invalid_reason)?; Ok(()) } @@ -1756,11 +1791,13 @@ fn host_allowed(authority: &Authority, allowed_hosts: &[String]) -> bool { .strip_prefix('[') .and_then(|value| value.strip_suffix(']')) .unwrap_or(host); + let authority_has_port = authority.port().is_some(); allowed_hosts.iter().any(|allowed| { let allowed = allowed.trim(); allowed.eq_ignore_ascii_case(full) - || allowed.eq_ignore_ascii_case(host) - || allowed.eq_ignore_ascii_case(unbracketed_host) + || (!authority_has_port + && (allowed.eq_ignore_ascii_case(host) + || allowed.eq_ignore_ascii_case(unbracketed_host))) }) } @@ -2746,7 +2783,7 @@ impl ClientIpResolver { if self.max_forwarded_for_bytes > 0 && value.len() > self.max_forwarded_for_bytes { return Err(ClientIpHeaderError::TooLong); } - parse_forwarded_ip_token(value).ok_or(ClientIpHeaderError::Invalid) + parse_singleton_client_ip_token(value).ok_or(ClientIpHeaderError::Invalid) } fn is_trusted(&self, ip: IpAddr) -> bool { @@ -2877,6 +2914,14 @@ fn parse_forwarded_ip_token(raw: &str) -> Option { token.parse::().map(IpAddr::V6).ok() } +fn parse_singleton_client_ip_token(raw: &str) -> Option { + let token = raw.trim(); + if token.is_empty() { + return None; + } + token.parse().ok() +} + fn maybe_admin_response( state: &HttpProxyState, method: &str, @@ -3034,15 +3079,13 @@ fn joined_path_and_query( fn remove_hop_by_hop_headers(headers: &mut HeaderMap) { let mut extra = Vec::new(); for connection in headers.get_all(http::header::CONNECTION).iter() { - if let Ok(connection) = connection.to_str() { - for token in connection.split(',') { - let token = token.trim(); - if token.is_empty() { - continue; - } - if let Ok(name) = HeaderName::from_bytes(token.as_bytes()) { - extra.push(name); - } + for token in connection.as_bytes().split(|byte| *byte == b',') { + let token = trim_header_value(token); + if token.is_empty() { + continue; + } + if let Ok(name) = HeaderName::from_bytes(token) { + extra.push(name); } } } @@ -3279,6 +3322,24 @@ mod tests { assert!(!headers.contains_key("upgrade")); } + #[test] + fn strips_valid_connection_options_from_non_utf8_connection_values() { + let mut headers = HeaderMap::new(); + headers.insert( + "connection", + HeaderValue::from_bytes(b"x-test, \xff").unwrap(), + ); + headers.insert("x-test", HeaderValue::from_static("1")); + + remove_hop_by_hop_headers(&mut headers); + + assert!(!headers.contains_key("connection")); + assert!( + !headers.contains_key("x-test"), + "valid connection-options must be honored even when another token is malformed" + ); + } + #[test] fn upstream_response_hop_by_hop_headers_are_sanitized() { let mut response = Response::builder() @@ -3497,6 +3558,27 @@ mod tests { ); } + #[test] + fn client_ip_custom_identity_header_rejects_forwarded_for_address_variants() { + let resolver = ClientIpResolver::from_config(&ClientIpConfig { + header: "cf-connecting-ip".to_string(), + trusted_proxies: vec!["127.0.0.1/32".to_string()], + max_forwarded_for_bytes: 64, + ..Default::default() + }); + let peer: IpAddr = "127.0.0.1".parse().unwrap(); + + for value in ["198.51.100.7:443", "[2001:db8::1]", "[2001:db8::1]:443"] { + let mut headers = HeaderMap::new(); + headers.insert("cf-connecting-ip", HeaderValue::from_str(value).unwrap()); + assert_eq!( + resolver.resolve(peer, &headers).unwrap_err(), + ClientIpHeaderError::Invalid, + "{value} must be rejected for singleton identity headers" + ); + } + } + #[test] fn forwarded_headers_are_sanitized_for_untrusted_peers() { let stats = Stats::default(); @@ -3726,11 +3808,28 @@ mod tests { assert_eq!(parse_forwarded_ip_token("[2001:db8::1]junk"), None); } + #[test] + fn singleton_client_ip_tokens_are_raw_ip_literals_only() { + assert_eq!( + parse_singleton_client_ip_token("198.51.100.7"), + Some("198.51.100.7".parse().unwrap()) + ); + assert_eq!( + parse_singleton_client_ip_token("2001:db8::1"), + Some("2001:db8::1".parse().unwrap()) + ); + assert_eq!(parse_singleton_client_ip_token("198.51.100.7:443"), None); + assert_eq!(parse_singleton_client_ip_token("[2001:db8::1]"), None); + assert_eq!(parse_singleton_client_ip_token("[2001:db8::1]:443"), None); + } + #[test] fn parses_content_length_header() { let mut headers = HeaderMap::new(); headers.insert(CONTENT_LENGTH, HeaderValue::from_static("42")); assert_eq!(content_length(&headers), Some(42)); + headers.insert(CONTENT_LENGTH, HeaderValue::from_static(" \t42\t ")); + assert_eq!(content_length(&headers), Some(42)); headers.insert(CONTENT_LENGTH, HeaderValue::from_static("not-a-number")); assert_eq!(content_length(&headers), None); } @@ -3741,6 +3840,9 @@ mod tests { content_length_headers.insert(CONTENT_LENGTH, HeaderValue::from_static("42")); let mut chunked_headers = HeaderMap::new(); chunked_headers.insert(TRANSFER_ENCODING, HeaderValue::from_static("chunked")); + let mut chunked_with_empty_elements = HeaderMap::new(); + chunked_with_empty_elements + .insert(TRANSFER_ENCODING, HeaderValue::from_static(", chunked,")); assert_eq!( validate_request_framing(&content_length_headers, false), @@ -3751,6 +3853,10 @@ mod tests { Err("chunked request body not allowed") ); assert_eq!(validate_request_framing(&chunked_headers, true), Ok(())); + assert_eq!( + validate_request_framing(&chunked_with_empty_elements, true), + Ok(()) + ); } #[test] @@ -3762,6 +3868,11 @@ mod tests { comma_list.insert(CONTENT_LENGTH, HeaderValue::from_static("4, 4")); let mut invalid = HeaderMap::new(); invalid.insert(CONTENT_LENGTH, HeaderValue::from_static("nope")); + let mut overflow = HeaderMap::new(); + overflow.insert( + CONTENT_LENGTH, + HeaderValue::from_static("18446744073709551616"), + ); assert_eq!( validate_request_framing(&duplicate, false), @@ -3775,6 +3886,10 @@ mod tests { validate_request_framing(&invalid, false), Err("invalid content-length header") ); + assert_eq!( + validate_request_framing(&overflow, false), + Err("invalid content-length header") + ); } #[test] @@ -3833,7 +3948,102 @@ mod tests { assert_eq!( validate_request_framing(&invalid, true), - Err("invalid transfer-encoding header") + Err("too many empty transfer-encoding elements") + ); + + let empty_tail_spray = format!( + "chunked, {}", + std::iter::repeat_n("", MAX_EMPTY_TRANSFER_ENCODING_ELEMENTS + 1) + .collect::>() + .join(", ") + ); + let mut invalid = HeaderMap::new(); + invalid.insert( + TRANSFER_ENCODING, + HeaderValue::from_bytes(empty_tail_spray.as_bytes()).unwrap(), + ); + + assert_eq!( + validate_request_framing(&invalid, true), + Err("too many empty transfer-encoding elements") + ); + } + + #[test] + fn upstream_response_framing_allows_single_content_length_or_chunked() { + let mut content_length_headers = HeaderMap::new(); + content_length_headers.insert(CONTENT_LENGTH, HeaderValue::from_static("42")); + let mut chunked_headers = HeaderMap::new(); + chunked_headers.insert(TRANSFER_ENCODING, HeaderValue::from_static("chunked")); + + assert_eq!( + validate_upstream_response_framing(&content_length_headers), + Ok(()) + ); + assert_eq!(validate_upstream_response_framing(&chunked_headers), Ok(())); + } + + #[test] + fn upstream_response_framing_rejects_invalid_content_length() { + let mut duplicate = HeaderMap::new(); + duplicate.append(CONTENT_LENGTH, HeaderValue::from_static("4")); + duplicate.append(CONTENT_LENGTH, HeaderValue::from_static("4")); + let mut comma_list = HeaderMap::new(); + comma_list.insert(CONTENT_LENGTH, HeaderValue::from_static("4, 4")); + let mut invalid = HeaderMap::new(); + invalid.insert(CONTENT_LENGTH, HeaderValue::from_static("nope")); + let mut overflow = HeaderMap::new(); + overflow.insert( + CONTENT_LENGTH, + HeaderValue::from_static("18446744073709551616"), + ); + + assert_eq!( + validate_upstream_response_framing(&duplicate), + Err("multiple upstream content-length headers") + ); + assert_eq!( + validate_upstream_response_framing(&comma_list), + Err("invalid upstream content-length header") + ); + assert_eq!( + validate_upstream_response_framing(&invalid), + Err("invalid upstream content-length header") + ); + assert_eq!( + validate_upstream_response_framing(&overflow), + Err("invalid upstream content-length header") + ); + } + + #[test] + fn upstream_response_framing_rejects_ambiguous_or_bad_transfer_encoding() { + let mut te_and_cl = HeaderMap::new(); + te_and_cl.insert(CONTENT_LENGTH, HeaderValue::from_static("4")); + te_and_cl.insert(TRANSFER_ENCODING, HeaderValue::from_static("chunked")); + let mut duplicate_te = HeaderMap::new(); + duplicate_te.append(TRANSFER_ENCODING, HeaderValue::from_static("chunked")); + duplicate_te.append(TRANSFER_ENCODING, HeaderValue::from_static("chunked")); + let mut unsupported = HeaderMap::new(); + unsupported.insert(TRANSFER_ENCODING, HeaderValue::from_static("gzip")); + let mut extra_coding = HeaderMap::new(); + extra_coding.insert(TRANSFER_ENCODING, HeaderValue::from_static("gzip, chunked")); + + assert_eq!( + validate_upstream_response_framing(&te_and_cl), + Err("ambiguous upstream transfer-encoding and content-length") + ); + assert_eq!( + validate_upstream_response_framing(&duplicate_te), + Err("multiple upstream transfer-encoding headers") + ); + assert_eq!( + validate_upstream_response_framing(&unsupported), + Err("unsupported upstream transfer-encoding") + ); + assert_eq!( + validate_upstream_response_framing(&extra_coding), + Err("unsupported upstream transfer-encoding") ); } @@ -3994,6 +4204,13 @@ mod tests { ), Ok(()) ); + assert_eq!( + validate_raw_http1_header_block( + b"POST /drain HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: , chunked,\r\n\r\n", + true + ), + Ok(()) + ); } #[test] @@ -4028,6 +4245,20 @@ mod tests { ); } + #[test] + fn raw_initial_framing_precheck_rejects_invalid_header_names() { + for block in [ + b"GET / HTTP/1.1\r\nBad Name: value\r\n\r\n".as_slice(), + b"GET / HTTP/1.1\r\nBad@Name: value\r\n\r\n".as_slice(), + b"GET / HTTP/1.1\r\nBad\x7fName: value\r\n\r\n".as_slice(), + ] { + assert_eq!( + validate_raw_http1_header_block(block, false), + Err("invalid header name") + ); + } + } + #[test] fn raw_initial_framing_precheck_rejects_transfer_encoding_comma_spray() { let comma_spray = std::iter::repeat_n("gzip", 64) @@ -4049,7 +4280,22 @@ mod tests { assert_eq!( validate_raw_http1_header_block(block.as_bytes(), true), - Err("invalid transfer-encoding header") + Err("too many empty transfer-encoding elements") + ); + + let empty_tail_spray = format!( + "chunked, {}", + std::iter::repeat_n("", MAX_EMPTY_TRANSFER_ENCODING_ELEMENTS + 1) + .collect::>() + .join(", ") + ); + let block = format!( + "POST / HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: {empty_tail_spray}\r\n\r\n" + ); + + assert_eq!( + validate_raw_http1_header_block(block.as_bytes(), true), + Err("too many empty transfer-encoding elements") ); } @@ -4527,7 +4773,7 @@ mod tests { } let _ = stream .write_all( - b"HTTP/1.1 200 OK\r\nContent-Length: 128\r\nConnection: close\r\n\r\noversized", + b"HTTP/1.1 200 OK\r\nContent-Length: \t128 \r\nConnection: close\r\n\r\noversized", ) .await; }); @@ -4604,6 +4850,103 @@ mod tests { let _ = std::fs::remove_file(event_file); } + #[tokio::test] + async fn request_declared_oversized_body_with_ows_is_rejected_before_upstream() { + let upstream_listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let upstream_addr = upstream_listener.local_addr().unwrap(); + let upstream_hits = Arc::new(AtomicU64::new(0)); + let upstream_hits_for_task = Arc::clone(&upstream_hits); + let upstream_task = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = upstream_listener.accept().await else { + break; + }; + upstream_hits_for_task.fetch_add(1, Ordering::Relaxed); + let _ = stream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + } + }); + + let proxy_port = unused_tcp_port(); + let temp_suffix = format!("{}-{}", std::process::id(), upstream_addr.port()); + let filter_file = + std::env::temp_dir().join(format!("altura-prot-request-size-{temp_suffix}.json")); + let event_file = + std::env::temp_dir().join(format!("altura-prot-request-size-{temp_suffix}.jsonl")); + std::fs::write(&filter_file, r#"{"filters":[]}"#).unwrap(); + let engine = + FilterEngine::new(Vec::new(), filter_file.clone(), Duration::from_secs(60)).await; + let logger = Arc::new(crate::telemetry::EventLogger::new(&event_file).unwrap()); + let detector = AdaptiveDetector::new( + crate::adaptive::AdaptiveDetectorConfig { + enabled: false, + threshold_per_second: 1_000_000, + activation_ttl: Duration::from_secs(60), + event_cooldown: Duration::from_secs(1), + max_signature_windows: 8_192, + max_path_shape_windows: 8_192, + }, + Arc::clone(&engine), + Arc::clone(&logger), + ); + let stats = Arc::new(Stats::default()); + let cfg = test_http_config(&format!( + r#"{{ + "listen":"127.0.0.1:{proxy_port}", + "upstream":"http://{upstream_addr}", + "max_body_bytes":16 + }}"# + )); + let (startup_tx, startup_rx) = oneshot::channel(); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let mut proxy_task = tokio::spawn(run_http_proxy( + cfg, + engine, + detector, + Arc::clone(&stats), + logger, + Some(startup_tx), + shutdown_rx, + )); + startup_rx.await.unwrap().unwrap(); + + let mut stream = TcpStream::connect(("127.0.0.1", proxy_port)).await.unwrap(); + stream + .write_all( + b"POST /large HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: \t32 \r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + let response = read_test_response(&mut stream).await; + assert!( + response.starts_with(b"HTTP/1.1 413 Payload Too Large"), + "unexpected response: {}", + String::from_utf8_lossy(&response) + ); + assert!(response.ends_with(b"request body too large\n")); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(upstream_hits.load(Ordering::Relaxed), 0); + assert_eq!(stats.http_body_rejected.load(Ordering::Relaxed), 1); + assert_eq!(stats.http_proxied.load(Ordering::Relaxed), 0); + + let _ = shutdown_tx.send(true); + if timeout(Duration::from_secs(1), &mut proxy_task) + .await + .is_err() + { + proxy_task.abort(); + panic!("proxy task did not stop before test timeout"); + } + upstream_task.abort(); + let _ = std::fs::remove_file(filter_file); + let _ = std::fs::remove_file(event_file); + } + #[tokio::test] async fn rate_limit_denial_precedes_filter_evaluation() { let upstream_listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) @@ -4816,11 +5159,17 @@ mod tests { allowed_ipv6.insert(HOST, HeaderValue::from_static("[2001:db8::1]")); let mut denied = HeaderMap::new(); denied.insert(HOST, HeaderValue::from_static("evil.example")); + let mut denied_unlisted_port = HeaderMap::new(); + denied_unlisted_port.insert(HOST, HeaderValue::from_static("example.com:8443")); assert!(validate_host_header(&allowed, &cfg).is_ok()); assert!(validate_host_header(&allowed_authority, &cfg).is_ok()); assert!(validate_host_header(&allowed_ipv6, &cfg).is_ok()); assert_eq!(validate_host_header(&denied, &cfg), Err("host not allowed")); + assert_eq!( + validate_host_header(&denied_unlisted_port, &cfg), + Err("host not allowed") + ); } #[test] @@ -4841,6 +5190,12 @@ mod tests { validate_effective_host(&uri, &headers, &cfg), Err("host not allowed") ); + + let uri: Uri = "http://good.local:8443/app?x=1".parse().unwrap(); + assert_eq!( + validate_effective_host(&uri, &headers, &cfg), + Err("host not allowed") + ); } #[test] diff --git a/src/tcp_proxy.rs b/src/tcp_proxy.rs index d373c49..3c1d348 100644 --- a/src/tcp_proxy.rs +++ b/src/tcp_proxy.rs @@ -322,7 +322,7 @@ where tokio::pin!(idle_sleep); let (mut downstream_read, mut downstream_write) = split(downstream); let (mut upstream_read, mut upstream_write) = split(upstream); - let (activity_tx, mut activity_rx) = mpsc::unbounded_channel(); + let (activity_tx, mut activity_rx) = mpsc::channel(1); let downstream_activity_tx = if idle_enabled { Some(activity_tx.clone()) } else { @@ -391,7 +391,7 @@ async fn relay_direction( idle_timeout: Duration, mut rate: TcpRateGuard, too_slow: TcpRelayOutcome, - activity_tx: Option>, + activity_tx: Option>, ) -> io::Result where R: AsyncRead + Unpin, @@ -410,9 +410,13 @@ where if !write_all_with_idle(writer, &buf[..read], idle_timeout).await? { return Ok(TcpRelayOutcome::IdleTimeout); } - if let Some(activity_tx) = &activity_tx { - let _ = activity_tx.send(()); - } + notify_activity(&activity_tx); + } +} + +fn notify_activity(activity_tx: &Option>) { + if let Some(activity_tx) = activity_tx { + let _ = activity_tx.try_send(()); } } @@ -513,6 +517,22 @@ mod tests { let _ = timeout(Duration::from_millis(200), relay).await; } + #[tokio::test] + async fn activity_notifications_are_bounded_and_coalesced() { + let (activity_tx, mut activity_rx) = mpsc::channel(1); + let activity_tx = Some(activity_tx); + + notify_activity(&activity_tx); + notify_activity(&activity_tx); + notify_activity(&activity_tx); + + assert!(activity_rx.try_recv().is_ok()); + assert!(matches!( + activity_rx.try_recv(), + Err(mpsc::error::TryRecvError::Empty) + )); + } + #[tokio::test] async fn relay_keeps_upstream_to_downstream_flowing_when_downstream_write_stalls() { let (client, mut downstream) = duplex(64); diff --git a/tools/assert_local_bench.py b/tools/assert_local_bench.py index 678d7c1..c3b8054 100644 --- a/tools/assert_local_bench.py +++ b/tools/assert_local_bench.py @@ -31,10 +31,26 @@ "guardrails.path_shape_rate.short_token_sibling_event_shape_recorded", "short-token sibling churn must emit the parent path-shape event evidence", ), + ( + "guardrails.path_shape_rate.other_shape_allowed", + "path-shape limiting must not block unrelated request shapes", + ), ( "guardrails.path_shape_rate.version_shape_allowed", "normal versioned API routes must remain allowed", ), + ( + "guardrails.path_shape_rate.retry_after_header_matches", + "path-shape rate-limit responses must carry Retry-After", + ), + ( + "guardrails.path_shape_rate.cache_control_header_matches", + "path-shape rate-limit responses must be marked no-store", + ), + ( + "guardrails.path_shape_rate.path_shape_metric_matches", + "path-shape rate-limit probe must increment the dedicated path-shape metric", + ), ( "guardrails.path_shape_rate.rate_limited_metric_includes_path_shape_limit", "aggregate rate-limit metrics must include path-shape limiting", @@ -47,6 +63,14 @@ "guardrails.signature_rate.other_signature_allowed", "signature limiting must not block unrelated request signatures", ), + ( + "guardrails.signature_rate.retry_after_header_matches", + "signature-rate responses must carry Retry-After", + ), + ( + "guardrails.signature_rate.cache_control_header_matches", + "signature-rate responses must be marked no-store", + ), ( "guardrails.signature_rate.signature_metric_matches", "signature-rate probe must increment the dedicated signature metric", @@ -91,6 +115,10 @@ "guardrails.filter_activation_nonblocking.runtime_reload_loaded", "runtime filter reload must successfully load the generated rule set", ), + ( + "guardrails.runtime_sigterm.sigterm_graceful", + "runtime shutdown must handle SIGTERM gracefully within the probe timeout", + ), ( "guardrails.rate_limit_before_filter.rate_limit_precedes_filter", "rate limiting must run before blocking filter activation on the hot path", @@ -99,6 +127,14 @@ "guardrails.tracked_ip_cap.new_client_denied_when_active_shard_full", "tracked-IP capacity exhaustion must fail closed for new clients", ), + ( + "guardrails.tracked_ip_cap.first_client_initial_allowed", + "tracked-IP capacity probe must allow the first client before shard pressure", + ), + ( + "guardrails.tracked_ip_cap.first_client_rate_limited", + "tracked-IP capacity probe must keep the existing client bounded after pressure", + ), ( "guardrails.adaptive_catalog_shape.catalog_shape_requires_strong_evidence", "adaptive catalog path-shape activation must require strong evidence", @@ -139,6 +175,14 @@ "guardrails.request_content_encoding.compressed_request_not_stored", "compressed request rejection must be marked no-store", ), + ( + "guardrails.request_content_encoding.identity_request_allowed", + "identity-encoded request bodies must remain allowed", + ), + ( + "guardrails.expect_guard.normal_request_allowed", + "normal requests without Expect headers must remain allowed", + ), ( "guardrails.expect_guard.expect_continue_rejected", "Expect: 100-continue must be rejected by default", @@ -167,6 +211,14 @@ "guardrails.range_guard.range_rejections_not_stored", "Range rejections must be marked no-store", ), + ( + "guardrails.range_guard.single_range_allowed", + "single-range requests must remain allowed", + ), + ( + "guardrails.chunked_request_body_opt_in.chunked_request_allowed", + "chunked request bodies must be allowed only when explicitly configured", + ), ( "guardrails.accept_encoding.origin_accept_encoding_stripped", "origin requests must strip Accept-Encoding by default", @@ -231,6 +283,10 @@ "guardrails.initial_framing_precheck_response.initial_header_too_large_response_observed", "initial header byte overflow must generate a bounded rejection", ), + ( + "guardrails.initial_framing_precheck_response.initial_header_late_terminator_over_cap_observed", + "initial header byte overflow with a late terminator must generate a bounded rejection", + ), ( "guardrails.initial_framing_precheck_response.raw_initial_400_closes_connection", "raw initial framing rejection must close the downstream connection", @@ -243,6 +299,70 @@ "guardrails.host_guard.absolute_form_unsupported_scheme_rejected", "absolute-form request targets must reject non-http schemes before origin work", ), + ( + "guardrails.host_guard.bare_host_does_not_allow_unlisted_port", + "allowed-host entries without ports must not allow unlisted host:port authorities", + ), + ( + "guardrails.host_guard.absolute_form_bare_host_does_not_allow_unlisted_port", + "absolute-form allowed-host checks must require exact host:port authorities", + ), + ( + "guardrails.uri_guard.raw_initial_request_target_guard_observed", + "raw initial request-target guard must reject oversized request targets before origin work", + ), + ( + "guardrails.allowed_hosts_startup.blank_rejected", + "allowed-host config must reject blank authorities at startup", + ), + ( + "guardrails.allowed_hosts_startup.duplicate_rejected", + "allowed-host config must reject duplicate authorities at startup", + ), + ( + "guardrails.allowed_hosts_startup.invalid_authority_rejected", + "allowed-host config must reject invalid authorities at startup", + ), + ( + "guardrails.allowed_hosts_startup.oversized_rejected", + "allowed-host config must reject oversized authorities at startup", + ), + ( + "guardrails.allowed_hosts_startup.too_many_rejected", + "allowed-host config must reject excessive authorities at startup", + ), + ( + "guardrails.allowed_hosts_startup.userinfo_rejected", + "allowed-host config must reject authorities with userinfo at startup", + ), + ( + "guardrails.allowed_hosts_startup.whitespace_rejected", + "allowed-host config must reject authorities with whitespace at startup", + ), + ( + "guardrails.allowed_hosts_startup.wildcard_rejected", + "allowed-host config must reject wildcard authorities at startup", + ), + ( + "guardrails.allowed_methods_startup.duplicate_rejected", + "allowed HTTP methods must reject duplicates at startup", + ), + ( + "guardrails.allowed_methods_startup.empty_rejected", + "allowed HTTP methods must reject empty method lists at startup", + ), + ( + "guardrails.allowed_methods_startup.invalid_token_rejected", + "allowed HTTP methods must reject invalid method tokens at startup", + ), + ( + "guardrails.allowed_methods_startup.oversized_token_rejected", + "allowed HTTP methods must reject oversized method tokens at startup", + ), + ( + "guardrails.allowed_methods_startup.too_many_rejected", + "allowed HTTP methods must reject excessive method lists at startup", + ), ( "guardrails.allowed_methods_startup.unsupported_connect_rejected", "allowed HTTP methods must reject CONNECT at startup", @@ -255,6 +375,38 @@ "guardrails.allowed_methods_startup.unsupported_track_rejected", "allowed HTTP methods must reject TRACK at startup", ), + ( + "guardrails.header_buffer_floor_startup.downstream_max_header_bytes_rejected", + "downstream header byte cap below the safe floor must fail startup", + ), + ( + "guardrails.header_buffer_floor_startup.upstream_max_header_bytes_rejected", + "upstream header byte cap below the safe floor must fail startup", + ), + ( + "guardrails.header_buffer_ceiling_startup.downstream_max_header_bytes_rejected", + "downstream header byte cap above the supported ceiling must fail startup", + ), + ( + "guardrails.header_buffer_ceiling_startup.upstream_max_header_bytes_rejected", + "upstream header byte cap above the supported ceiling must fail startup", + ), + ( + "guardrails.header_count_ceiling_startup.downstream_max_headers_rejected", + "downstream header count above the supported ceiling must fail startup", + ), + ( + "guardrails.header_count_ceiling_startup.upstream_max_headers_rejected", + "upstream header count above the supported ceiling must fail startup", + ), + ( + "guardrails.header_read_timeout_ceiling_startup.header_read_timeout_rejected", + "header read timeout above the supported ceiling must fail startup", + ), + ( + "guardrails.header_read_timeout_ceiling_startup.startup_rejected", + "header read-timeout ceiling probe must fail startup for unsupported values", + ), ( "guardrails.http_endpoint_startup.invalid_listen_rejected", "invalid HTTP listen addresses must fail startup before binding", @@ -299,6 +451,38 @@ "guardrails.tcp_endpoint_startup.upstream_zero_port_rejected", "TCP upstream config must reject port zero", ), + ( + "guardrails.client_ip_config_startup.blank_trusted_proxy_rejected", + "client-IP config must reject blank trusted-proxy entries", + ), + ( + "guardrails.client_ip_config_startup.duplicate_trusted_proxy_rejected", + "client-IP config must reject duplicate trusted-proxy entries", + ), + ( + "guardrails.client_ip_config_startup.invalid_header_rejected", + "client-IP config must reject invalid trusted identity header names", + ), + ( + "guardrails.client_ip_config_startup.invalid_trusted_proxy_rejected", + "client-IP config must reject invalid trusted-proxy networks", + ), + ( + "guardrails.client_ip_config_startup.oversized_header_rejected", + "client-IP config must reject oversized trusted identity header names", + ), + ( + "guardrails.client_ip_config_startup.oversized_trusted_proxy_rejected", + "client-IP config must reject oversized trusted-proxy entries", + ), + ( + "guardrails.client_ip_config_startup.too_many_trusted_proxies_rejected", + "client-IP config must reject excessive trusted-proxy entries", + ), + ( + "guardrails.trusted_proxy_global_trust_startup.global_trusted_proxy_rejected", + "trusted-proxy config must reject globally trusting all remote clients", + ), ( "guardrails.method_override_headers.override_headers_rejected", "method override headers must be rejected by default before origin work", @@ -311,6 +495,22 @@ "guardrails.trusted_proxy_aggregate_rate.rotating_xff_aggregate_limited", "trusted-proxy aggregate rate limit must catch rotating X-Forwarded-For floods", ), + ( + "guardrails.trusted_proxy_aggregate_rate.first_burst_allowed", + "trusted-proxy aggregate rate limit must allow the initial configured burst", + ), + ( + "guardrails.trusted_proxy_aggregate_rate.retry_after_header_matches", + "trusted-proxy aggregate rate-limit responses must carry Retry-After", + ), + ( + "guardrails.trusted_proxy_aggregate_rate.cache_control_header_matches", + "trusted-proxy aggregate rate-limit responses must be marked no-store", + ), + ( + "guardrails.trusted_proxy_aggregate_rate.trusted_proxy_metric_matches", + "trusted-proxy aggregate probe must increment the trusted-proxy limit metric", + ), ( "guardrails.forwarded_headers.trusted_proxy.duplicate_xff_chain_preserved", "trusted-proxy forwarding must canonicalize every duplicate X-Forwarded-For value used for client identity", @@ -331,6 +531,62 @@ "guardrails.forwarded_headers.custom_identity_header.custom_identity_rejected_metric_matches", "trusted custom identity header rejections must increment the forwarded-rejected metric", ), + ( + "guardrails.forwarded_header_bounds.valid_chain_allowed", + "bounded forwarded-header parsing must allow a valid trusted chain", + ), + ( + "guardrails.forwarded_header_bounds.malformed_chain_rejected", + "bounded forwarded-header parsing must reject malformed chains", + ), + ( + "guardrails.forwarded_header_bounds.oversized_chain_rejected", + "bounded forwarded-header parsing must reject oversized chains", + ), + ( + "guardrails.forwarded_header_bounds.too_many_hops_rejected", + "bounded forwarded-header parsing must reject excessive hop counts", + ), + ( + "guardrails.forwarded_header_bounds.rejection_metric_matches", + "bounded forwarded-header rejections must increment the forwarded-rejected metric", + ), + ( + "guardrails.trusted_proxy_in_flight.rotating_xff_peer_concurrency_limited", + "trusted-proxy in-flight limits must catch rotating X-Forwarded-For peer floods", + ), + ( + "guardrails.trusted_proxy_in_flight.retry_after_header_matches", + "trusted-proxy in-flight overload responses must carry Retry-After", + ), + ( + "guardrails.trusted_proxy_in_flight.cache_control_header_matches", + "trusted-proxy in-flight overload responses must be marked no-store", + ), + ( + "guardrails.trusted_proxy_in_flight.trusted_proxy_metric_matches", + "trusted-proxy in-flight probe must increment the trusted-proxy limit metric", + ), + ( + "guardrails.ip_prefix_aggregation.same_ipv6_prefix_limited", + "IPv6 prefix aggregation must limit rotating clients inside the same prefix", + ), + ( + "guardrails.ip_prefix_aggregation.different_ipv6_prefix_allowed", + "IPv6 prefix aggregation must allow clients from unrelated prefixes", + ), + ( + "guardrails.ip_prefix_aggregation.retry_after_header_matches", + "IPv6 prefix aggregation limit responses must carry Retry-After", + ), + ( + "guardrails.ip_prefix_aggregation.cache_control_header_matches", + "IPv6 prefix aggregation limit responses must be marked no-store", + ), + ( + "guardrails.ip_prefix_aggregation.rate_limited_metric_matches", + "IPv6 prefix aggregation probe must increment the rate-limited metric", + ), ( "guardrails.upstream_failure_circuit.circuit_scoped_to_path_shape", "upstream failure circuit breaker must be scoped by path shape", @@ -391,10 +647,62 @@ "guardrails.upstream_response_guard.stalled_response.closed", "stalled upstream response bodies must be closed", ), + ( + "guardrails.request_trailer_policy.request_trailer_policy_observed", + "request trailer policy must be exercised by the benchmark", + ), + ( + "guardrails.request_trailer_policy.oversized_request_trailer_rejected", + "oversized request trailers must be rejected", + ), + ( + "guardrails.request_trailer_policy.generated_431_closes_connection", + "oversized request-trailer rejections must close the downstream connection", + ), + ( + "guardrails.request_trailer_policy.generated_431_not_stored", + "oversized request-trailer rejections must be marked no-store", + ), + ( + "guardrails.request_trailer_policy.connection_closed_before_followup", + "oversized request-trailer rejections must close before keep-alive reuse", + ), + ( + "guardrails.upstream_trailer_policy.upstream_trailer_policy_observed", + "upstream trailer policy must be exercised by the benchmark", + ), ( "guardrails.downstream_keep_alive.closed_before_second_response", "downstream keep-alive must be disabled by default", ), + ( + "guardrails.connection_request_limit.connection_request_limit_observed", + "per-connection request-count guard must be observed", + ), + ( + "guardrails.connection_request_limit.third_request_limited", + "per-connection request-count guard must limit excess keep-alive reuse", + ), + ( + "guardrails.connection_request_limit.retry_after_header_matches", + "per-connection request-count limit responses must carry Retry-After", + ), + ( + "guardrails.connection_request_limit.cache_control_header_matches", + "per-connection request-count limit responses must be marked no-store", + ), + ( + "guardrails.connection_request_limit.connection_close_header_matches", + "per-connection request-count limit responses must close the connection", + ), + ( + "guardrails.connection_request_limit.connection_closed_before_fourth", + "per-connection request-count guard must close before further reuse", + ), + ( + "guardrails.downstream_write_timeout.downstream_write_timeout_observed", + "downstream write timeout must close write-backpressured responses", + ), ( "guardrails.tcp_global_connection_cap.rejected_closed", "TCP global connection cap must reject excess active connections", @@ -411,6 +719,10 @@ "guardrails.tcp_relay_head_of_line.upstream_response_delivered_while_downstream_write_blocked", "TCP relay must keep the opposite direction flowing while one direction is write-backpressured", ), + ( + "guardrails.log_suppression.timeout_log_lines_bounded", + "timeout log suppression must keep repeated timeout logs bounded", + ), ( "guardrails.event_log_async_queue.all_requests_completed", "event-log queue pressure must not stall request completion", @@ -419,10 +731,46 @@ "guardrails.event_log_async_queue.event_log_queue_dropped", "event-log async queue must drop when full instead of blocking the request thread", ), + ( + "guardrails.event_log_flush.all_requests_ok", + "event-log flush probe traffic must complete successfully", + ), + ( + "guardrails.event_log_flush.first_event_flushed_immediately", + "event logging must flush the first event immediately for early attack evidence", + ), + ( + "guardrails.event_log_flush.burst_flush_batched", + "event logging must batch burst flushes instead of forcing per-event sync I/O", + ), + ( + "guardrails.event_log_flush.interval_flush_observed", + "event logging must flush batched events on the configured interval", + ), ( "guardrails.event_log_field_bounds.all_event_fields_bounded", "event-log fields must remain bounded before serialization", ), + ( + "guardrails.event_log_rotation.all_requests_ok", + "event-log rotation probe traffic must complete successfully", + ), + ( + "guardrails.event_log_rotation.backup_present", + "event-log rotation must create a bounded backup file after the byte cap is reached", + ), + ( + "guardrails.event_log_rotation.active_log_present", + "event-log rotation must leave an active log file available for new events", + ), + ( + "guardrails.event_log_rotation.jsonl_valid", + "event-log rotation must preserve valid JSONL records", + ), + ( + "guardrails.event_log_rotation.total_bytes_bounded", + "event-log rotation must keep retained log bytes bounded", + ), ( "guardrails.runtime_filter_bounds.oversized_file_rejected_and_last_good_preserved", "oversized runtime filter files must be rejected while preserving last-good rules", @@ -431,6 +779,218 @@ "guardrails.runtime_filter_bounds.too_many_rules_rejected_and_last_good_preserved", "runtime filter over-capacity files must be rejected while preserving last-good rules", ), + ( + "guardrails.runtime_filter_hot_path.normal_request_allowed", + "runtime filter hot-path probe must allow normal requests", + ), + ( + "guardrails.runtime_filter_hot_path.control_request_allowed", + "runtime filter hot-path probe must allow control requests", + ), + ( + "guardrails.filter_rule_startup.empty_condition_rejected", + "static filter rules with empty conditions must fail startup", + ), + ( + "guardrails.filter_rule_startup.invalid_status_rejected", + "static filter rules with invalid generated status codes must fail startup", + ), + ( + "guardrails.filter_rule_startup.oversized_body_rejected", + "static filter rules with oversized generated bodies must fail startup", + ), + ( + "guardrails.filter_rule_startup.too_many_static_filters_rejected", + "static filter rule counts above supported bounds must fail startup", + ), + ( + "guardrails.config_file_startup.oversized_config_rejected", + "oversized config files must fail startup before loading", + ), + ( + "guardrails.config_file_startup.non_regular_config_rejected", + "non-regular config paths must fail startup before loading", + ), + ( + "guardrails.negative_rate_startup.negative_http_rate_rejected", + "negative HTTP rate limits must fail startup", + ), + ( + "guardrails.negative_rate_startup.negative_tcp_rate_rejected", + "negative TCP rate limits must fail startup", + ), + ( + "guardrails.zero_capacity_startup.http_connection_cap_rejected", + "zero HTTP connection capacity must fail startup", + ), + ( + "guardrails.zero_capacity_startup.tcp_connection_cap_rejected", + "zero TCP connection capacity must fail startup", + ), + ( + "guardrails.zero_capacity_startup.http_metadata_cap_rejected", + "zero HTTP metadata capacity must fail startup", + ), + ( + "guardrails.zero_capacity_startup.http_forwarded_cap_rejected", + "zero forwarded-header capacity must fail startup", + ), + ( + "guardrails.zero_capacity_startup.http_upstream_connect_timeout_rejected", + "zero HTTP upstream connect timeout must fail startup", + ), + ( + "guardrails.dynamic_state_ceiling_startup.all_dynamic_state_ceilings_rejected", + "dynamic state ceilings above supported bounds must fail startup", + ), + ( + "guardrails.control_capacity_startup.filter_file_cap_rejected", + "filter file capacity above supported bounds must fail startup", + ), + ( + "guardrails.control_capacity_startup.filter_rule_cap_rejected", + "filter rule capacity above supported bounds must fail startup", + ), + ( + "guardrails.control_capacity_startup.adaptive_queue_cap_rejected", + "adaptive queue capacity above supported bounds must fail startup", + ), + ( + "guardrails.control_capacity_startup.adaptive_window_cap_rejected", + "adaptive window capacity above supported bounds must fail startup", + ), + ( + "guardrails.control_capacity_startup.adaptive_log_cap_rejected", + "adaptive log capacity above supported bounds must fail startup", + ), + ( + "guardrails.control_capacity_startup.adaptive_flush_interval_rejected", + "adaptive flush interval above supported bounds must fail startup", + ), + ( + "guardrails.http_metadata_ceiling_startup.all_http_metadata_ceilings_rejected", + "HTTP metadata ceilings above supported bounds must fail startup", + ), + ( + "guardrails.http_stream_timeout_ceiling_startup.request_body_idle_timeout_rejected", + "request-body idle timeout above supported bounds must fail startup", + ), + ( + "guardrails.http_stream_timeout_ceiling_startup.upstream_body_idle_timeout_rejected", + "upstream-body idle timeout above supported bounds must fail startup", + ), + ( + "guardrails.http_stream_timeout_ceiling_startup.downstream_write_timeout_rejected", + "downstream write timeout above supported bounds must fail startup", + ), + ( + "guardrails.http_stream_timeout_ceiling_startup.request_body_min_rate_grace_rejected", + "request-body minimum-rate grace above supported bounds must fail startup", + ), + ( + "guardrails.http_stream_timeout_ceiling_startup.upstream_body_min_rate_grace_rejected", + "upstream-body minimum-rate grace above supported bounds must fail startup", + ), + ( + "guardrails.body_size_ceiling_startup.max_body_bytes_rejected", + "request body size above supported bounds must fail startup", + ), + ( + "guardrails.body_size_ceiling_startup.max_upstream_body_bytes_rejected", + "upstream body size above supported bounds must fail startup", + ), + ( + "guardrails.min_rate_ceiling_startup.all_min_rate_ceilings_rejected", + "minimum-rate ceilings above supported bounds must fail startup", + ), + ( + "guardrails.min_rate_ceiling_startup.http_request_body_min_rate_rejected", + "HTTP request-body minimum-rate ceiling above supported bounds must fail startup", + ), + ( + "guardrails.min_rate_ceiling_startup.http_upstream_body_min_rate_rejected", + "HTTP upstream-body minimum-rate ceiling above supported bounds must fail startup", + ), + ( + "guardrails.min_rate_ceiling_startup.tcp_downstream_min_rate_rejected", + "TCP downstream minimum-rate ceiling above supported bounds must fail startup", + ), + ( + "guardrails.min_rate_ceiling_startup.tcp_upstream_min_rate_rejected", + "TCP upstream minimum-rate ceiling above supported bounds must fail startup", + ), + ( + "guardrails.connect_timeout_ceiling_startup.http_upstream_connect_timeout_rejected", + "HTTP upstream connect timeout above supported bounds must fail startup", + ), + ( + "guardrails.connect_timeout_ceiling_startup.tcp_connect_timeout_rejected", + "TCP connect timeout above supported bounds must fail startup", + ), + ( + "guardrails.upstream_timeout_ceiling_startup.upstream_timeout_rejected", + "upstream response timeout above supported bounds must fail startup", + ), + ( + "guardrails.upstream_timeout_ceiling_startup.startup_rejected", + "upstream response timeout ceiling probe must fail startup for unsupported values", + ), + ( + "guardrails.upstream_failure_circuit_ceiling_startup.upstream_failure_threshold_rejected", + "upstream failure threshold above supported bounds must fail startup", + ), + ( + "guardrails.upstream_failure_circuit_ceiling_startup.upstream_failure_open_ms_rejected", + "upstream failure open interval above supported bounds must fail startup", + ), + ( + "guardrails.upstream_idle_pool_ceiling_startup.upstream_pool_max_idle_per_host_rejected", + "upstream idle pool size above supported bounds must fail startup", + ), + ( + "guardrails.upstream_idle_pool_ceiling_startup.upstream_pool_idle_timeout_rejected", + "upstream idle pool timeout above supported bounds must fail startup", + ), + ( + "guardrails.connection_duration_ceiling_startup.http_max_connection_duration_rejected", + "HTTP maximum connection duration above supported bounds must fail startup", + ), + ( + "guardrails.connection_duration_ceiling_startup.tcp_max_connection_duration_rejected", + "TCP maximum connection duration above supported bounds must fail startup", + ), + ( + "guardrails.connection_request_count_ceiling_startup.max_requests_per_connection_rejected", + "maximum requests per connection above supported bounds must fail startup", + ), + ( + "guardrails.connection_request_count_ceiling_startup.startup_rejected", + "connection request-count ceiling probe must fail startup for unsupported values", + ), + ( + "guardrails.event_log_queue_capacity_ceiling_startup.event_log_queue_capacity_rejected", + "event-log queue capacity above supported bounds must fail startup", + ), + ( + "guardrails.event_log_queue_capacity_ceiling_startup.startup_rejected", + "event-log queue capacity ceiling probe must fail startup for unsupported values", + ), + ( + "guardrails.event_log_backup_count_ceiling_startup.event_log_backup_count_rejected", + "event-log backup count above supported bounds must fail startup", + ), + ( + "guardrails.event_log_backup_count_ceiling_startup.startup_rejected", + "event-log backup count ceiling probe must fail startup for unsupported values", + ), + ( + "guardrails.runtime_nofile.runtime_nofile_observed", + "runtime NOFILE limit must be observed in the benchmarked process", + ), + ( + "guardrails.runtime_nofile_capacity.capacity_rejected", + "startup must reject configured socket capacity above runtime NOFILE capacity", + ), ( "guardrails.edge_template_port_coverage.systemd_unit_allowed", "shipped systemd unit must satisfy runtime guardrails", @@ -463,18 +1023,206 @@ "guardrails.edge_template_port_coverage.missing_public_port_rejected", "edge template validation must reject missing public listener ports", ), + ( + "guardrails.edge_template_port_coverage.loopback_missing_port_allowed", + "edge template validation must allow loopback-only listeners to be absent from public edge rules", + ), ( "guardrails.edge_template_port_coverage.weak_systemd_sandbox_rejected", "systemd validation must reject weak sandboxing", ), + ( + "guardrails.edge_template_port_coverage.missing_connlimit_set_size_rejected", + "edge template validation must reject connlimit sets without explicit size bounds", + ), + ( + "guardrails.edge_template_port_coverage.missing_udp_drop_rejected", + "edge template validation must reject missing UDP drop backstops", + ), + ( + "guardrails.edge_template_port_coverage.generic_tcp_backstops_allowed", + "edge template validation must allow generic TCP backstops with explicit transport matching", + ), + ( + "guardrails.edge_template_port_coverage.missing_generic_tcp_l4proto_rejected", + "edge template validation must reject generic TCP backstops missing meta l4proto", + ), + ( + "guardrails.edge_template_port_coverage.ipv6_prefix_backstops_allowed", + "edge template validation must allow IPv6 prefix SYN and connlimit backstops", + ), + ( + "guardrails.edge_template_port_coverage.missing_ipv6_prefix_syn_backstop_rejected", + "edge template validation must reject missing IPv6 prefix SYN backstops", + ), + ( + "guardrails.edge_template_port_coverage.missing_ipv6_prefix_connlimit_rejected", + "edge template validation must reject missing IPv6 prefix connlimit backstops", + ), + ( + "guardrails.edge_template_port_coverage.ipv6_extension_safe_protocols_allowed", + "edge template validation must allow IPv6 extension-header-safe protocol matches", + ), + ( + "guardrails.edge_template_port_coverage.missing_ipv6_syn_l4proto_rejected", + "edge template validation must reject IPv6 SYN rules missing meta l4proto", + ), + ( + "guardrails.edge_template_port_coverage.missing_ipv6_connlimit_l4proto_rejected", + "edge template validation must reject IPv6 connlimit rules missing meta l4proto", + ), + ( + "guardrails.edge_template_port_coverage.missing_ipv6_icmp_l4proto_rejected", + "edge template validation must reject IPv6 ICMP rules missing transport-safe matching", + ), + ( + "guardrails.edge_template_port_coverage.icmpv4_control_exemption_allowed", + "edge template validation must allow required ICMPv4 control traffic before drops", + ), + ( + "guardrails.edge_template_port_coverage.missing_icmpv4_control_exemption_rejected", + "edge template validation must reject missing ICMPv4 control exemptions", + ), + ( + "guardrails.edge_template_port_coverage.late_icmpv4_control_exemption_rejected", + "edge template validation must reject ICMPv4 control exemptions placed after drops", + ), + ( + "guardrails.edge_template_port_coverage.icmpv6_control_exemption_allowed", + "edge template validation must allow required ICMPv6 control traffic before drops", + ), + ( + "guardrails.edge_template_port_coverage.missing_icmpv6_control_exemption_rejected", + "edge template validation must reject missing ICMPv6 control exemptions", + ), + ( + "guardrails.edge_template_port_coverage.late_icmpv6_control_exemption_rejected", + "edge template validation must reject ICMPv6 control exemptions placed after drops", + ), + ( + "guardrails.edge_template_port_coverage.syn_rate_set_bounds_allowed", + "edge template validation must allow bounded SYN-rate sets", + ), + ( + "guardrails.edge_template_port_coverage.missing_syn_rate_set_size_rejected", + "edge template validation must reject SYN-rate sets without explicit size bounds", + ), + ( + "guardrails.edge_template_port_coverage.missing_syn_rate_set_timeout_rejected", + "edge template validation must reject SYN-rate sets without explicit timeout bounds", + ), + ( + "guardrails.edge_template_port_coverage.fragment_sysctls_allowed", + "host sysctl validation must allow bounded fragment reassembly settings", + ), + ( + "guardrails.edge_template_port_coverage.missing_fragment_sysctls_rejected", + "host sysctl validation must reject missing fragment reassembly guardrails", + ), + ( + "guardrails.edge_template_port_coverage.excessive_fragment_time_rejected", + "host sysctl validation must reject excessive fragment retention time", + ), + ( + "guardrails.edge_template_port_coverage.invalid_fragment_thresholds_rejected", + "host sysctl validation must reject invalid fragment memory thresholds", + ), + ( + "guardrails.edge_template_port_coverage.insufficient_systemd_nofile_rejected", + "systemd validation must reject LimitNOFILE values below shipped guardrail minimums", + ), + ( + "guardrails.edge_template_port_coverage.excessive_systemd_capabilities_rejected", + "systemd validation must reject excessive service capabilities", + ), + ( + "guardrails.admin_prefix_startup.query_prefix_rejected", + "admin prefix config must reject prefixes containing query strings", + ), + ( + "guardrails.admin_prefix_startup.relative_prefix_rejected", + "admin prefix config must reject relative prefixes", + ), + ( + "guardrails.admin_prefix_startup.root_prefix_rejected", + "admin prefix config must reject root prefixes", + ), + ( + "guardrails.admin_token_startup.blank_token_rejected", + "admin metrics token config must reject blank tokens", + ), + ( + "guardrails.admin_token_startup.control_token_rejected", + "admin metrics token config must reject control characters", + ), + ( + "guardrails.admin_token_startup.empty_token_rejected", + "admin metrics token config must reject empty tokens", + ), ( "guardrails.admin_token_startup.long_token_rejected", "admin metrics tokens above the bounded comparison budget must fail startup", ), + ( + "guardrails.admin_token_startup.padded_token_rejected", + "admin metrics token config must reject tokens with padding whitespace", + ), ( "guardrails.admin_control_plane.duplicate_metrics_token_rejected", "metrics auth must fail closed when duplicate admin token headers are present", ), + ( + "guardrails.admin_control_plane.admin_responses_not_stored", + "admin control-plane responses must be marked no-store", + ), + ( + "guardrails.admin_control_plane.admin_responses_close_connection", + "admin control-plane responses must close connections by default", + ), + ( + "guardrails.admin_control_plane.body_bearing_admin_responses_not_stored", + "body-bearing admin control-plane responses must be marked no-store", + ), + ( + "guardrails.admin_control_plane.body_bearing_health_closes_connection", + "body-bearing health checks must close before keep-alive reuse", + ), + ( + "guardrails.admin_control_plane.body_bearing_metrics_without_token_closes_connection", + "body-bearing unauthenticated metrics checks must close before keep-alive reuse", + ), + ( + "guardrails.admin_control_plane.body_bearing_metrics_with_token_closes_connection", + "body-bearing authenticated metrics checks must close before keep-alive reuse", + ), + ( + "guardrails.admin_rate_limit.retry_after_header_matches", + "admin endpoint per-IP rate-limit responses must carry Retry-After", + ), + ( + "guardrails.admin_rate_limit.cache_control_header_matches", + "admin endpoint per-IP rate-limit responses must be marked no-store", + ), + ( + "guardrails.admin_signature_rate.admin_health_signature_limited", + "admin health requests must be covered by request-signature limiting", + ), + ( + "guardrails.admin_signature_rate.retry_after_header_matches", + "admin signature-rate responses must carry Retry-After", + ), + ( + "guardrails.admin_signature_rate.cache_control_header_matches", + "admin signature-rate responses must be marked no-store", + ), + ( + "guardrails.admin_signature_rate.signature_metric_matches", + "admin signature-rate probe must increment the dedicated signature metric", + ), + ( + "guardrails.admin_signature_rate.rate_limited_metric_includes_signature_limit", + "aggregate rate-limit metrics must include admin signature limiting", + ), ) @@ -499,6 +1247,16 @@ 0, "default TCP minimum-rate guard must keep the third slow-drip byte from echoing", ), + ( + "guardrails.admin_rate_limit.statuses", + [200, 429], + "admin health per-IP rate-limit probe must allow the first request and limit the second", + ), + ( + "guardrails.admin_signature_rate.statuses", + [200, 200, 429, 429], + "admin health signature-rate probe must allow the burst then limit repeated signatures", + ), ) diff --git a/tools/codex_analyzer.py b/tools/codex_analyzer.py index 7c62872..9b77ce3 100644 --- a/tools/codex_analyzer.py +++ b/tools/codex_analyzer.py @@ -21,7 +21,13 @@ SAFE_ID = re.compile(r"[^a-zA-Z0-9_.:-]+") +HTTP_TOKEN = re.compile(r"[-!#$%&'*+.^_`|~0-9A-Za-z]+\Z") +SUPPORTED_SIGNATURE = re.compile(r"(?:[0-9a-f]{16}|[0-9a-f]{32})\Z") FILTER_TTL_MAX_SECONDS = 24 * 60 * 60 +FILTER_RULE_ID_MAX_BYTES = 96 +FILTER_MATCH_VALUE_MAX_BYTES = 512 +FILTER_HEADER_NAME_MAX_BYTES = 64 +FILTER_HEADER_CONTAINS_MAX_BYTES = 256 DEFAULT_PROVIDER_CONFIG = { "selected_provider": "codex", @@ -132,6 +138,10 @@ def event_has_runtime_signature_basis(event: dict[str, Any]) -> bool: return isinstance(basis, str) and basis.count("|") >= 4 +def is_supported_signature(value: str) -> bool: + return bool(SUPPORTED_SIGNATURE.fullmatch(value)) + + def count_attack_evidence(events: list[dict[str, Any]]) -> int: """Number of strong-evidence attack events: requests a deterministic control actually denied (rate limits, filter blocks, body guards). @@ -395,7 +405,7 @@ def codex_filters(prompt: dict[str, Any], provider_cfg: dict[str, Any], ttl_seco ) parsed = extract_json(result.final_response) filters = parsed.get("filters", []) if isinstance(parsed, dict) else [] - return [sanitize_filter(item, ttl_seconds) for item in filters if isinstance(item, dict)] + return sanitized_filter_list(filters, ttl_seconds) def reasoning_effort(raw: Any, enum_cls: Any) -> Any: @@ -431,7 +441,7 @@ def openai_filters(prompt: dict[str, Any], provider_cfg: dict[str, Any], ttl_sec ) parsed = extract_json(response_text_from_openai(data)) filters = parsed.get("filters", []) if isinstance(parsed, dict) else [] - return [sanitize_filter(item, ttl_seconds) for item in filters if isinstance(item, dict)] + return sanitized_filter_list(filters, ttl_seconds) def anthropic_filters(prompt: dict[str, Any], provider_cfg: dict[str, Any], ttl_seconds: int) -> list[dict[str, Any]]: @@ -454,7 +464,7 @@ def anthropic_filters(prompt: dict[str, Any], provider_cfg: dict[str, Any], ttl_ ) parsed = extract_json(response_text_from_anthropic(data)) filters = parsed.get("filters", []) if isinstance(parsed, dict) else [] - return [sanitize_filter(item, ttl_seconds) for item in filters if isinstance(item, dict)] + return sanitized_filter_list(filters, ttl_seconds) def openrouter_filters(prompt: dict[str, Any], provider_cfg: dict[str, Any], ttl_seconds: int) -> list[dict[str, Any]]: @@ -475,7 +485,7 @@ def openrouter_filters(prompt: dict[str, Any], provider_cfg: dict[str, Any], ttl data = post_json(f"{base_url}/chat/completions", headers, payload) parsed = extract_json(response_text_from_chat_completion(data)) filters = parsed.get("filters", []) if isinstance(parsed, dict) else [] - return [sanitize_filter(item, ttl_seconds) for item in filters if isinstance(item, dict)] + return sanitized_filter_list(filters, ttl_seconds) def gemini_filters(prompt: dict[str, Any], provider_cfg: dict[str, Any], ttl_seconds: int) -> list[dict[str, Any]]: @@ -494,7 +504,7 @@ def gemini_filters(prompt: dict[str, Any], provider_cfg: dict[str, Any], ttl_sec ) parsed = extract_json(response_text_from_gemini(data)) filters = parsed.get("filters", []) if isinstance(parsed, dict) else [] - return [sanitize_filter(item, ttl_seconds) for item in filters if isinstance(item, dict)] + return sanitized_filter_list(filters, ttl_seconds) def cli_agent_argv(provider: str, command: str, model: str, prompt_text: str) -> tuple[list[str], bool]: @@ -559,7 +569,7 @@ def cli_agent_filters(provider: str, prompt: dict[str, Any], provider_cfg: dict[ raise RuntimeError(f"{command} exited {proc.returncode}: {detail or 'not logged in? run ' + login_cmd}") parsed = extract_json(cli_agent_response_text(provider, proc.stdout)) filters = parsed.get("filters", []) if isinstance(parsed, dict) else [] - return [sanitize_filter(item, ttl_seconds) for item in filters if isinstance(item, dict)] + return sanitized_filter_list(filters, ttl_seconds) def detect_cli_agent(provider: str, provider_cfg: dict[str, Any] | None = None) -> dict[str, Any]: @@ -767,25 +777,36 @@ def sanitize_filter(item: dict[str, Any], ttl_seconds: int) -> dict[str, Any]: "user_agent_contains", ]: value = condition.get(key) - if isinstance(value, str) and len(value) <= 512: + if isinstance(value, str) and bounded_utf8(value, FILTER_MATCH_VALUE_MAX_BYTES): if key == "path_prefix" and value in {"", "/"}: continue + if key == "signature" and not is_supported_signature(value): + continue clean_condition[key] = value methods = condition.get("methods") if isinstance(methods, list): - clean_condition["methods"] = [ + clean_methods = [ str(method).upper() for method in methods if str(method).upper() in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"} ][:8] + if clean_methods: + clean_condition["methods"] = clean_methods headers = condition.get("headers") if isinstance(headers, list): clean_headers = [] for header in headers[:8]: if not isinstance(header, dict): continue - name = str(header.get("name", ""))[:64] - contains = str(header.get("contains", ""))[:256] - if name and contains: - clean_headers.append({"name": name, "contains": contains}) + name = header.get("name") + contains = header.get("contains") + if ( + isinstance(name, str) + and isinstance(contains, str) + and is_http_header_name(name) + and bounded_utf8(contains, FILTER_HEADER_CONTAINS_MAX_BYTES) + ): + clean_headers.append( + {"name": name[:FILTER_HEADER_NAME_MAX_BYTES], "contains": contains} + ) if clean_headers: clean_condition["headers"] = clean_headers @@ -805,6 +826,20 @@ def sanitize_filter(item: dict[str, Any], ttl_seconds: int) -> dict[str, Any]: } +def filter_has_matcher(item: dict[str, Any]) -> bool: + condition = item.get("condition") + return isinstance(condition, dict) and bool(condition) + + +def sanitized_filter_list(filters: list[Any], ttl_seconds: int) -> list[dict[str, Any]]: + cleaned = [ + sanitize_filter(item, ttl_seconds) + for item in filters + if isinstance(item, dict) + ] + return [item for item in cleaned if filter_has_matcher(item)] + + def merge_strong_coverage( provider_filters: list[dict[str, Any]], events: list[dict[str, Any]], @@ -812,19 +847,45 @@ def merge_strong_coverage( ttl_seconds: int, learn_observed: bool = False, ) -> list[dict[str, Any]]: - merged = [sanitize_filter(item, ttl_seconds) for item in provider_filters] - covered_keys = {filter_key(sanitize_filter(item, ttl_seconds)) for item in merged} + merged = sanitized_filter_list(provider_filters, ttl_seconds) + covered_keys = {filter_key(item) for item in merged} for fallback in deterministic_filters(events, min_count, ttl_seconds, learn_observed=learn_observed): clean = sanitize_filter(fallback, ttl_seconds) key = filter_key(clean) - if key != "id:" and key not in covered_keys: + if filter_has_matcher(clean) and key != "id:" and key not in covered_keys: merged.append(clean) covered_keys.add(key) return merged def safe_id(value: str) -> str: - return SAFE_ID.sub("-", value)[:96].strip("-") or "codex-learned-filter" + return SAFE_ID.sub("-", value)[:FILTER_RULE_ID_MAX_BYTES].strip("-") or "codex-learned-filter" + + +def is_http_header_name(value: str) -> bool: + return 0 < len(value) <= FILTER_HEADER_NAME_MAX_BYTES and bool(HTTP_TOKEN.fullmatch(value)) + + +def bounded_utf8(value: str, max_bytes: int) -> bool: + return 0 < len(value.encode("utf-8")) <= max_bytes + + +def unique_filter_ids(filters: list[dict[str, Any]]) -> list[dict[str, Any]]: + seen: set[str] = set() + unique = [] + for item in filters: + clean = dict(item) + base = safe_id(str(clean.get("id") or "codex-learned-filter")) + candidate = base + suffix = 2 + while candidate in seen: + tail = f"-{suffix}" + candidate = f"{base[: FILTER_RULE_ID_MAX_BYTES - len(tail)]}{tail}".strip("-") + suffix += 1 + clean["id"] = candidate + seen.add(candidate) + unique.append(clean) + return unique def write_filters(path: Path, filters: list[dict[str, Any]]) -> None: @@ -869,6 +930,8 @@ def merge_existing_filters( def upsert(item: dict[str, Any]) -> None: clean = sanitize_filter(item, ttl_seconds) + if not filter_has_matcher(clean): + return key = filter_key(clean) if key == "id:": return @@ -879,7 +942,7 @@ def upsert(item: dict[str, Any]) -> None: upsert(item) for item in new_filters: upsert(item) - return list(merged.values())[-max(1, max_filters) :] + return unique_filter_ids(list(merged.values())[-max(1, max_filters) :]) def analyze_once(args: argparse.Namespace) -> None: @@ -930,7 +993,7 @@ def analyze_once(args: argparse.Namespace) -> None: learn_observed=args.learn_observed, ) else: - filters = [sanitize_filter(item, ttl_seconds) for item in filters] + filters = sanitized_filter_list(filters, ttl_seconds) filters = merge_existing_filters(existing_filters, filters, ttl_seconds, args.max_filters) write_filters(Path(args.filters), filters) print(f"wrote {len(filters)} filters to {args.filters} via {used_provider}", flush=True) diff --git a/tools/run_edge_namespace_smoke.py b/tools/run_edge_namespace_smoke.py index d7716cd..9183339 100644 --- a/tools/run_edge_namespace_smoke.py +++ b/tools/run_edge_namespace_smoke.py @@ -87,6 +87,10 @@ def timeout_output(value: str | bytes | None) -> str: return value +def compact_nft_output(value: str) -> str: + return re.sub(r"\s+", " ", value).strip() + + def run( command: list[str], timeout_seconds: int = NAMESPACE_SMOKE_TIMEOUT_SECONDS, @@ -128,19 +132,32 @@ def run_namespace_smoke( stdout = result.stdout or "" stderr = result.stderr or "" source = nft_path.read_text(encoding="utf-8") - ipv6_prefix_mask = bool(re.search(r"ip6 saddr\s*(?:and|&)\s*ffff:ffff:ffff:ffff::", stdout)) + compact_stdout = compact_nft_output(stdout) + ipv6_prefix_mask = bool( + re.search(r"ip6 saddr\s*(?:and|&)\s*ffff:ffff:ffff:ffff::", compact_stdout) + ) udp_extension_safe_source = bool( re.search( r"\bmeta\s+l4proto\s+udp\s+udp\s+dport\s+@protected_tcp_ports\s+drop\b", re.sub(r"\s+", " ", source), ) ) - protected_syn_match = ( - "tcp dport @protected_tcp_ports tcp flags & (fin | syn | rst | ack) == syn" + protected_syn_matches = ( + "tcp dport @protected_tcp_ports tcp flags & (fin | syn | rst | ack) == syn", + "tcp dport @protected_tcp_ports tcp flags & (fin|syn|rst|ack) == syn", + "tcp dport @protected_tcp_ports tcp flags syn / fin,syn,rst,ack", ) - protected_new_match = ( - "tcp dport @protected_tcp_ports ct state new tcp flags & (fin | syn | rst | ack)" + protected_new_non_syn_matches = ( + "tcp dport @protected_tcp_ports ct state new tcp flags & (fin | syn | rst | ack) != syn drop", + "tcp dport @protected_tcp_ports ct state new tcp flags & (fin|syn|rst|ack) != syn drop", + "tcp dport @protected_tcp_ports ct state new tcp flags != syn / fin,syn,rst,ack drop", ) + xmas_drop_matches = ( + "tcp flags & (fin | syn | rst | psh | ack | urg) == fin | psh | urg drop", + "tcp flags & (fin|syn|rst|psh|ack|urg) == fin|psh|urg drop", + "tcp flags fin,psh,urg / fin,syn,rst,psh,ack,urg drop", + ) + protected_syn_present = any(match in compact_stdout for match in protected_syn_matches) return { "skipped": False, "command": command, @@ -148,46 +165,50 @@ def run_namespace_smoke( "stdout": stdout, "stderr": stderr, "nft_loaded": result.returncode == 0, - "listed_edge_table": "table inet altura_prot_edge" in stdout, - "protected_tcp_ports_present": "set protected_tcp_ports" in stdout, - "tcp4_connlimit_present": "set tcp4_connlimit" in stdout, - "tcp6_connlimit_present": "set tcp6_connlimit" in stdout, - "syn_rate_sets_timeout_bounded": stdout.count("timeout 10s") >= 2, + "listed_edge_table": "table inet altura_prot_edge" in compact_stdout, + "protected_tcp_ports_present": "set protected_tcp_ports" in compact_stdout, + "tcp4_connlimit_present": "set tcp4_connlimit" in compact_stdout, + "tcp6_connlimit_present": "set tcp6_connlimit" in compact_stdout, + "syn_rate_sets_timeout_bounded": compact_stdout.count("timeout 10s") >= 2, "tcp_invalid_null_drop_present": ( - "tcp dport @protected_tcp_ports tcp flags ! fin,syn,rst,ack drop" in stdout + "tcp dport @protected_tcp_ports tcp flags ! fin,syn,rst,ack drop" + in compact_stdout ), - "tcp_invalid_xmas_drop_present": ( - "tcp flags & (fin | syn | rst | psh | ack | urg) == fin | psh | urg drop" - in stdout + "tcp_invalid_xmas_drop_present": any( + match in compact_stdout for match in xmas_drop_matches ), "ipv6_prefix_syn_backstop_present": ipv6_prefix_mask - and "update @tcp6_syn_rate" in stdout, - "tcp4_syn_backstop_present": protected_syn_match in stdout - and "update @tcp4_syn_rate" in stdout, - "global_syn_backstop_present": protected_syn_match in stdout - and "limit rate over 5000/second burst 10000 packets drop" in stdout, - "ct_invalid_drop_present": "ct state invalid drop" in stdout, - "new_non_syn_drop_present": protected_new_match in stdout - and "!= syn drop" in stdout, - "tcp4_connlimit_rule_present": "add @tcp4_connlimit" in stdout - and "ct count over 128" in stdout, + and "update @tcp6_syn_rate" in compact_stdout, + "tcp4_syn_backstop_present": protected_syn_present + and "update @tcp4_syn_rate" in compact_stdout, + "global_syn_backstop_present": protected_syn_present + and "limit rate over 5000/second burst 10000 packets drop" in compact_stdout, + "ct_invalid_drop_present": "ct state invalid drop" in compact_stdout, + "new_non_syn_drop_present": any( + match in compact_stdout for match in protected_new_non_syn_matches + ), + "tcp4_connlimit_rule_present": "add @tcp4_connlimit" in compact_stdout + and "ct count over 128" in compact_stdout, "ipv6_prefix_connlimit_present": ipv6_prefix_mask - and "add @tcp6_connlimit" in stdout, + and "add @tcp6_connlimit" in compact_stdout, "tcp6_connlimit_rule_present": ipv6_prefix_mask - and "add @tcp6_connlimit" in stdout - and "ct count over 128" in stdout, - "udp_protected_port_drop_present": "udp dport @protected_tcp_ports drop" in stdout, + and "add @tcp6_connlimit" in compact_stdout + and "ct count over 128" in compact_stdout, + "udp_protected_port_drop_present": ( + "udp dport @protected_tcp_ports drop" in compact_stdout + ), "udp_protected_port_drop_extension_safe_source": udp_extension_safe_source, - "icmpv4_control_exemption_present": "icmp type" in stdout - and "destination-unreachable" in stdout, + "icmpv4_control_exemption_present": "icmp type" in compact_stdout + and "destination-unreachable" in compact_stdout, "icmpv4_flood_drop_present": ( - "ip protocol icmp limit rate over 100/second burst 200 packets drop" in stdout + "ip protocol icmp limit rate over 100/second burst 200 packets drop" + in compact_stdout ), - "icmpv6_control_exemption_present": "icmpv6 type" in stdout - and "packet-too-big" in stdout, + "icmpv6_control_exemption_present": "icmpv6 type" in compact_stdout + and "packet-too-big" in compact_stdout, "icmpv6_flood_drop_present": ( "meta l4proto ipv6-icmp limit rate over 100/second burst 200 packets drop" - in stdout + in compact_stdout ), } diff --git a/tools/run_local_bench.py b/tools/run_local_bench.py index ce3695f..f078700 100644 --- a/tools/run_local_bench.py +++ b/tools/run_local_bench.py @@ -22,7 +22,7 @@ import time from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, Callable from bench_provenance import generated_at_utc, source_tree_metadata @@ -70,6 +70,7 @@ "global_connects_per_second": 1_000_000, "global_connect_burst": 1_000_000, } +PROCESS_STDERR_TAIL_CHARS = 4000 class FastHttpHandler(socketserver.BaseRequestHandler): @@ -449,6 +450,47 @@ def wait_http(url_host: str, url_port: int, path: str = "/__altura/health") -> N raise RuntimeError("AlturaProt did not become ready") +def stop_process_and_collect_stderr( + process: subprocess.Popen[str], + timeout_seconds: float = 5.0, +) -> str: + if process.poll() is None: + try: + process.terminate() + except ProcessLookupError: + pass + try: + _, stderr = process.communicate(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + process.kill() + try: + _, stderr = process.communicate(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + return "" + return stderr or "" + + +def format_process_stderr_tail(stderr: str) -> str: + stderr = stderr.strip() + if not stderr: + return "" + if len(stderr) <= PROCESS_STDERR_TAIL_CHARS: + return stderr + return f"\n{stderr[-PROCESS_STDERR_TAIL_CHARS:]}" + + +def startup_failure_with_stderr( + reason: BaseException, + process: subprocess.Popen[str], + stderr: str, +) -> RuntimeError: + return RuntimeError( + "AlturaProt benchmark proxy startup failed: " + f"{reason}; exit_status={process.returncode}; " + f"stderr_tail={format_process_stderr_tail(stderr)}" + ) + + def wait_tcp_port(port: int) -> None: deadline = time.time() + 5 while time.time() < deadline: @@ -4843,6 +4885,35 @@ def first_decodable_json_line(path: Path) -> dict[str, Any]: return {} +def decodable_json_lines(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + events: list[dict[str, Any]] = [] + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + if not line.strip(): + continue + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(value, dict): + events.append(value) + return events + + +def wait_for_jsonl_event( + path: Path, + predicate: Callable[[dict[str, Any]], bool], + timeout_seconds: float, +) -> list[dict[str, Any]]: + deadline = time.perf_counter() + timeout_seconds + events = decodable_json_lines(path) + while not any(predicate(event) for event in events) and time.perf_counter() < deadline: + time.sleep(0.02) + events = decodable_json_lines(path) + return events + + def event_log_files(path: Path, backup_count: int) -> list[Path]: return [path] + [Path(f"{path}.{idx}") for idx in range(1, backup_count + 1)] @@ -7038,6 +7109,9 @@ def run_host_guard_probe(binary: Path, upstream_port: int, tmp_path: Path) -> di "allowed_host_port": send_raw_host_request( proxy_port, ["api.good.local:8080"] ), + "unlisted_port_host": send_raw_host_request( + proxy_port, ["good.local:8080"] + ), "missing_host": send_raw_host_request(proxy_port, []), "duplicate_host": send_raw_host_request( proxy_port, ["good.local", "evil.local"] @@ -7051,6 +7125,9 @@ def run_host_guard_probe(binary: Path, upstream_port: int, tmp_path: Path) -> di "absolute_form_disallowed_authority": send_raw_host_request( proxy_port, ["good.local"], target="http://evil.local/" ), + "absolute_form_unlisted_port_authority": send_raw_host_request( + proxy_port, ["good.local"], target="http://good.local:8080/" + ), "absolute_form_unsupported_scheme": send_raw_host_request( proxy_port, ["good.local"], target="ftp://good.local/" ), @@ -7074,10 +7151,18 @@ def run_host_guard_probe(binary: Path, upstream_port: int, tmp_path: Path) -> di "invalid_host", "long_host", "disallowed_host", + "unlisted_port_host", "absolute_form_disallowed_authority", + "absolute_form_unlisted_port_authority", "absolute_form_unsupported_scheme", ] ), + "bare_host_does_not_allow_unlisted_port": probes["unlisted_port_host"].get("status") + == 400, + "absolute_form_bare_host_does_not_allow_unlisted_port": probes[ + "absolute_form_unlisted_port_authority" + ].get("status") + == 400, "absolute_form_unsupported_scheme_rejected": probes[ "absolute_form_unsupported_scheme" ].get("status") @@ -8267,7 +8352,7 @@ def run_path_shape_rate_probe(binary: Path, upstream_port: int, tmp_path: Path) "global_burst": 1_000_000, "signature_rps": 1_000_000, "signature_burst": 1_000_000, - "max_tracked_signatures": 64, + "max_tracked_signatures": 4096, "path_shape_rps": path_shape_rps, "path_shape_burst": path_shape_burst, "max_tracked_path_shapes": 64, @@ -8326,20 +8411,12 @@ def run_path_shape_rate_probe(binary: Path, upstream_port: int, tmp_path: Path) other_shape_status = get_status(proxy_port, "GET", "/api/catalog/123") version_shape_status = get_status(proxy_port, "GET", "/api/v1/users") metrics_after = fetch_metrics(proxy_port, token="path-shape-rate-token") - wait_file_line_count(events_path, 1, 1.0) - events: list[dict[str, Any]] = [] - if events_path.exists(): - for line in events_path.read_text( - encoding="utf-8", errors="replace" - ).splitlines(): - if not line.strip(): - continue - try: - event = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(event, dict): - events.append(event) + events = wait_for_jsonl_event( + events_path, + lambda event: event.get("reason") == "path_shape_rate_limited" + and event.get("path_shape") == "/api/:short-token", + 1.0, + ) path_shape_event_shapes = [ event.get("path_shape") for event in events @@ -10488,6 +10565,7 @@ def run_validator() -> subprocess.CompletedProcess[str]: str(sysctl_path), "--systemd", str(systemd_path), + "--skip-nft-syntax-check", ], text=True, stdout=subprocess.PIPE, @@ -11086,9 +11164,15 @@ def main() -> None: text=True, env={**os.environ, "RUST_BACKTRACE": "0"}, ) + proxy_stopped = False try: - wait_http("127.0.0.1", proxy_port) - wait_tcp_port(tcp_proxy_port) + try: + wait_http("127.0.0.1", proxy_port) + wait_tcp_port(tcp_proxy_port) + except Exception as err: + stderr = stop_process_and_collect_stderr(proxy) + proxy_stopped = True + raise startup_failure_with_stderr(err, proxy, stderr) from err result = { "generated_at_utc": generated_at_utc(), "source_tree": source_tree_metadata(Path.cwd()), @@ -11613,11 +11697,8 @@ def main() -> None: } print(json.dumps(result, indent=2, sort_keys=True)) finally: - proxy.terminate() - try: - proxy.wait(timeout=5) - except subprocess.TimeoutExpired: - proxy.kill() + if not proxy_stopped: + stop_process_and_collect_stderr(proxy) upstream.shutdown() diff --git a/tools/test_ai_tools.py b/tools/test_ai_tools.py index a277058..8cebd82 100644 --- a/tools/test_ai_tools.py +++ b/tools/test_ai_tools.py @@ -5,6 +5,7 @@ import os import socketserver import subprocess +import sys import tempfile import threading import unittest @@ -22,6 +23,15 @@ import validate_edge_templates +SIG_A = "a" * 32 +SIG_B = "b" * 32 +SIG_C = "c" * 32 +SIG_D = "d" * 32 +SIG_E = "e" * 32 +SIG_F = "f" * 32 +LEGACY_SIG = "0123456789abcdef" + + class CiWorkflowTests(unittest.TestCase): def test_ci_lints_all_rust_targets(self) -> None: workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") @@ -169,6 +179,24 @@ def test_edge_sysctl_template_contains_required_ddos_sysctls(self) -> None: for key in validate_edge_templates.DDOS_SYSCTL_RULES: self.assertIn(f"{key} = ", template) + def test_edge_template_probe_skips_kernel_nft_syntax_check(self) -> None: + calls: list[list[str]] = [] + + def fake_run(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(cmd) + return subprocess.CompletedProcess(cmd, 0, "", "") + + with tempfile.TemporaryDirectory() as tmp: + with patch.object(run_local_bench.subprocess, "run", side_effect=fake_run): + report = run_local_bench.run_edge_template_port_coverage_probe(Path(tmp)) + + self.assertTrue(calls) + self.assertTrue( + all("--skip-nft-syntax-check" in call for call in calls), + calls, + ) + self.assertTrue(report["covered_public_ports_allowed"]) + def test_chunked_message_complete_accepts_empty_and_nonempty_trailers(self) -> None: self.assertTrue(run_local_bench.chunked_message_complete(b"0\r\n\r\n")) self.assertTrue( @@ -207,6 +235,43 @@ def fake_check_output(cmd: list[str], text: bool) -> str: ) self.assertEqual(calls[0][1], str(expected_helper)) + def test_process_stderr_tail_is_bounded(self) -> None: + stderr = "a" * (run_local_bench.PROCESS_STDERR_TAIL_CHARS + 10) + + tail = run_local_bench.format_process_stderr_tail(stderr) + + self.assertTrue(tail.startswith("\n")) + self.assertLessEqual( + len(tail), + len("\n") + run_local_bench.PROCESS_STDERR_TAIL_CHARS, + ) + self.assertTrue(tail.endswith("a" * run_local_bench.PROCESS_STDERR_TAIL_CHARS)) + + def test_startup_failure_includes_exit_status_and_stderr_tail(self) -> None: + process = subprocess.Popen( + [ + sys.executable, + "-c", + "import sys; sys.stderr.write('startup boom\\n'); sys.exit(17)", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + process.wait(timeout=1) + stderr = run_local_bench.stop_process_and_collect_stderr(process) + + err = run_local_bench.startup_failure_with_stderr( + RuntimeError("AlturaProt did not become ready"), + process, + stderr, + ) + + message = str(err) + self.assertIn("AlturaProt did not become ready", message) + self.assertIn("exit_status=17", message) + self.assertIn("startup boom", message) + def test_first_decodable_json_line_skips_partial_event_log_line(self) -> None: with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "events.jsonl" @@ -219,6 +284,33 @@ def test_first_decodable_json_line_skips_partial_event_log_line(self) -> None: self.assertEqual(event, {"signature": "valid"}) + def test_wait_for_jsonl_event_waits_for_matching_event(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "events.jsonl" + path.write_text('{"path_shape": "/api/:id"}\n', encoding="utf-8") + + def append_target_event() -> None: + path.write_text( + '{"path_shape": "/api/:id"}\n' + '{"path_shape": "/api/:short-token"}\n', + encoding="utf-8", + ) + + timer = threading.Timer(0.05, append_target_event) + timer.start() + try: + events = run_local_bench.wait_for_jsonl_event( + path, + lambda event: event.get("path_shape") == "/api/:short-token", + 1.0, + ) + finally: + timer.cancel() + + self.assertTrue( + any(event.get("path_shape") == "/api/:short-token" for event in events) + ) + def test_http_max_connection_duration_probe_detects_closed_connection(self) -> None: class CloseAfterOneHttp(socketserver.BaseRequestHandler): def handle(self) -> None: @@ -406,6 +498,360 @@ def test_assert_local_bench_rejects_failed_guardrail(self) -> None: self.assertIn("short_token_sibling_churn_limited", errors[0]) self.assertIn("expected true", errors[0]) + def test_assert_local_bench_rejects_failed_runtime_sigterm_probe(self) -> None: + report = self.valid_report() + report["guardrails"]["runtime_sigterm"]["sigterm_graceful"] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("runtime_sigterm.sigterm_graceful", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_failed_event_log_rotation_probe(self) -> None: + report = self.valid_report() + report["guardrails"]["event_log_rotation"]["total_bytes_bounded"] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("event_log_rotation.total_bytes_bounded", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_missing_edge_udp_drop_guardrail(self) -> None: + report = self.valid_report() + report["guardrails"]["edge_template_port_coverage"][ + "missing_udp_drop_rejected" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("missing_udp_drop_rejected", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_missing_ipv6_l4proto_guardrail(self) -> None: + report = self.valid_report() + report["guardrails"]["edge_template_port_coverage"][ + "missing_ipv6_connlimit_l4proto_rejected" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("missing_ipv6_connlimit_l4proto_rejected", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_invalid_fragment_sysctl_guardrail(self) -> None: + report = self.valid_report() + report["guardrails"]["edge_template_port_coverage"][ + "invalid_fragment_thresholds_rejected" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("invalid_fragment_thresholds_rejected", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_admin_rate_limit_bypass(self) -> None: + report = self.valid_report() + report["guardrails"]["admin_rate_limit"]["statuses"] = [200, 200] + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("admin_rate_limit.statuses", errors[0]) + self.assertIn("expected [200, 429]", errors[0]) + + def test_assert_local_bench_rejects_admin_signature_rate_bypass(self) -> None: + report = self.valid_report() + report["guardrails"]["admin_signature_rate"][ + "admin_health_signature_limited" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("admin_health_signature_limited", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_oversized_config_startup_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["config_file_startup"][ + "oversized_config_rejected" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("config_file_startup.oversized_config_rejected", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_dynamic_state_ceiling_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["dynamic_state_ceiling_startup"][ + "all_dynamic_state_ceilings_rejected" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn( + "dynamic_state_ceiling_startup.all_dynamic_state_ceilings_rejected", + errors[0], + ) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_runtime_nofile_capacity_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["runtime_nofile_capacity"]["capacity_rejected"] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("runtime_nofile_capacity.capacity_rejected", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_invalid_client_ip_config_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["client_ip_config_startup"][ + "invalid_trusted_proxy_rejected" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn( + "client_ip_config_startup.invalid_trusted_proxy_rejected", errors[0] + ) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_forwarded_header_bounds_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["forwarded_header_bounds"][ + "too_many_hops_rejected" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("forwarded_header_bounds.too_many_hops_rejected", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_trusted_proxy_in_flight_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["trusted_proxy_in_flight"][ + "rotating_xff_peer_concurrency_limited" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn( + "trusted_proxy_in_flight.rotating_xff_peer_concurrency_limited", + errors[0], + ) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_ipv6_prefix_aggregation_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["ip_prefix_aggregation"][ + "same_ipv6_prefix_limited" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("ip_prefix_aggregation.same_ipv6_prefix_limited", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_path_shape_response_header_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["path_shape_rate"]["retry_after_header_matches"] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("path_shape_rate.retry_after_header_matches", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_signature_rate_cache_header_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["signature_rate"]["cache_control_header_matches"] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("signature_rate.cache_control_header_matches", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_trusted_proxy_metric_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["trusted_proxy_aggregate_rate"][ + "trusted_proxy_metric_matches" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn( + "trusted_proxy_aggregate_rate.trusted_proxy_metric_matches", errors[0] + ) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_tracked_ip_allowance_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["tracked_ip_cap"]["first_client_initial_allowed"] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("tracked_ip_cap.first_client_initial_allowed", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_identity_encoding_allowance_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["request_content_encoding"][ + "identity_request_allowed" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("request_content_encoding.identity_request_allowed", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_uri_guard_observation_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["uri_guard"][ + "raw_initial_request_target_guard_observed" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("uri_guard.raw_initial_request_target_guard_observed", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_runtime_filter_hot_path_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["runtime_filter_hot_path"][ + "normal_request_allowed" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("runtime_filter_hot_path.normal_request_allowed", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_min_rate_ceiling_detail_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["min_rate_ceiling_startup"][ + "tcp_upstream_min_rate_rejected" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn( + "min_rate_ceiling_startup.tcp_upstream_min_rate_rejected", errors[0] + ) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_edge_connlimit_size_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["edge_template_port_coverage"][ + "missing_connlimit_set_size_rejected" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn( + "edge_template_port_coverage.missing_connlimit_set_size_rejected", + errors[0], + ) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_allowed_host_startup_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["allowed_hosts_startup"]["wildcard_rejected"] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("allowed_hosts_startup.wildcard_rejected", errors[0]) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_header_ceiling_startup_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["header_buffer_ceiling_startup"][ + "downstream_max_header_bytes_rejected" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn( + "header_buffer_ceiling_startup.downstream_max_header_bytes_rejected", + errors[0], + ) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_filter_rule_startup_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["filter_rule_startup"][ + "too_many_static_filters_rejected" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn( + "filter_rule_startup.too_many_static_filters_rejected", errors[0] + ) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_connection_request_limit_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["connection_request_limit"][ + "connection_closed_before_fourth" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn( + "connection_request_limit.connection_closed_before_fourth", errors[0] + ) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_request_trailer_policy_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["request_trailer_policy"][ + "oversized_request_trailer_rejected" + ] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn( + "request_trailer_policy.oversized_request_trailer_rejected", errors[0] + ) + self.assertIn("expected true", errors[0]) + + def test_assert_local_bench_rejects_admin_prefix_startup_gap(self) -> None: + report = self.valid_report() + report["guardrails"]["admin_prefix_startup"]["root_prefix_rejected"] = False + + errors = assert_local_bench.assert_report(report) + + self.assertEqual(len(errors), 1) + self.assertIn("admin_prefix_startup.root_prefix_rejected", errors[0]) + self.assertIn("expected true", errors[0]) + def test_assert_local_bench_rejects_post_grace_tcp_echo(self) -> None: report = self.valid_report() report["guardrails"]["tcp_min_rate"]["slow_drip"]["second_echo_bytes"] = 1 @@ -538,6 +984,79 @@ def test_assert_edge_namespace_smoke_accepts_loaded_template(self) -> None: self.assertEqual(run_edge_namespace_smoke.assert_smoke_result(report), []) + def test_run_edge_namespace_smoke_accepts_canonical_nft_flag_output(self) -> None: + original_run = run_edge_namespace_smoke.subprocess.run + canonical_stdout = "\n".join( + [ + "table inet altura_prot_edge {", + " set protected_tcp_ports { type inet_service; }", + " set tcp4_connlimit { type ipv4_addr . inet_service; }", + " set tcp6_connlimit { type ipv6_addr . inet_service; }", + " set tcp4_syn_rate { flags timeout; timeout 10s; }", + " set tcp6_syn_rate { flags timeout; timeout 10s; }", + " chain preraw {", + " tcp dport @protected_tcp_ports tcp flags ! fin,syn,rst,ack drop", + " tcp dport @protected_tcp_ports tcp flags fin,psh,urg / fin,syn,rst,psh,ack,urg drop", + " ip protocol tcp tcp dport @protected_tcp_ports tcp flags syn / fin,syn,rst,ack update @tcp4_syn_rate { ip saddr . tcp dport limit rate over 200/second burst 400 packets } drop", + " meta nfproto ipv6 meta l4proto tcp ip6 saddr and ffff:ffff:ffff:ffff:: . tcp dport update @tcp6_syn_rate { ip6 saddr and ffff:ffff:ffff:ffff:: . tcp dport limit rate over 200/second burst 400 packets } drop", + " tcp dport @protected_tcp_ports tcp flags syn / fin,syn,rst,ack limit rate over 5000/second burst 10000 packets drop", + " meta l4proto udp udp dport @protected_tcp_ports drop", + " ip protocol icmp icmp type { destination-unreachable } accept", + " ip protocol icmp limit rate over 100/second burst 200 packets drop", + " meta l4proto ipv6-icmp icmpv6 type { packet-too-big } accept", + " meta l4proto ipv6-icmp limit rate over 100/second burst 200 packets drop", + " }", + " chain input {", + " ct state invalid drop", + " tcp dport @protected_tcp_ports ct state new tcp flags != syn / fin,syn,rst,ack drop", + " meta l4proto tcp add @tcp4_connlimit { ip saddr . tcp dport ct count over 128 } drop", + " meta nfproto ipv6 meta l4proto tcp ip6 saddr and ffff:ffff:ffff:ffff:: . tcp dport add @tcp6_connlimit { ip6 saddr and ffff:ffff:ffff:ffff:: . tcp dport ct count over 128 } drop", + " }", + "}", + ] + ) + + def fake_run(*_args: object, **_kwargs: object) -> object: + return run_edge_namespace_smoke.subprocess.CompletedProcess( + args=["unshare"], + returncode=0, + stdout=canonical_stdout, + stderr="", + ) + + with tempfile.TemporaryDirectory() as tmp: + nft_path = Path(tmp) / "edge.nft" + nft_path.write_text( + "meta l4proto udp udp dport @protected_tcp_ports drop\n", + encoding="utf-8", + ) + run_edge_namespace_smoke.subprocess.run = fake_run # type: ignore[assignment] + try: + report = run_edge_namespace_smoke.run_namespace_smoke(nft_path) + finally: + run_edge_namespace_smoke.subprocess.run = original_run # type: ignore[assignment] + + self.assertTrue(report["tcp_invalid_xmas_drop_present"]) + self.assertTrue(report["listed_edge_table"]) + self.assertTrue(report["protected_tcp_ports_present"]) + self.assertTrue(report["tcp4_connlimit_present"]) + self.assertTrue(report["tcp6_connlimit_present"]) + self.assertTrue(report["syn_rate_sets_timeout_bounded"]) + self.assertTrue(report["tcp_invalid_null_drop_present"]) + self.assertTrue(report["tcp4_syn_backstop_present"]) + self.assertTrue(report["ipv6_prefix_syn_backstop_present"]) + self.assertTrue(report["global_syn_backstop_present"]) + self.assertTrue(report["ct_invalid_drop_present"]) + self.assertTrue(report["new_non_syn_drop_present"]) + self.assertTrue(report["tcp4_connlimit_rule_present"]) + self.assertTrue(report["ipv6_prefix_connlimit_present"]) + self.assertTrue(report["tcp6_connlimit_rule_present"]) + self.assertTrue(report["udp_protected_port_drop_present"]) + self.assertTrue(report["icmpv4_control_exemption_present"]) + self.assertTrue(report["icmpv4_flood_drop_present"]) + self.assertTrue(report["icmpv6_control_exemption_present"]) + self.assertTrue(report["icmpv6_flood_drop_present"]) + def test_assert_edge_namespace_smoke_accepts_required_packet_probe(self) -> None: report = self.valid_edge_smoke_report() report["packet_probe"] = self.valid_edge_packet_probe_report() @@ -737,15 +1256,15 @@ def test_read_events_includes_rotated_logs_in_order(self) -> None: def test_deterministic_filters_are_adaptive_signature_rules(self) -> None: events = [ - {"signature": "abc", "path": "/x", "user_agent": "bench", "reason": "per_ip_rate_limited"}, - {"signature": "abc", "path": "/x", "user_agent": "bench", "reason": "per_ip_rate_limited"}, + {"signature": SIG_A, "path": "/x", "user_agent": "bench", "reason": "per_ip_rate_limited"}, + {"signature": SIG_A, "path": "/x", "user_agent": "bench", "reason": "per_ip_rate_limited"}, ] filters = codex_analyzer.deterministic_filters(events, min_count=2, ttl_seconds=45) self.assertEqual(len(filters), 1) clean = codex_analyzer.sanitize_filter(filters[0], ttl_seconds=45) self.assertTrue(clean["adaptive"]) self.assertEqual(clean["ttl_seconds"], 45) - self.assertEqual(clean["condition"]["signature"], "abc") + self.assertEqual(clean["condition"]["signature"], SIG_A) self.assertEqual(clean["action"]["status"], 403) def test_sanitize_filter_clamps_ttl_seconds_to_server_ceiling(self) -> None: @@ -753,7 +1272,7 @@ def test_sanitize_filter_clamps_ttl_seconds_to_server_ceiling(self) -> None: { "id": "ttl", "ttl_seconds": codex_analyzer.FILTER_TTL_MAX_SECONDS + 1, - "condition": {"signature": "abc"}, + "condition": {"signature": SIG_A}, }, ttl_seconds=45, ) @@ -765,7 +1284,7 @@ def test_sanitize_filter_uses_positive_fallback_for_bad_ttl_seconds(self) -> Non { "id": "ttl", "ttl_seconds": 0, - "condition": {"signature": "abc"}, + "condition": {"signature": SIG_A}, }, ttl_seconds=45, ) @@ -778,13 +1297,124 @@ def test_sanitize_filter_drops_catch_all_path_prefixes(self) -> None: clean = codex_analyzer.sanitize_filter( { "id": "root-prefix", - "condition": {"path_prefix": path_prefix, "signature": "abc"}, + "condition": {"path_prefix": path_prefix, "signature": SIG_A}, }, ttl_seconds=45, ) self.assertNotIn("path_prefix", clean["condition"]) - self.assertEqual(clean["condition"]["signature"], "abc") + self.assertEqual(clean["condition"]["signature"], SIG_A) + + def test_sanitize_filter_drops_empty_runtime_matchers(self) -> None: + clean = codex_analyzer.sanitize_filter( + { + "id": "empty-matchers", + "condition": { + "signature": SIG_A, + "path_exact": "", + "path_contains": "", + "path_shape": "", + "query_contains": "", + "user_agent_contains": "", + }, + }, + ttl_seconds=45, + ) + + self.assertEqual(clean["condition"], {"signature": SIG_A}) + + def test_sanitize_filter_drops_empty_methods_matcher(self) -> None: + clean = codex_analyzer.sanitize_filter( + { + "id": "empty-methods", + "condition": {"methods": ["TRACE", ""]}, + }, + ttl_seconds=45, + ) + + self.assertEqual(clean["condition"], {}) + self.assertFalse(codex_analyzer.filter_has_matcher(clean)) + self.assertEqual( + codex_analyzer.sanitized_filter_list( + [{"id": "empty-methods", "condition": {"methods": ["TRACE", ""]}}], + ttl_seconds=45, + ), + [], + ) + + def test_sanitize_filter_drops_matchers_above_runtime_byte_caps(self) -> None: + clean = codex_analyzer.sanitize_filter( + { + "id": "oversized-matchers", + "condition": { + "signature": SIG_A, + "path_contains": "\u2603" * 200, + "headers": [ + {"name": "x-altura-signal", "contains": "\u2603" * 100}, + ], + }, + }, + ttl_seconds=45, + ) + + self.assertEqual(clean["condition"], {"signature": SIG_A}) + + def test_sanitize_filter_drops_invalid_header_matchers(self) -> None: + clean = codex_analyzer.sanitize_filter( + { + "id": "bad-headers", + "condition": { + "signature": SIG_A, + "headers": [ + {"name": "x-altura-signal", "contains": "burst"}, + {"name": "bad header", "contains": "burst"}, + {"name": "bad:header", "contains": "burst"}, + {"name": "x-empty", "contains": ""}, + {"name": "x-non-string", "contains": 123}, + ], + }, + }, + ttl_seconds=45, + ) + + self.assertEqual( + clean["condition"]["headers"], + [{"name": "x-altura-signal", "contains": "burst"}], + ) + + def test_sanitize_filter_normalizes_response_header_safe_id(self) -> None: + clean = codex_analyzer.sanitize_filter( + { + "id": "bad\r\nid,with unicode \u2603", + "condition": {"signature": SIG_A}, + }, + ttl_seconds=45, + ) + + self.assertEqual(clean["id"], "bad-id-with-unicode") + self.assertNotRegex(clean["id"], r"[\x00-\x20\x7f,]") + self.assertTrue(clean["id"].isascii()) + + def test_merge_existing_filters_deconflicts_duplicate_sanitized_ids(self) -> None: + filters = codex_analyzer.merge_existing_filters( + [], + [ + { + "id": "same id", + "condition": {"signature": SIG_A}, + }, + { + "id": "same\nid", + "condition": {"path_shape": "/api/:num"}, + }, + ], + ttl_seconds=45, + max_filters=8, + ) + + ids = [item["id"] for item in filters] + self.assertEqual(ids, ["same-id", "same-id-2"]) + self.assertEqual(len(ids), len(set(ids))) def test_build_prompt_clamps_ttl_seconds_to_server_ceiling(self) -> None: prompt = codex_analyzer.build_prompt( @@ -797,8 +1427,8 @@ def test_build_prompt_clamps_ttl_seconds_to_server_ceiling(self) -> None: def test_observed_only_events_do_not_learn_by_default(self) -> None: events = [ - {"signature": "abc", "path": "/x", "user_agent": "bench", "reason": "observed"}, - {"signature": "abc", "path": "/x", "user_agent": "bench", "reason": "observed"}, + {"signature": SIG_A, "path": "/x", "user_agent": "bench", "reason": "observed"}, + {"signature": SIG_A, "path": "/x", "user_agent": "bench", "reason": "observed"}, ] self.assertEqual(codex_analyzer.deterministic_filters(events, min_count=2, ttl_seconds=45), []) self.assertEqual( @@ -808,33 +1438,33 @@ def test_observed_only_events_do_not_learn_by_default(self) -> None: def test_merge_strong_coverage_adds_missing_signatures(self) -> None: events = [ - {"signature": "covered", "path": "/a", "user_agent": "bench", "reason": "per_ip_rate_limited"}, - {"signature": "missing", "path": "/b", "user_agent": "bench", "reason": "per_ip_rate_limited"}, + {"signature": SIG_A, "path": "/a", "user_agent": "bench", "reason": "per_ip_rate_limited"}, + {"signature": SIG_B, "path": "/b", "user_agent": "bench", "reason": "per_ip_rate_limited"}, ] provider_filters = [ { "id": "provider-covered", "enabled": True, "adaptive": True, - "condition": {"signature": "covered"}, + "condition": {"signature": SIG_A}, "action": {"kind": "block", "status": 403, "body": "blocked by adaptive filter\n"}, } ] merged = codex_analyzer.merge_strong_coverage(provider_filters, events, min_count=1, ttl_seconds=20) signatures = {item["condition"]["signature"] for item in merged} - self.assertEqual(signatures, {"covered", "missing"}) + self.assertEqual(signatures, {SIG_A, SIG_B}) def test_merge_coverage_adds_observed_filters_when_enabled(self) -> None: events = [ { - "signature": "sig-a", + "signature": SIG_A, "path": "/api/abcdefghij/1", "path_shape": "/api/:token/:num", "user_agent": "bench", "reason": "observed", }, { - "signature": "sig-b", + "signature": SIG_B, "path": "/api/klmnopqrst/2", "path_shape": "/api/:token/:num", "user_agent": "bench", @@ -865,7 +1495,7 @@ def test_merge_existing_filters_preserves_dormant_rules(self) -> None: "id": "old", "enabled": True, "adaptive": True, - "condition": {"signature": "oldsig"}, + "condition": {"signature": SIG_A}, "action": {"kind": "block", "status": 403, "body": "blocked by adaptive filter\n"}, } ] @@ -874,12 +1504,12 @@ def test_merge_existing_filters_preserves_dormant_rules(self) -> None: "id": "new", "enabled": True, "adaptive": True, - "condition": {"signature": "newsig"}, + "condition": {"signature": SIG_B}, "action": {"kind": "block", "status": 403, "body": "blocked by adaptive filter\n"}, } ] merged = codex_analyzer.merge_existing_filters(existing, new, ttl_seconds=20, max_filters=10) - self.assertEqual({item["condition"]["signature"] for item in merged}, {"oldsig", "newsig"}) + self.assertEqual({item["condition"]["signature"] for item in merged}, {SIG_A, SIG_B}) def test_merge_existing_filters_replaces_same_signature(self) -> None: existing = [ @@ -887,7 +1517,7 @@ def test_merge_existing_filters_replaces_same_signature(self) -> None: "id": "old", "enabled": True, "adaptive": True, - "condition": {"signature": "same"}, + "condition": {"signature": SIG_A}, "action": {"kind": "block", "status": 403, "body": "blocked by adaptive filter\n"}, } ] @@ -896,7 +1526,7 @@ def test_merge_existing_filters_replaces_same_signature(self) -> None: "id": "new", "enabled": True, "adaptive": True, - "condition": {"signature": "same"}, + "condition": {"signature": SIG_A}, "action": {"kind": "block", "status": 403, "body": "blocked by adaptive filter\n"}, } ] @@ -910,7 +1540,7 @@ def test_merge_existing_filters_cap_prefers_new_rules(self) -> None: "id": f"old-{idx}", "enabled": True, "adaptive": True, - "condition": {"signature": f"old-{idx}"}, + "condition": {"signature": [SIG_A, SIG_B, SIG_C][idx]}, "action": {"kind": "block", "status": 403, "body": "blocked by adaptive filter\n"}, } for idx in range(3) @@ -920,13 +1550,13 @@ def test_merge_existing_filters_cap_prefers_new_rules(self) -> None: "id": "new", "enabled": True, "adaptive": True, - "condition": {"signature": "new"}, + "condition": {"signature": SIG_D}, "action": {"kind": "block", "status": 403, "body": "blocked by adaptive filter\n"}, } ] merged = codex_analyzer.merge_existing_filters(existing, new, ttl_seconds=20, max_filters=3) self.assertEqual(len(merged), 3) - self.assertIn("new", {item["condition"]["signature"] for item in merged}) + self.assertIn(SIG_D, {item["condition"]["signature"] for item in merged}) def test_provider_config_merges_defaults(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -948,14 +1578,14 @@ def test_codex_defaults_are_gpt55_high_fast(self) -> None: def test_deterministic_filters_learn_catalog_shape_when_route_family_is_hot(self) -> None: events = [ { - "signature": "sig-a", + "signature": SIG_A, "path": "/api/catalog/1", "path_shape": "/api/catalog/:num", "user_agent": "bench", "reason": "observed", }, { - "signature": "sig-b", + "signature": SIG_B, "path": "/api/catalog/2", "path_shape": "/api/catalog/:num", "user_agent": "bench", @@ -971,7 +1601,7 @@ def test_deterministic_filters_learn_catalog_shape_when_route_family_is_hot(self def test_deterministic_filters_learn_precise_benign_shape_runtime_signatures(self) -> None: events = [ { - "signature": "sig-a", + "signature": SIG_A, "path": "/api/catalog/1", "path_shape": "/api/catalog/:num", "signature_basis": "GET|/api/catalog/:num|page,sort|curl|*/*", @@ -979,7 +1609,7 @@ def test_deterministic_filters_learn_precise_benign_shape_runtime_signatures(sel "reason": "observed", }, { - "signature": "sig-a", + "signature": SIG_A, "path": "/api/catalog/2", "path_shape": "/api/catalog/:num", "signature_basis": "GET|/api/catalog/:num|page,sort|curl|*/*", @@ -989,19 +1619,19 @@ def test_deterministic_filters_learn_precise_benign_shape_runtime_signatures(sel ] filters = codex_analyzer.deterministic_filters(events, min_count=2, ttl_seconds=45, learn_observed=True) self.assertEqual(len(filters), 1) - self.assertEqual(filters[0]["condition"]["signature"], "sig-a") + self.assertEqual(filters[0]["condition"]["signature"], SIG_A) def test_deterministic_filters_learn_dictionary_slug_path_shape(self) -> None: events = [ { - "signature": "sig-a", + "signature": SIG_A, "path": "/api/subscription/1", "path_shape": "/api/subscription/:num", "user_agent": "bench", "reason": "global_observed", }, { - "signature": "sig-b", + "signature": SIG_B, "path": "/api/subscription/2", "path_shape": "/api/subscription/:num", "user_agent": "bench", @@ -1019,14 +1649,14 @@ def test_deterministic_filters_learn_dictionary_slug_path_shape(self) -> None: def test_deterministic_filters_learn_path_shape_for_polymorphic_events(self) -> None: events = [ { - "signature": "sig-a", + "signature": SIG_A, "path": "/api/abcdefghij/1", "path_shape": "/api/:token/:num", "user_agent": "bench", "reason": "global_observed", }, { - "signature": "sig-b", + "signature": SIG_B, "path": "/api/klmnopqrst/2", "path_shape": "/api/:token/:num", "user_agent": "bench", @@ -1041,14 +1671,14 @@ def test_deterministic_filters_learn_path_shape_for_polymorphic_events(self) -> def test_deterministic_filters_learn_short_token_path_shape_for_polymorphic_events(self) -> None: events = [ { - "signature": "a", + "signature": SIG_A, "path": "/api/ab", "path_shape": "/api/:short-token", "user_agent": "bench", "reason": "global_observed", }, { - "signature": "b", + "signature": SIG_B, "path": "/api/cd", "path_shape": "/api/:short-token", "user_agent": "bench", @@ -1064,7 +1694,7 @@ def test_deterministic_filters_learn_short_token_path_shape_for_polymorphic_even def test_body_too_large_events_are_strong_evidence(self) -> None: events = [ { - "signature": "sig-body", + "signature": SIG_C, "path": "/upload", "user_agent": "bench", "reason": "body_too_large", @@ -1072,12 +1702,12 @@ def test_body_too_large_events_are_strong_evidence(self) -> None: ] filters = codex_analyzer.deterministic_filters(events, min_count=1, ttl_seconds=45) self.assertEqual(len(filters), 1) - self.assertEqual(filters[0]["condition"]["signature"], "sig-body") + self.assertEqual(filters[0]["condition"]["signature"], SIG_C) def test_signature_rate_limited_events_are_strong_evidence(self) -> None: events = [ { - "signature": "sig-hot", + "signature": SIG_D, "path": "/hot", "user_agent": "bench", "reason": "signature_rate_limited", @@ -1085,12 +1715,12 @@ def test_signature_rate_limited_events_are_strong_evidence(self) -> None: ] filters = codex_analyzer.deterministic_filters(events, min_count=1, ttl_seconds=45) self.assertEqual(len(filters), 1) - self.assertEqual(filters[0]["condition"]["signature"], "sig-hot") + self.assertEqual(filters[0]["condition"]["signature"], SIG_D) def test_trusted_proxy_rate_limited_events_are_strong_evidence(self) -> None: events = [ { - "signature": "sig-edge", + "signature": SIG_E, "path": "/api/login", "path_shape": "/api/login", "user_agent": "bench", @@ -1099,19 +1729,19 @@ def test_trusted_proxy_rate_limited_events_are_strong_evidence(self) -> None: ] filters = codex_analyzer.deterministic_filters(events, min_count=1, ttl_seconds=45) self.assertEqual(len(filters), 1) - self.assertEqual(filters[0]["condition"]["signature"], "sig-edge") + self.assertEqual(filters[0]["condition"]["signature"], SIG_E) def test_path_shape_rate_limited_events_are_strong_evidence(self) -> None: events = [ { - "signature": "sig-a", + "signature": SIG_A, "path": "/api/abcdefghij/1", "path_shape": "/api/:token/:num", "user_agent": "bench", "reason": "path_shape_rate_limited", }, { - "signature": "sig-b", + "signature": SIG_B, "path": "/api/klmnopqrst/2", "path_shape": "/api/:token/:num", "user_agent": "bench", @@ -2826,6 +3456,22 @@ def test_target_score_still_requires_benign_allowance(self) -> None: class EdgeTemplateTests(unittest.TestCase): + def test_validate_nft_skip_syntax_check_does_not_call_nft(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + nft = Path(tmp) / "edge.nft" + nft.write_text("table inet altura_prot_edge {}\n", encoding="utf-8") + + with patch.object( + validate_edge_templates.shutil, "which", return_value="/usr/sbin/nft" + ), patch.object(validate_edge_templates, "run") as run: + errors = validate_edge_templates.validate_nft( + nft, + skip_syntax_check=True, + ) + + self.assertEqual(errors, ["nftables syntax check skipped by request"]) + run.assert_not_called() + def test_loopback_listeners_do_not_require_edge_port_coverage(self) -> None: with tempfile.TemporaryDirectory() as tmp: base = Path(tmp) @@ -3755,15 +4401,30 @@ def test_run_provider_dispatches_cli_agent_and_sanitizes(self) -> None: cfg = codex_analyzer.provider_config( codex_analyzer.load_provider_config(Path("/tmp/nonexistent.json")), "claude" ) - out = '{"type":"result","result":"{\\"filters\\":[{\\"condition\\":{\\"signature\\":\\"sig-x\\"},\\"action\\":{\\"kind\\":\\"block\\",\\"status\\":403,\\"body\\":\\"x\\"}}]}"}' + out = ( + '{"type":"result","result":"{\\"filters\\":[{\\"condition\\":' + f'{{\\"signature\\":\\"{SIG_A}\\"}}' + ',\\"action\\":{\\"kind\\":\\"block\\",\\"status\\":403,\\"body\\":\\"x\\"}}]}"}' + ) with patch("subprocess.run", return_value=_FakeProc(0, out)) as run: filters = codex_analyzer.run_provider("claude", prompt, cfg, ttl_seconds=30) # claude is fed on stdin, not argv. self.assertIsNotNone(run.call_args.kwargs.get("input")) self.assertEqual(len(filters), 1) - self.assertEqual(filters[0]["condition"]["signature"], "sig-x") + self.assertEqual(filters[0]["condition"]["signature"], SIG_A) self.assertEqual(filters[0]["action"]["status"], 403) + def test_run_provider_drops_malformed_signature_only_filters(self) -> None: + prompt = codex_analyzer.build_prompt([], min_count=1, ttl_seconds=30) + cfg = codex_analyzer.provider_config( + codex_analyzer.load_provider_config(Path("/tmp/nonexistent.json")), "claude" + ) + out = '{"type":"result","result":"{\\"filters\\":[{\\"condition\\":{\\"signature\\":\\"sig-x\\"},\\"action\\":{\\"kind\\":\\"block\\",\\"status\\":403,\\"body\\":\\"x\\"}}]}"}' + with patch("subprocess.run", return_value=_FakeProc(0, out)): + filters = codex_analyzer.run_provider("claude", prompt, cfg, ttl_seconds=30) + + self.assertEqual(filters, []) + def test_cli_agent_nonzero_exit_raises(self) -> None: prompt = codex_analyzer.build_prompt([], min_count=1, ttl_seconds=30) cfg = codex_analyzer.provider_config( @@ -3784,7 +4445,7 @@ def test_run_provider_dispatches_gemini(self) -> None: "content": { "parts": [ { - "text": '{"filters":[{"condition":{"signature":"g"},"action":{"kind":"block","status":403,"body":"x"}}]}' + "text": f'{{"filters":[{{"condition":{{"signature":"{SIG_B}"}},"action":{{"kind":"block","status":403,"body":"x"}}}}]}}' } ] } @@ -3798,7 +4459,7 @@ def test_run_provider_dispatches_gemini(self) -> None: self.assertIn(":generateContent", url) self.assertEqual(post.call_args.args[1]["x-goog-api-key"], "test-key") self.assertEqual(len(filters), 1) - self.assertEqual(filters[0]["condition"]["signature"], "g") + self.assertEqual(filters[0]["condition"]["signature"], SIG_B) def test_detect_cli_agent_reports_missing_binary(self) -> None: info = codex_analyzer.detect_cli_agent( diff --git a/tools/validate_edge_templates.py b/tools/validate_edge_templates.py index 058fdc9..eb3794b 100644 --- a/tools/validate_edge_templates.py +++ b/tools/validate_edge_templates.py @@ -96,6 +96,11 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--nft", default="ops/nftables/altura-prot-edge.nft") parser.add_argument("--sysctl", default="ops/sysctl.d/99-altura-prot-ddos.conf") parser.add_argument("--systemd", default="ops/systemd/altura-prot.service") + parser.add_argument( + "--skip-nft-syntax-check", + action="store_true", + help="skip kernel-backed nft -c validation while keeping static template checks", + ) return parser.parse_args() @@ -103,9 +108,11 @@ def run(cmd: list[str]) -> subprocess.CompletedProcess[str]: return subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) -def validate_nft(path: Path) -> list[str]: +def validate_nft(path: Path, *, skip_syntax_check: bool = False) -> list[str]: if not path.exists(): return [f"missing nftables template: {path}"] + if skip_syntax_check: + return ["nftables syntax check skipped by request"] nft = shutil.which("nft") if nft is None: return ["nft not found; skipped nftables syntax check"] @@ -1298,7 +1305,12 @@ def validate_systemd_unit(path: Path, config: Path) -> list[str]: def main() -> int: args = parse_args() errors: list[str] = [] - errors.extend(validate_nft(Path(args.nft))) + errors.extend( + validate_nft( + Path(args.nft), + skip_syntax_check=args.skip_nft_syntax_check, + ) + ) errors.extend(validate_protected_tcp_ports(Path(args.nft))) errors.extend(validate_udp_protected_port_drop(Path(args.nft))) errors.extend(validate_generic_tcp_backstop_protocol_matches(Path(args.nft)))