Forwarded host authority

Reverse proxies use forwarding headers to report the original request's host, scheme, and port. Rack reads these values before it falls back to the request's Host header and direct connection details. Without a trust boundary, a client can send X-Forwarded-Host or an RFC 7239 Forwarded header and choose the host that the application believes it serves.

This affects any value derived from request.host, request.scheme, request.ssl?, or request.port, including redirect targets, generated links, WebAuthn rp_id, OmniAuth redirect_uri, mailer base URLs, secure-cookie decisions, and mounted Rack applications that build absolute URLs.

Otto uses the proxy trust configured for client IP resolution to decide whether Rack may also use forwarded host, scheme, and port values.

[!WARNING] Omitting proxy trust does not reject forwarded authority. Otto leaves the headers intact for compatibility, and Rack applies its own process-global policy. Choose an explicit trust posture before serving requests whenever application behavior depends on these request values.

Choose a trust posture

Deployment Configuration Result
The application is directly exposed and should trust no proxy trusted_proxies: :none Otto strips forwarded host, scheme, and port carriers from every request.
Proxy addresses can be enumerated trusted_proxies: [...] Otto keeps the carriers only when REMOTE_ADDR matches a configured proxy.
Proxy addresses cannot be enumerated, but the hop count is fixed trusted_proxy_depth: N Otto trusts the carriers on every request. The application origin must accept traffic only from the proxy tier.
Another layer owns the trust decision Leave proxy trust unconfigured Otto leaves the carriers unchanged and makes no trust assertion.

[!WARNING] These settings control forwarded authority only. They do not validate the ordinary Host header. After forwarded carriers are stripped, Rack falls back to Host, which a direct client can still choose. If redirects, generated links, WebAuthn, OAuth, or cookie policy require a canonical host, enforce a host allowlist in the front server or application.

For a directly exposed application:

otto = Otto.new('routes', trusted_proxies: :none)

For an application behind proxies whose addresses are known:

otto = Otto.new(
  'routes',
  trusted_proxies: ['10.0.0.0/8', '192.0.2.0/24']
)

Use depth mode only when the proxy addresses cannot be listed and the number of proxy hops is fixed:

otto = Otto.new('routes', trusted_proxy_depth: 1)

Depth mode treats every connecting peer as trusted. Before enabling it, prevent direct access to the application origin with private networking, firewall or security-group rules, or an equivalent control. Otherwise, a client can submit forwarded host, scheme, port, and IP values directly.

Configure these options before the first request, when Otto freezes its configuration.

How Otto handles each trust state

The decision is made by IPPrivacyMiddleware from the connecting peer (REMOTE_ADDR) before any masking, and recorded in env['otto.via_trusted_proxy'].

Trust state otto.via_trusted_proxy Forwarded host, scheme, and port carriers
REMOTE_ADDR matches a configured trusted-proxy CIDR true Kept. When privacy masking applies, Forwarded keeps its proto=, host=, and by= fields while its for= value is replaced with the masked IP.
Depth mode is enabled true for every peer Kept, subject to the same privacy masking.
Proxy trust is configured, but the peer does not match a configured CIDR false Deleted.
trusted_proxies: :none is configured false for every peer Deleted.
Proxy trust is not configured absent Left unchanged. Otto makes no trust assertion, so Rack may apply its own policy.

The deleted keys are HTTP_FORWARDED, HTTP_X_FORWARDED_HOST, HTTP_X_FORWARDED_PROTO, HTTP_X_FORWARDED_SCHEME, HTTP_X_FORWARDED_SSL, and HTTP_X_FORWARDED_PORT. X-Forwarded-For is not deleted on this path; Otto's own client IP resolution already ignores it from an untrusted peer, and a masking privacy profile rewrites it separately. With IP privacy disabled the header reaches the application intact, and Rack::Request#ip returns its value whenever REMOTE_ADDR is private or loopback. Read env['otto.client_ip'] rather than Rack::Request#ip, and configure any mounted gem that reads request.ip (for example Rack::Attack) accordingly.

The absent otto.via_trusted_proxy key is intentional. It means the operator made no proxy-trust assertion, so downstream consumers may apply their own heuristics. This preserves compatibility for applications that already run behind a proxy without configuring Otto's trust controls.

Direct exposure: trust no proxy

Use trusted_proxies: :none when clients connect directly to the application and no reverse proxy should influence request authority. This explicit assertion is different from omitting trusted_proxies. The String spelling 'none' (any case) is accepted too, for YAML- or environment-driven configuration. The sentinel is only valid as the whole option: a list that contains it, such as trusted_proxies: ['none'], is rejected at configuration time rather than installed as a proxy entry.

You can also make the assertion after construction, but before the first request:

otto.trust_no_proxies!

The same operation is available through the security configurator and the underlying security configuration:

otto.security.trust_no_proxies!
otto.security_config.trust_no_proxies!

Under this assertion:

  • env['otto.via_trusted_proxy'] is false for every peer;
  • client IP resolution ignores forwarded chains and uses REMOTE_ADDR;
  • forwarded host, scheme, and port carriers are stripped;
  • Rack::Request#host resolves from the Host header; and
  • trusted geo headers remain disabled because they require enumerated trusted-proxy CIDRs.

