Security

Threat Model

Status: living document, revised at every milestone’s security review. Scope today: M1 serving core (listeners, TLS, HTTP/1.1 + HTTP/2, routing, static files, reverse proxy, metrics endpoint, config reload) and M2 managed runtimes (process supervision, PHP-FPM/FastCGI, Node, Python, WebSocket tunnels, HTTPS upstreams).

1. Assets

AssetWhy it matters
Availability of the server and of unrelated tenantsShared process serves many tenants.
TLS private keysImpersonation of hosted domains.
Files outside configured document rootsSource code, .env, credentials, other tenants’ data.
Upstream applicationsMust only receive requests the operator routed to them; must not be fooled by spoofed forwarding headers.
Configuration and its integrityA bad or malicious config can expose files or take the server down.
Logs and metricsMay leak secrets or become a DoS vector via cardinality.

2. Trust boundaries

  1. Network clients → listener. Fully untrusted: bytes, timing, TLS ClientHello, headers, bodies.
  2. scalwsd → upstream applications. Semi-trusted: apps are tenant code. Their responses are forwarded but must not crash or stall the server.
  3. Filesystem. Document roots are tenant-controlled content; tenants may create symlinks.
  4. Operator → configuration / signals / admin socket. Trusted principal, but input is still validated (typos are a threat to availability).
  5. Metrics endpoint. Exposes operational data; bound to loopback by default.

3. Threats and mitigations

Legend: ✅ implemented and tested · 🟡 partial · ⏳ planned (milestone)

Network / protocol

ThreatMitigationStatus
Slowloris (slow headers)h1 header_read_timeout (also bounds keep-alive idle); TLS handshake timeout✅
Slow request bodyPer-frame body read timeout in LimitedBody✅
Slow response readerWrite timeout below TLS (limits.write_timeout) closes connections whose writes stall✅ tested
Connection exhaustionGlobal max_connections semaphore; accept pauses instead of queueing in user space✅
One source exhausting connections or request capacityPer-source (IPv4 / IPv6 /64) connection limit at accept and request token bucket (429); sharded, size-capped state that cannot grow with the number of attacking addresses✅ tested (incl. 100k-address flood)
Per-tenant request floodsTenant concurrency and rate quotas (M3)✅ tested
Oversized headers / URI / bodymax_header_bytes, max_headers, h2 max_header_list_size, URI length → 414, Content-Length precheck → 413, streaming cap✅
HTTP/2 rapid reset (CVE-2023-44487) and stream floodsmax_concurrent_streams, max_pending_accept_reset_streams, max_local_error_reset_streams✅ (config)
Request smuggling (CL/TE ambiguity, obs-fold, invalid chunking)hyper’s strict h1 parser; proxy always re-frames the upstream request and strips hop-by-hop headers; never forwards Transfer-Encoding from the client verbatim✅ tested for CL+TE
Host header ambiguityMultiple Host headers → 400; HTTP/1.1 without Host → 400; absolute-form authority wins and must agree with Host✅
SNI / Host mismatch (domain fronting across tenants)strict_sni listeners answer 421 when Host differs from SNI✅ tested (opt-in)
TLS downgrade / weak ciphersrustls defaults (TLS 1.2+ only, AEAD suites only)✅
Header injection into upstreamInbound X-Forwarded-*, Forwarded, X-Real-IP removed and replaced; X-Request-Id regenerated✅
WebSocket tunnel exhaustionServer-wide tunnel limit (max_upgraded_connections, 503 when exhausted) and idle timeout; only Upgrade: websocket on HTTP/1.1 GET is honoured, other upgrades are stripped✅ (tunnel + idle pump tested)
Upstream TLS spoofinghttps:// upstreams verify the certificate against system roots (+ optional CA file) and the configured host name✅ tested

Filesystem

