ESC
Type to search...
S
Soli Docs

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 Secure flag on the session cookie (set when the request scheme is https).
  • The host portion of *_url named-route helpers used for absolute URLs in emails and redirects.
Default: OFF

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.

Apps created by 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).

Default: 8 MiB

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.

Default: 16 × the per-request cap

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.

Default: a quarter of the global budget

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.

Parameters
  • 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.