Otto v2.3.0 Migration Guide

Overview

This release harmonizes IP address and trusted-proxy resolution (the upstream work for onetimesecret#3436). The client IP is now resolved once into a canonical env['otto.client_ip'] that downstream code reads, trusted-proxy matching uses real CIDR containment, and several privacy helpers that silently returned nil now work. It also adds an additive, opt-in count-based trusted-proxy depth mode for proxy tiers whose addresses cannot be enumerated as CIDRs (see the New feature section below). Most apps need no changes; the one behavior change to review is trusted-proxy matching.

Breaking / behavior changes

1. Trusted-proxy matching is now real CIDR containment

Otto::Security::Config#trusted_proxy? previously compared entries with == / String#start_with?. It now parses entries with IPAddr and matches by containment for both IPv4 and IPv6.

What changes in practice:

```ruby config.add_trusted_proxy(‘10.0.0.0/8’) config.trusted_proxy?(‘10.1.2.3’) # was false (CIDR never matched), now true

config.add_trusted_proxy(‘192.168.1.1’) config.trusted_proxy?(‘192.168.1.100’) # was true (textual prefix), now false ```

  • CIDR ranges now actually work. Previously add_trusted_proxy('10.0.0.0/8') was effectively a no-op because no real IP literally starts with the string '10.0.0.0/8'. If you relied on a single-IP entry acting as a prefix to cover a range, replace it with an explicit CIDR.
  • Bare hosts match only themselves. 192.168.1.1 no longer matches 192.168.1.100, 192.168.1.10, etc.
  • Non-IP strings still work via the legacy path. An entry like '172.16.' that does not parse as an IP/CIDR falls back to the old prefix match, and add_trusted_proxy now logs a warning suggesting a CIDR (172.16.0.0/12).
  • Regexp entries are unchanged.

Action: audit your trusted_proxies configuration. Convert any single-IP or bare-prefix entries that were meant to cover a range into proper CIDR notation.

2. secure? trusts forwarded proto via a canonical flag

Otto::Request#secure? previously decided whether to honor X-Forwarded-Proto / X-Scheme by checking trusted_proxy?(REMOTE_ADDR). With IP privacy enabled, IPPrivacyMiddleware rewrites REMOTE_ADDR to the masked client IP, so that check could no longer see the connecting proxy and secure? could wrongly return false behind a TLS-terminating trusted proxy.

The middleware now records the peer-trust decision once (before masking) in a leak-free boolean env['otto.via_trusted_proxy'], and secure? reads it. When the middleware has not run (standalone request use), secure? falls back to its previous behavior. No app changes are required.

3. Privacy helpers now return values (previously nil)

These helpers read the un-namespaced env keys that the middleware never set, so they always returned nil. They now read the canonical otto.privacy.* keys and return real values when IP privacy is enabled:

  • Otto::Request#redacted_fingerprint
  • Otto::Request#geo_country
  • Otto::Request#hashed_ip
  • Otto::Request#masked_ip

If you worked around these returning nil, you can now use them directly.

4. private_ip? is now IPv6-aware

Otto::Request#private_ip? (used by #local_or_private_ip? and #local?) was an IPv4-only regex that classified every IPv6 address — including ::1 and ULA fc00::/7 — as public. It now delegates to Otto::Utils.private_ip?, which recognizes IPv6 loopback, unique-local, link-local, multicast and unspecified addresses (and folds IPv4-mapped IPv6). IPv4 behavior is preserved for the RFC1918, link-local and unspecified ranges; two IPv4 cases are now also classified non-public that the old regex missed: loopback 127.0.0.0/8 and the full multicast block 224.0.0.0/4 (the old regex only matched 224.0.0.0/8). Both are harmless — #local_or_private_ip? already special-cased 127.0.0.1, and private_ip? no longer participates in client-IP resolution.

5. Standalone client_ipaddress matches the middleware

Otto::Request#client_ipaddress and IPPrivacyMiddleware now share one resolver (Otto::Utils.resolve_client_ip). With the middleware mounted (the normal case) client_ipaddress returns the canonical env['otto.client_ip'] unchanged. Without the middleware, its fallback now walks the X-Forwarded-For chain skipping trusted proxies (instead of skipping private IPs) and consults X-Client-IP rather than the legacy Client-IP header — making the no-middleware path agree with production. This only affects apps that use Otto::Request standalone without the Otto middleware stack.

New / canonical env keys

Key Type Meaning
otto.client_ip String Canonical client IP, resolved once. Masked when privacy is enabled; resolved real IP when disabled or exempt. Read by Request#ip / #client_ipaddress.
otto.via_trusted_proxy Boolean Whether the request arrived via a trusted proxy, decided before masking. Read by Request#secure?.

The privacy data keys remain otto.privacy.{fingerprint,masked_ip,hashed_ip,geo_country}.

New feature: count-based trusted-proxy depth (additive, opt-in)

New in 2.3.0 alongside the harmonization above: count-based trusted-proxy resolution (“trust the last N hops”), the Express trust proxy = N primitive, for proxy tiers whose addresses cannot be enumerated as CIDRs. It is additivetrusted_proxy_depth defaults to nil, leaving CIDR-walk behavior unchanged. This is the upstream landing point for the depth logic previously carried in onetimesecret#3436 and the ConfigureTrustedProxy monkeypatch (onetimesecret#3116).

When to use depth vs CIDR-walk

Use… When…
CIDR-walk (add_trusted_proxy, the default) Your proxy IPs are enumerable — a fixed set of load balancers / reverse proxies you can list as IPs or CIDR ranges. The client is the first address in the forwarded chain that is not itself a trusted proxy.
Depth (trusted_proxy_depth = N) Your proxy tier is non-enumerable — Fly, cloud load balancers, dynamic/autoscaling reverse proxies whose addresses you cannot pin down. You instead know the fixed number of hops between the client and your app.

The two modes are mutually exclusive. Configuring both trusted_proxies and trusted_proxy_depth >= 1 raises an ArgumentError immediately (the moment the second of the two is set), with a freeze-time backstop, so a contradictory setup fails fast rather than silently picking one.

Configuration

```ruby # Non-enumerable single-proxy edge (e.g. one reverse proxy in front of the app): otto.security_config.trusted_proxy_depth = 1

Two hops (e.g. cloud LB -> app proxy -> app):

otto.security_config.trusted_proxy_depth = 2 ```

Or via the security facade / options, alongside the other security knobs:

```ruby otto.security.configure(trusted_proxy_depth: 1)

or at construction (a top-level option, like trusted_proxies):

Otto.new(routes, trusted_proxy_depth: 1) ```

nil or 0 disables depth mode (CIDR-walk applies). The accessor validates the mutual-exclusion rule on assignment and raises FrozenError once the configuration is frozen, like the other security scalars.

How depth resolution works

The resolver builds a chain of X-Forwarded-For (left = client … right = nearest proxy) followed by REMOTE_ADDR (the direct peer), and trusts exactly N hops from the right:

chain = X-Forwarded-For (left → right) + [REMOTE_ADDR] client = chain[-(N + 1)] # == Express addrs[N]

Express parity. trusted_proxy_depth = N matches Express / proxy-addr trust proxy = N: both trust N hops from the connecting peer inward and return the next address as the client. When the chain is long enough, Otto’s chain[-(N+1)] is exactly Express’s addrs[N].

Robust against X-Forwarded-For padding. Because hops are counted from the right, a forged leftmost entry is never reached. With depth = 1, a request arriving as X-Forwarded-For: 9.9.9.9, 203.0.113.50 (where 9.9.9.9 is attacker-supplied and 203.0.113.50 is appended by the proxy) resolves to 203.0.113.50. Positions are counted raw — entries are never dropped before counting, so an attacker cannot pad the header with junk/invalid entries to shift the index. Only the single selected entry is validated.

Short-chain and invalid-target fallback. If the chain is shorter than N + 1 (a request that may have bypassed the proxy tier), or the selected entry is not a valid IP, the resolver returns REMOTE_ADDR rather than a spoofable forwarded value. This is intentionally stricter than Express, which returns the leftmost (most spoofable) forwarded entry in that case.

Single-value headers are never consulted. X-Real-IP and X-Client-IP cannot express a hop chain, so depth mode ignores them (CIDR-walk still consults them). Which multi-hop header depth counts from — X-Forwarded-For (default), the RFC 7239 Forwarded header, or Both — is configurable as of 2.3.1; see Selecting the forwarded header below.

secure? is independent of depth. Depth mode resolves the client IP only; it does not grant proxy trust for X-Forwarded-Proto / X-Scheme. env['otto.via_trusted_proxy'] — which Otto::Request#secure? consults to honor a forwarded proto — is derived solely from the trusted-proxy identity check (does REMOTE_ADDR match a configured trusted_proxies CIDR?), never from hop depth. Because depth mode and trusted_proxies are mutually exclusive, that check is false under depth, so secure? does not honor a forwarded proto and reflects only a direct TLS connection (HTTPS=on / port 443). This mirrors the downstream (OneTimeSecret) behavior: proto-trust is never derived from depth.

Selecting the forwarded header (added in 2.3.1)

By default depth counts hops from X-Forwarded-For. Deployments fronted by a proxy that emits the RFC 7239 Forwarded header can change the source via trusted_proxy_header, mirroring OneTimeSecret’s site.network.trusted_proxy.header:

```ruby otto.security_config.trusted_proxy_header = ‘Forwarded’ # RFC 7239 only

or via the facade / construction options, alongside the depth:

otto.security.configure(trusted_proxy_depth: 1, trusted_proxy_header: ‘Both’) Otto.new(routes, trusted_proxy_depth: 1, trusted_proxy_header: ‘Forwarded’) ```

Value Chain source
'X-Forwarded-For' (default) X-Forwarded-For
'Forwarded' RFC 7239 Forwarded — the for= of each forwarded-element
'Both' Forwarded when it carries a for=, otherwise X-Forwarded-For
  • Both precedence is fallback, not merge. If the Forwarded header carries at least one for=, only its chain is used and X-Forwarded-For is ignored; otherwise the X-Forwarded-For chain is used. The two chains are never concatenated. (Matches OTS’s extract_rfc7239_forwarded(env) || extract_x_forwarded_for(env).)
  • RFC 7239 parsing. Each comma-separated forwarded-element is one hop; its for= parameter is read case-insensitively and unquoted. Quoted IPv6 with a port (for="[2001:db8::1]:443") resolves to 2001:db8::1. Obfuscated (for=_hidden) and for=unknown identifiers still occupy a hop position but are not valid IPs, so if one is the selected hop the resolver falls back to REMOTE_ADDR. Multiple Forwarded headers (joined by Rack into one comma-separated value) are handled.
  • Raw position counting is preserved in all three modes: elements are never dropped before counting (an element without a for= still counts as a hop), so padding cannot shift the index; only the selected hop is validated.
  • Depth mode only. trusted_proxy_header is consulted solely in depth mode; CIDR-walk is unaffected.
  • Lenient spelling, strict validation. The value is matched case-insensitively with surrounding whitespace ignored (forwarded, both, ` X-Forwarded-For ` all work) and stored canonicalized. A genuinely unrecognized value raises ArgumentError at assignment rather than silently falling back to a default header — a typo surfaces at config time instead of as subtly-wrong client IPs at request time.

Security prerequisite: origin lockdown

Depth trust assumes your app is unreachable except through the proxy tier.

This is the inherent trade-off versus CIDR-walk: depth relies on a fixed network topology instead of enumerable proxy addresses. If a client can reach your app directly (origin not locked down), it can pad X-Forwarded-For so that a forged value lands at chain[-(N+1)], spoofing the resolved client IP. (Proto trust is unaffected — depth never feeds secure? — but the resolved IP is only as trustworthy as the lockdown.)

Before enabling depth, ensure the origin only accepts connections from the proxy tier (private networking, security groups, an authenticating header the proxy injects, etc.). If you can enumerate your proxies instead, prefer CIDR-walk.

Migrating from OneTimeSecret depth (ConfigureTrustedProxy)

If you are collapsing OneTimeSecret’s ClientIpHelpers / ConfigureTrustedProxy depth logic onto this resolver, note two intentional differences:

  • Off-by-one (Otto counts the peer). Otto’s chain is X-Forwarded-For plus REMOTE_ADDR, so it is one hop longer than OTS’s XFF-only chain. To resolve the same client, Otto’s depth must be one higher than the operator’s OTS depth:. When the YAML→Otto translator is built, map trusted_proxy_depth = ots_depth + 1 so existing depth: values keep their meaning. Keep a parity regression test on the OTS side to lock this mapping.

  • Stricter short-chain behavior (kept on purpose). When the chain is shorter than N + 1, Otto returns REMOTE_ADDR (the peer), whereas OTS returned the leftmost — spoofable — X-Forwarded-For entry. Otto’s behavior is the safer one and is not reconciled down to OTS; expect a short-chain request to resolve to the proxy peer rather than a forwarded value, and document this when migrating.

No action required for

  • Apps that pass trusted_proxies as CIDR ranges or Regexp.
  • Apps that read req.ip / req.client_ipaddress (now backed by the canonical value, same masked result).
  • Apps relying on IPv6 — IPv6 client resolution behind trusted proxies is now correct (it was previously truncated to the first hextet).
  • Apps that do not set trusted_proxy_depth (default nil → unchanged CIDR-walk behavior; depth mode is entirely opt-in).