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.1no longer matches192.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, andadd_trusted_proxynow logs a warning suggesting a CIDR (172.16.0.0/12). Regexpentries 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_fingerprintOtto::Request#geo_countryOtto::Request#hashed_ipOtto::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
additive — trusted_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 |
Bothprecedence is fallback, not merge. If theForwardedheader carries at least onefor=, only its chain is used andX-Forwarded-Foris ignored; otherwise theX-Forwarded-Forchain is used. The two chains are never concatenated. (Matches OTS’sextract_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 to2001:db8::1. Obfuscated (for=_hidden) andfor=unknownidentifiers still occupy a hop position but are not valid IPs, so if one is the selected hop the resolver falls back toREMOTE_ADDR. MultipleForwardedheaders (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_headeris 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 raisesArgumentErrorat 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-ForplusREMOTE_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 OTSdepth:. When the YAML→Otto translator is built, maptrusted_proxy_depth = ots_depth + 1so existingdepth: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 returnsREMOTE_ADDR(the peer), whereas OTS returned the leftmost — spoofable —X-Forwarded-Forentry. 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_proxiesas CIDR ranges orRegexp. - 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(defaultnil→ unchanged CIDR-walk behavior; depth mode is entirely opt-in).