ThreatMitigationStatus
Path traversal (.., encoded %2e%2e, %2f, backslash, NUL)Decode once, reject .. / NUL / \ / encoded slash; reject before touching the filesystem✅ tested
Symlink escapeCanonicalise and require the result to stay beneath the canonical root✅ tested (Linux)
TOCTOU symlink swap between check and openLinux: openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS) from a root dir fd (kernel-enforced); fallback platforms keep the residual risk✅ Linux / 🟡 fallback
Opening a FIFO/device blocks a threadO_NONBLOCK on open, type checked with fstat on the opened fd✅
Dotfile exposure (.env, .git)Any path segment starting with . is 404 except .well-known✅ tested
Directory listingNever generated✅

Managed applications (M2)

ThreatMitigationStatus
Shell injection through configured commandsargv is executed directly, never through a shell✅
Secrets in scalws’s environment leaking to applicationsChild environment is cleared; only PATH, LANG, HOME, configured env and SCALWS_* listen variables✅ tested
Injection into the generated PHP-FPM pool fileini keys/values, env values and runtime_dir restricted to safe character sets at validation✅
PHP source disclosure.php files are never served by the static handler next to a PHP runtime✅ tested
Executing scripts outside the document rootSCRIPT_FILENAME only from the confined lookup (ADR-0006); security.limit_extensions = .php✅ tested
httpoxy (Proxy: header → HTTP_PROXY)Proxy header never forwarded to FastCGI; underscore header names dropped✅ tested
Spoofed client identity towards applicationsREMOTE_ADDR/X-Forwarded-* set from the connection; client values discarded✅ tested
Other local users reaching application socketsSocket directory 0700, FPM socket 0600✅
Response mix-up on reused FastCGI connectionsConnection returned to the pool only after END_REQUEST; stale detection; replay limited to buffered requests✅ tested (worker recycling under concurrency)
A crashing / crash-looping applicationSupervisor restarts with backoff; Failed after 5 crashes/minute; 503 + Retry-After; unrelated apps unaffected✅ tested
Orphaned application processes if scalwsd is killedOwn process group per worker, group killed on stop/crash; on SIGKILL of scalwsd the systemd unit’s control-group kill is required🟡
Applications of different tenants reading each other’s filesAll applications run as the scalws user (PHP workers as nobody when scalws runs as root); per-tenant users designed in ADR-0010, not implemented⏳ hardening
Runaway application memory / fork bombs / CPU hoggingcgroup v2 memory.max (+ memory.swap.max), pids.max, cpu.max per tenant; OOM kill confined to the tenant’s group; workers join their group before their first instruction✅ tested (OOM isolation, live limit change)
A tenant exhausting the shared server (request floods)Per-tenant max_concurrent_requests (503) and token-bucket rate (429) with bounded state✅ tested
Limits silently not enforcedserver.cgroups.mode: required refuses to start without delegation; auto logs a warning and exports scalws_cgroups_enabled 0✅

Cache and request rules (M5)

ThreatMitigationStatus
Serving one user’s personalised response to anotherOnly explicitly cacheable responses; never with Set-Cookie, private, no-store, no-cache; requests with Authorization or personalisation cookies (profile rules) bypass the cache; no AI involvement✅ tested
Cache poisoning via unkeyed headersVary variants keyed by request header values; Vary: * not stored✅ tested
Cache memory exhaustionSize-weighted budget with eviction, per-entry size cap✅ tested
Brute force on specific paths (login, password reset)security.rate_limits per client address and path✅ tested
Cache stampede (many concurrent misses hitting a slow upstream)Miss coalescing: one upstream request per URL, bounded 5 s wait✅ tested
Orphaned application processes after a crash of scalwsdsetpriv --pdeathsig TERM; cgroup leaves reaped at start✅ tested
Cache disk exhaustion / stale filesDisk tier bounded by size (LRU), expired and partial files removed at start, writes dropped when the queue is full✅ tested
Unauthenticated purgePurge only on the admin socket (ADR-0017)✅
Sensitive files of known frameworks (wp-config.php, .env, uploaded PHP)Profile deny rules + configured deny paths, case-insensitive on the normalised path✅ tested

