Server Hardening
Production-safe defaults for handling untrusted input at the request edge: trust-proxy gating for X-Forwarded-* headers, and a per-request body-size cap to prevent memory-exhaustion DoS.
The checklist of what production already does versus what you still set is on
Production security defaults.
Trust Proxy
Soli reads X-Forwarded-Proto and X-Forwarded-Host from incoming requests only when trust-proxy is enabled. These headers govern two security-sensitive decisions:
- The
Secureflag on the session cookie (set when the request scheme ishttps). - The host portion of
*_urlnamed-route helpers used for absolute URLs in emails and redirects.
A request reaching the Soli process directly cannot influence either decision via these headers — the cookie Secure flag stays off and *_url falls back to the Host header. Enable trust-proxy only when your deployment terminates TLS at a trusted proxy hop (Caddy, nginx, ALB, etc.) and that proxy is configured to strip inbound X-Forwarded-* headers from clients before adding its own.
enable_trust_proxy() / disable_trust_proxy() / trust_proxy_enabled()
Toggle and inspect the trust-proxy gate. All three return Bool.
# config/application.sl
enable_trust_proxy
# Later, e.g. in a test harness running without a proxy:
disable_trust_proxy
# Inspect:
if trust_proxy_enabled
println("X-Forwarded-* headers are honored")
end
You can also set the startup default from the environment — useful when the same image runs in different deployments:
# .env.production
SOLI_TRUST_PROXY=1
Truthy values: 1, true, yes (case-insensitive). The function calls still override the env-driven default at runtime.
Name the proxies you trust
The flag alone is all-or-nothing: with it on, an X-Forwarded-*
header is honoured no matter who sent it. SOLI_TRUSTED_PROXIES
narrows that to the hops that actually rewrite those headers, so a client
reaching the app directly is not trusted even while the flag is on.
# .env.production
SOLI_TRUST_PROXY=1
SOLI_TRUSTED_PROXIES=10.0.0.0/8,127.0.0.1,::1
Entries are IPs or CIDR blocks, comma-separated; an IPv4-mapped IPv6 peer
(::ffff:10.0.0.5, what a dual-stack listener
reports) matches an IPv4 rule. Leaving it unset keeps the previous behaviour:
every peer is trusted once the flag is on.
soli new ship with this off.
It used to be on by default, which is wrong for an app exposed directly: any
client could forge the request authority and scheme — downgrading the CSRF
and origin checks, flipping the cookie Secure flag, aiming
*_url helpers at a phishing host, and taking a fresh identity per
request so per-IP rate limits never tripped. Uncomment the line in
config/application.sl when you deploy behind a proxy.
Request Body Limit
Every non-GET/HEAD request is capped before its body is buffered into memory. Without this cap an attacker can stream an arbitrarily large body — or open many concurrent uploads — and exhaust worker memory before any handler runs.
The server short-circuits to 413 Payload Too Large in two ways: via the Content-Length header (no bytes read) or mid-stream once the running total crosses the limit (catches chunked uploads that don't declare a length).
Apps that accept larger uploads — document upload, image processing, etc. — should raise the cap explicitly. Prefer per-action checks inside the handler over a high global cap so abusive clients can't open many concurrent uploads on routes that shouldn't accept them.
Aggregate In-Flight Limit
SOLI_MAX_BODY_SIZE bounds one request. Upload bytes stay in memory while the request runs, so the two limits multiply: without a second bound the server buffers one body per connection (SOLI_MAX_CONNECTIONS, 20 000 by default), and every parsed request then waits in the worker queue still holding its payload.
SOLI_MAX_INFLIGHT_BODY_BYTES caps the sum of request-body bytes reserved at once. A request is charged as its bytes arrive: a 64 KiB reservation up front, doubled as the body grows, up to SOLI_MAX_BODY_SIZE — rather than claiming the full cap before reading a byte, so a crowd of small bodies no longer exhausts the budget. The reservation is returned when the request ends — including on error paths — so a burst of large uploads is refused instead of swapping the box.
128 MiB out of the box. Over the cap the server answers 503 Service Unavailable with Retry-After: 1. Set it to 0 to disable the budget. Raising SOLI_MAX_BODY_SIZE raises this default with it, so the two stay in proportion unless you set it explicitly.
SOLI_MAX_INFLIGHT_BODY_BYTES=268435456 # 256 MiB across all requests
Per-client share
SOLI_BODY_BUDGET_PER_IP_BYTES caps how much of that budget one client may hold at once, so a single client uploading in parallel cannot take the whole budget and turn every other upload away. Over its share a client gets the same 503 Server busy: too many uploads in flight with Retry-After: 1; everyone else is unaffected.
Never less than one SOLI_MAX_BODY_SIZE (a client can always send one maximum-size body), never more than the global budget, and 0 when the global budget is disabled. Set it to 0 to disable the per-client cap.
The client is the TCP peer, or the right-most X-Forwarded-For entry when trust proxy is on — the same key the rate limiter uses. IPv6 clients are counted per /64 (one host owns the whole prefix), and an IPv4-mapped IPv6 address counts as its IPv4 address.
SOLI_BODY_BUDGET_PER_IP_BYTES=67108864 # 64 MiB per client
Behind a proxy, turn trust proxy on
Without it every request comes from the proxy's address, so all clients share one share — about a quarter of the total upload capacity by default. Enable trust proxy (with SOLI_TRUSTED_PROXIES), raise this variable, or set it to 0.
Do not set it below 64 KiB: every upload claims a 64 KiB first slice, so a smaller share answers every request with a body 503.
Stalled and broken bodies
SOLI_BODY_READ_TIMEOUT_SECS (default 60) bounds how long a whole body may take. SOLI_BODY_IDLE_TIMEOUT_SECS (default 10) bounds the silence between two frames: a body that stalls that long is answered 408 Request Timeout and its reservation freed. A body cut off by a transport error is a 400 Bad Request (it used to be reported as 413).
set_max_body_size(bytes)
Sets the maximum buffered request body size, in bytes.
bytes(Int) - New limit in bytes (must be non-negative)
# config/application.sl
set_max_body_size(32 * 1024 * 1024) # 32 MiB cap for file uploads
# Returns Int — the value just set.
The startup default can also come from the environment:
# .env.production
SOLI_MAX_BODY_SIZE=33554432 # 32 MiB, in bytes
Non-numeric or negative values are ignored and the 8 MiB default stands. The function call still overrides at runtime.
max_body_size()
Returns the current limit in bytes.
println("Current cap: " + str(max_body_size) + " bytes")
Related: Security Headers
Production hardening also includes the response-side defaults — Content-Security-Policy, HSTS, X-Frame-Options, and friends — covered on the dedicated reference page.