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. Since the first release after 2.7.0 the key is tri-state: it is written only when proxy trust is actually configured (CIDR matchers or a depth), so a present key is authoritative in both directions and an absent key means “no proxy trust configured”. When the key is absent (standalone request use, or an unconfigured deployment), secure? falls back to the same decision the middleware would have implied: a CIDR check of the connecting peer — or, since the depth-mode peer-trust fix (#226, first release after 2.7.0), an unconditional grant when trusted_proxy_depth is configured. 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 (tri-state: may be absent) Peer trust decided before masking, written only when proxy trust is configured (first release after 2.7.0): true on a CIDR match — or unconditionally when depth mode is configured (#226); false means trust is configured and the peer failed it (authoritative deny). Absent = no proxy trust configured — the only case for consumer-side fallback heuristics. 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.

Depth grants peer trust (#226, first release after 2.7.0). Configuring a depth asserts that the connecting peer is your proxy tier — that is what the setting means — so depth mode records env['otto.via_trusted_proxy'] = true on every request, and Otto::Request#secure? (which consults that flag) honors a forwarded X-Forwarded-Proto / X-Scheme in depth mode. Releases up to and including 2.7.0 behaved differently: the flag was derived solely from the trusted_proxies CIDR identity check, which is always empty under depth (the modes are mutually exclusive), so it was recorded false and secure? reflected only a direct TLS connection (HTTPS=on / port 443). Because the grant is unconditional, the origin lockdown prerequisite below now covers proto trust exactly as it covers IP resolution. Geo headers are unaffected: they remain gated on enumerated trusted_proxies matchers and are still not trusted in depth mode (a hop trusted by count cannot be verified as a geo-setting CDN). As of the first release after 2.7.0 this carve-out fails loud instead of silently: configuring an ip-privacy geo_header together with a trusted_proxy_depth raises ArgumentError at configuration time (in either order, with a freeze-time backstop) — use filter mode for header-based geo, or a geo database (geo_db_path) under depth. Database- backed geo with depth remains fully supported.

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. (Since #226 this applies to proto trust too: depth grants otto.via_trusted_proxy, so a directly-reachable origin could spoof the scheme via X-Forwarded-Proto as well as the client IP.)

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:

  • Map depth values directly — do NOT add one. Otto’s chain is X-Forwarded-For plus REMOTE_ADDR, and the client is selected at chain[-(N+1)] — the appended peer is already accounted for by the index arithmetic. trusted_proxy_depth = N therefore means “N proxy hops, counting the connecting peer as hop 1”, which is exactly what the operator-facing OTS depth: N documents (“1 = standard single reverse proxy”). Map trusted_proxy_depth = ots_depth, and keep a parity regression test on the OTS side — including a padded-chain case asserting a forged leftmost X-Forwarded-For entry is never selected.

    Correction. Earlier revisions of this guide recommended trusted_proxy_depth = ots_depth + 1 to “keep existing depth: values’ meaning”. That reproduced an internal off-by-one of the deleted OTS walker (whose indexed path selected XFF[-(depth+1)], one position left of its own documented contract), not the operator-facing meaning. Under the +1 remap every honest documented-topology request hit the short-chain fallback and resolved the proxy address as the client, and a single forged leftmost X-Forwarded-For entry re-lengthened the chain so the forged value was selected. The direct mapping resolves the true client on honest chains and is padding-resistant.

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