Detection (M4)

ThreatMitigationStatus
A hostile application tree (symlinks to /etc, huge files, deep trees) abusing scalwsctl detectSymlinks never followed; reads capped at 256 KiB per file, depth 3, 4000 entries; dependency dirs skipped; nothing executed✅ tested (symlink escape)
Detected values injecting configurationFacts are escaped into quoted YAML strings or JSON arrays and the result is parsed and validated like any configuration✅
A deploy silently changing how an application runsConfiguration loading never inspects application files; detection only proposes✅ tested

Optimizer (M6)

ThreatMitigationStatus
Load spike makes the optimizer exhaust host resourcesWorker targets bounded by configured min/max; scale-up blocked at ≥ 85 % tenant memory; tenant cgroup limits still apply✅ tested
Oscillation (flapping)Separate up/down bands, consecutive-window streaks, per-rule cooldowns; property test proves single steps and cooldown spacing for arbitrary telemetry✅ tested
A change makes things worseOutcome observed for 3 windows; regression in 5xx ratio or p95 rolls back and suppresses the rule for 10 min✅ tested
Unexpected automatic changesRecommend-only by default; autoscale: auto per application; every decision audited✅
Journal disclosureServed only on the loopback admin listener; contains tenant/app names and aggregate numbers, no request data✅

Diagnostics (M7)

ThreatMitigationStatus
Leaking request data through diagnosticsRing buffer keeps method, path without query (truncated to 256 bytes), status and timings; no headers, cookies or bodies✅
Unbounded diagnostic memoryRing buffer capped per application (recent_requests, max 10000); one evidence/finding set per application✅
Exposure of /diagnoseRead-only, loopback metrics listener and admin socket✅
Misleading root causeFindings state correlation (related), carry their evidence and are fully deterministic and unit-tested✅

Local AI advisor (M8)

ThreatMitigationStatus
Telemetry or customer data leaving the hostDisabled by default; loopback endpoint unless allow_remote: true; only aggregated diagnostics, recent request paths and optimizer decisions are sent✅ tested
Secrets in the model inputEvery string redacted (query strings, userinfo, Bearer/Basic credentials, secret key=value, JWTs, e-mail addresses, opaque tokens); secret-named fields dropped; no headers, cookies or bodies collected✅ tested (unit, property, end to end)
Prompt injection via request pathsModel output is advisory text only; strict schema, bounded lengths, causes must reference real finding codes; the advisor cannot act✅
Terminal escape injection via model outputControl characters removed from every output field✅ tested
LLM-generated commands executedNever: investigations are displayed only; no code path executes them✅
Model down/slow/hostile degrades servingSeparate task, bounded queue (429 when full), per-call timeout, size-capped response (256 KiB); request path never waits✅ tested
API key exposureRead from an environment variable named in the configuration, never stored in YAML or logs✅

ACME (M10)

ThreatMitigationStatus
Theft of private keys / account keystate_dir 0700, keys and account 0600, atomic writes; unit StateDirectoryMode=0750✅ tested
Challenge endpoint abuseOnly tokens of pending orders are answered; everything else falls through to the application; plain HTTP only✅
Issuance for names the operator did not configureOnly domains of applications with acme: true; wildcards and IPs rejected at validation✅ tested
CA outage / rate limitsFailures back off per application (5 min doubling to 24 h); existing certificates keep being served; renewal starts 30 days before expiry✅
A bad issued file breaks servingManaged certificates are verified (key match, names) at prepare; invalid ones are skipped with a warning✅ tested

HTTP/3 (M10, opt-in feature)

