07 – Security & DDoS mitigation
Threat model
| Attacker | Goal | Countermeasure (layer) |
|---|---|---|
| Volumetric (L3/4, Gbit/s) | saturate the uplink | upstream: anycast/scrubbing, BGP flowspec. The proxy cannot do this alone. |
| SYN flood (TCP) | connection table / accept | kernel SYN cookies, accept backpressure, per-IP conn rate limit |
| UDP flood / spoofed | worker CPU, session table | rate limit before allocation, “validate first packet”, session cap, no reflect |
| Amplification via the proxy | abuse the proxy as a reflector | never reply unsolicited; reply only to the sender of an established session |
| Slowloris / idle-holding | tie up resources | peek timeout, idle timeout, min-throughput watchdog |
| Backend IP leak | direct attack bypassing the proxy | backends in the private network only, firewall: only proxy IPs may reach backends |
| PROXY-header spoofing | forged client IP at the backend | the backend accepts PROXY headers only from the proxy IP (allowlist) |
| Resolver abuse | flood the matchmaker with lookups | resolver cache, negative TTL, per-IP rate limit before the resolver call |
| Admin API access | reconfiguration | mTLS/token, internal interface only, no default bind on 0.0.0.0 |
Filter chain (before routing, cheapest first)
- CIDR allow/deny – static lists + an optional dynamic block list (feed/file, hot
reload). O(1) via an LPM trie. An IPv4-mapped IPv6 address (
::ffff:a.b.c.d) is matched as the IPv4 address it carries, so it cannot slip past an IPv4 deny list. - Geo filter (optional) – MaxMind Country DB (
settings.geo_db), per-listenergeo: { allow, deny }by ISO country code;denywins, a non-emptyallowis default-deny, an unplaceable IP is admitted only with noallowlist. Fails closed if the DB is not loaded. - Connection/datagram rate limit – token bucket per
src_ipand per/24(against distributed single IPs). Scope configurable. Excess → drop + metric, no error reply (no reflect). - Global caps –
max_connections,max_udp_sessions,max_new_sessions_per_sec. When reached: reject new ones, protect existing ones.
All steps run before buffer/session allocation.
TCP hardening
- Kernel:
net.ipv4.tcp_syncookies=1, a sensiblesomaxconn, a shorttcp_synack_retries. acceptbackpressure: when a worker is overloaded, throttle the accept rate instead of letting the queue explode.- Peek timeout (
peek_timeout_ms): anyone who sends nothing after connecting is dropped before a backend connect happens. - Min-progress watchdog: a connection with no byte progress beyond
idle_timeout→ close. - Optional per-source concurrent cap:
per_source: { max_per_ip, max_per_net }(implemented — a live counter per client IP / /24 / /64, refused before allocation).
UDP hardening
- No blind replies. The proxy sends toward the client only after the backend has replied within an established session.
- First-packet gate (optional, per route): the first datagram must pass a
first_bytesmatch (a known handshake prefix / a sniffer saying “valid”), otherwise no session is created. Keeps generic spoof floods off the session table. - Session cap per
src_ip//24(per_source, implemented — a hard concurrent counter, new sessions refused when full) and global (settings.limits.max_udp_sessions). LRU eviction of the oldest idle sessions under pressure is not done — the idle sweep reaps and new sessions are refused until room frees. - Keep the idle timeout short where the game allows it (query pools: a few seconds).
- Receive buffers large enough that legitimate bursts do not compete with flood drops.
- Disable
conntrackfor the proxy path viaNOTRACK(otherwise it is its own DoS surface).
Network & deployment hardening
- Backends: their own security zone; the ingress firewall allows only proxy source IPs to the game server ports. Backend egress minimal.
- The proxy runs without root: only
CAP_NET_BIND_SERVICE(ports < 1024) and — if transparent mode —CAP_NET_ADMIN. Read-only rootfs, seccomp profile. - Secrets (admin token, mTLS keys) via env/file with
0600, not in the YAML. - Bind the admin API and metrics to a separate internal interface; never on the public listener IPs.
- Rate-limit and ACL state is per instance; behind anycast HA, size it per node (an attack is spread across all nodes).
Abuse as an amplifier – checklist
Guarded by crates/wayhouse-core/tests/amplification.rs:
- No reply to datagrams without an established session
(
no_unsolicited_or_duplicated_replies). - No error / unsolicited reply from the app path to a dropped datagram —
routing, ACL, rate-limit, first-packet-gate drops are all silent
(
dropped_datagrams_get_no_error_reply). ICMP is the kernel’s; the proxy emits none itself. - Reply size never larger than the backend payload — the proxy forwards
backend bytes verbatim and prepends nothing toward the client
(
reply_is_exactly_the_backend_payload). - The rate limit applies before any state change — rejected datagrams create
no session and are never forwarded
(
rate_limit_is_enforced_before_any_state_change).
Sniffer sandbox guarantees (phase 9)
A loaded sniffer (settings.sniffers, crates/wayhouse/src/sniffer_loader.rs)
runs under wasmtime as a core WASM module with no WASI and no host
imports — nothing is wired in beyond the alloc/sniff exports the sniffer
itself provides. Concretely, a sniffer cannot:
- read or write the filesystem — no
wasi_snapshot_preview1, no ambient authority of any kind is linked in; - open a socket or make any network call — same reason;
- read the wall clock, environment variables, or process/thread state — nothing exposes them;
- see any byte the proxy hasn’t explicitly copied into its linear memory
(the first-bytes prefix the route already peeked, capped at
peek_len()≤PEEK_MAX) — it never sees later application bytes, and never sees another connection’s data (a freshStore+Instance, and therefore fresh linear memory, per call); - send anything toward the client — a
Snifferonly returns aRouteHint(host / key / reject); it has no reply path, so the amplifier checklist above is untouched by sniffers entirely.
Two independent bounds cap what a misbehaving (buggy or malicious) sniffer can cost the process, checked every call, not just at load time:
- Time:
wasmtimeepoch interruption. A dedicated ticker thread bumps the engine’s epoch everycall_timeout_ms / 2; each call sets a two-tick deadline before running, so a call still executing at the second tick traps (wayhouse_sniffer_calls_total{result="timeout"}). This is a ceiling, not an exact limit: the first tick lands anywhere after the call starts, so a call gets betweencall_timeout_ms / 2andcall_timeout_ms. A call well under half the timeout never traps (sniffer_loader::tests::short_calls_never_time_out_under_the_shipped_ticker). - Memory: a
StoreLimitscap (max_memory_bytes) on theStore— a sniffer that tries to grow its linear memory past the cap gets a failedmemory.grow(WASM-spec-1), not more memory.
Supply chain: settings.sniffers.modules: [{ name, sha256 }] optionally
pins each module’s SHA-256; when set, a dir entry that isn’t listed (or
whose hash doesn’t match) fails the whole load/rescan rather than loading
unpinned code.
Latency vs. NFR N1 (< 0.5 ms added; docs/01-requirements.md) — the
“latency is a gate, not an assumption” decision from docs/08 Phase 9,
measured end to end (real alloc/memory.write/sniff/decode round trip,
a fresh Store + Instance per call, release build) against the three
official sniffers (released from wayhouse-proxy/sniffers):
| sniffer | p50 | p90 | p99 | max |
|---|---|---|---|---|
a2s | 10.0 µs | 11.4 µs | 25.8 µs | 399 µs |
minecraft | 8.9 µs | 10.2 µs | 16.9 µs | 217 µs |
regex_firstbytes | 8.3 µs | 9.1 µs | 12.0 µs | 361 µs |
(Loopback numbers on the same box as crates/wayhouse-bench’s N1/N2 measurements;
2000 calls/sniffer after warmup. Reproduce with make sniffers-fetch then
cargo test -p wayhouse --release wasm_boundary -- --ignored --nocapture in
crates/wayhouse/src/sniffer_loader.rs.) All three pass N1 with wide margin — a
fresh per-call Store/Instance (the “no state survives between
connections” design from slice 3) turned out cheap enough on this hardware
that the InstancePre / pooling-allocator / warm-instance-per-worker
fallbacks noted in docs/08 Phase 9 were not needed. The occasional
higher-than-typical max samples (hundreds of µs, still far under the 500 µs
p50 gate) are consistent with ordinary scheduling jitter, not a systematic
cost — no sample in any run has been anywhere near N1.
Security logging
- Events: ACL block, rate-limit trip (aggregated, not per packet), session cap reached, health flap, config reload (who/what via the admin API), resolver error bursts.
- No plaintext of game payload in logs (privacy); metadata only.