Loopback is not special-cased. A reverse proxy running on 127.0.0.1 in front of the application is an untrusted peer under this assertion and its forwarded headers are stripped. Use add_trusted_proxy('127.0.0.1') for that deployment instead. The separate env['otto.peer_loopback'] signal is derived from the raw peer and is unaffected, as is env['otto.peer_relayed'], which records whether any relay marker header was present before the carriers were stripped so Otto::CaddyTLS::LocalhostGuard still refuses a relayed request.

The assertion is mutually exclusive with an actual trust grant. Combining it with trusted-proxy CIDRs or a depth of 1 or more raises at configuration time:

Cannot combine trusted_proxies: :none (trust no proxy) with trusted_proxies
CIDRs or trusted_proxy_depth >= 1. Assert :none OR grant trust, not both.

Why the whole Forwarded header is removed

For an untrusted peer Otto deletes Forwarded entirely rather than editing out its host= field. Editing would require Otto to parse RFC 7239 itself, and a parser that disagrees with Rack's on quoting can let a host= survive the edit. A value such as for=a"b;host=evil is enough to produce that disagreement. Deletion has no such failure mode. On this path Otto reads nothing from the header itself, so nothing is lost.

Choose the forwarding family for depth mode

Rack::Request.forwarded_priority is a process-wide setting that selects the main forwarding family Rack reads: X-Forwarded-*, RFC 7239 Forwarded, or both. Otto pins it to the family selected by trusted_proxy_header so client-IP resolution and Rack's main forwarded host, port, and scheme parsing agree.

This is not a complete sanitizer for requests from a trusted peer. Rack checks some compatibility carriers independently, notably X-Forwarded-SSL. Otto keeps forwarded carriers from trusted peers because it cannot distinguish values created by the proxy from values the proxy passed through. Configure the trusted proxy to remove client-supplied forwarding headers before setting its own authoritative values.

The pin governs host, port, and the X-Forwarded-Proto / proto= scheme lookup. It does not govern X-Forwarded-SSL: Rack (3.2.x) honors X-Forwarded-SSL: on before it consults forwarded_priority, in every family. Otto covers this by deleting X-Forwarded-SSL together with the other authority carriers for any untrusted peer, so the header only reaches Rack from a trusted proxy or an unconfigured deployment.

otto = Otto.new(
  'routes',
  trusted_proxy_depth: 1,
  trusted_proxy_header: 'Forwarded'  # or 'X-Forwarded-For' (default), or 'Both'
)

Because the setting is process-wide, two Otto applications mounted in one process that both resolve proxied requests must agree. The later one raises:

Cannot use forwarding family %s (trusted_proxy_header) because another Otto
application in this process already uses %s. Rack's forwarded host, port,
scheme, and IP policy is process-global, so every Otto application in one
process that resolves proxied requests must use the same forwarding family.

The two placeholders are the requested family and the already committed one.

An application that configures no proxy trust, and one that asserts trusted_proxies: :none, read no forwarded chain and therefore stake no claim on the family. Neither can block a later explicit choice, unless it also names trusted_proxy_header explicitly. Otto only reads the header in depth mode, but setting it is always a claim: it pins Rack's forwarded_priority and registers the family for the process, even under trusted_proxies: :none.

trusted_proxy_header accepts X-Forwarded-For (the default), Forwarded, or Both. When configuring proxy trust, Forwarded and Both require depth mode. CIDR filter mode resolves client IPs from the X-Forwarded-For family only (X-Forwarded-For, then X-Real-IP, then X-Client-IP) and never from RFC 7239 Forwarded, so a non-default family would make Rack read a header that Otto ignores:

Cannot configure trusted_proxy_header 'Forwarded' or 'Both' together with
trusted_proxies (CIDR filter mode): CIDR-walk resolves client IPs from the
X-Forwarded-For family only (X-Forwarded-For, X-Real-IP, X-Client-IP), never
RFC 7239 Forwarded. Use trusted_proxy_depth (count mode) to read the RFC 7239
Forwarded header.

Use trusted_proxy_depth when the deployment requires RFC 7239 Forwarded. Remember that depth mode also requires origin lockdown because it trusts every connecting peer.

Place the middleware before other request consumers

Otto performs this filtering in IPPrivacyMiddleware, which it installs first in its own stack. Downstream middleware, mounted applications, and handlers see the filtered environment.

If middleware outside the Otto application reads the request first, mount IPPrivacyMiddleware ahead of it in the common Rack stack. Pass the application's security configuration, as shown in Privacy-preserving request data. Without that configuration, the outer middleware instance applies defaults and makes no trust decision, so forwarded authority reaches earlier middleware unchanged.

The inner instance still enforces the application's own posture when an outer pass has already resolved the client IP:

  • trusted_proxies: :none always applies. The inner instance records otto.via_trusted_proxy as false and strips the authority carriers, whatever the outer instance did.
  • trusted_proxy_depth records true unless a configured outer pass already recorded a verdict.
  • trusted_proxies: [...] keeps the verdict of a configured outer pass. When no outer pass recorded one, the connecting peer can no longer be matched, because the outer pass rewrote REMOTE_ADDR. Otto then treats the peer as untrusted, strips the carriers, and logs a warning naming the fix: pass the application's security configuration to the outer instance.