ThreatMitigationStatus
QUIC connection floodsGlobal connection permits and per-source admission before the handshake completes (refuse); stream limit and idle timeout from limits✅
Amplification / address spoofingquinn’s address validation and anti-amplification limits (RFC 9000 §8)✅ (library)
Replay via 0-RTT0-RTT disabled (max_early_data_size = 0)✅
Immature HTTP/3 implementationOff by default and not in packages; requests pass the same pipeline checks as TCP🟡 experimental

Containers (M10)

ThreatMitigationStatus
Option injection into the engine CLI (--privileged as image, mounts with ,)argv only, no shell; image, user, mount paths and env keys validated✅ tested
Container escaping tenant limitspodman: --cgroup-parent = application group (tenant memory/CPU/pids apply); docker: daemon-placed, warning at validation✅ tested (podman) / 🟡 docker
Privilege escalation inside the containerno-new-privileges always; drop_capabilities, read_only, user options✅
Orphaned containers keep serving or consuming resources--init for signal delivery; rm --force after every worker exit; names unique per start✅ tested
Exposure of the published portPublished on 127.0.0.1 only; reached through scalws✅

Client address (trusted proxies)

ThreatMitigationStatus
Client spoofs X-Forwarded-For to evade per-IP limits or fake log entriesHeader used only from trusted_proxies peers; right-most untrusted entry wins; otherwise replaced✅ tested
Forged PROXY headersOnly from trusted peers; malformed/late headers close the connection; parser never reads past the header and is property-tested✅ tested
Balancer throttled as one clientTrusted peers exempt from the per-source connection limit; requests limited per reported client✅ tested

Configuration and operations

ThreatMitigationStatus
Typo / invalid config takes server downdeny_unknown_fields, semantic validation, full prepare before atomic swap; failure keeps active state✅ tested
Unimplemented features silently ignoredRuntime types not available in this build are rejected at validation✅
Metrics endpoint exposureLoopback by default; separate listener✅
Admin API abuseUnix socket (0660, directory 0750, owner/group scalws); optional remote API only with mutual TLS (client certificate from a configured CA, TLS 1.3); mutations refused (403) on the TCP metrics listener; every mutation audited (peer uid/gid/pid or certificate subject); a live socket of another instance is never replaced✅ tested (incl. package smoke test: outsider denied, group member allowed)
Running as rootPackaged unit runs as scalws with only CAP_NET_BIND_SERVICE; warning when started as root; no in-process privilege drop (avoids multi-threaded setuid)✅
Bad configuration applied by an operatorSame validate/prepare path as reload; rollback to any of the last 10 configurations; restore is a dry run unless --apply and keeps .bak files✅ tested
Tampered backup manifest writing outside intended pathsOnly absolute paths without .. are accepted; sizes verified; dry run by default✅ tested

Logging / metrics

ThreatMitigationStatus
Secrets in logsAccess log records path without query string; no headers or bodies logged✅
Metric cardinality explosionLabels limited to configured tenant/app names, listener name, normalised method and status class; unmatched hosts collapse to _unmatched✅

Supply chain

ThreatMitigationStatus
Vulnerable / malicious dependenciescargo deny (advisories, licences, sources, bans) and cargo audit in CI; Cargo.lock committed✅ (CI config)
Unsafe code bugsunsafe_code = "forbid" workspace-wide✅
Parser bugsProperty-based fuzzing (proptest) of path and host normalisation, range, Cache-Control, CGI headers, units and the configuration parser; coverage-guided fuzzing (cargo-fuzz, nightly) not set up🟡

4. Residual risks accepted for M1

  • TOCTOU on symlink replacement inside a document root on platforms/kernels without openat2 (fallback path only).
  • No write/response timeout for slow readers while a response is streaming.
  • No per-client rate limiting; a single client can consume up to max_connections.
  • No WAF.
  • Applications of different tenants share a Unix user (per-tenant users are a designed but unimplemented hardening step).
  • The cgroup trampoline relies on /bin/sh (a fixed script; user argv stays positional).