Geo-country resolution
Otto resolves a country-level ISO 3166-1 alpha-2 code for each request and
exposes it as req.geo_country / env['otto.privacy.geo_country']. Resolution
is country-only by design — that is the privacy posture; there is no city or
region lookup.
Resolution order
Otto::Privacy::GeoResolver.resolve returns the first hit from:
- Application-configured header (
geo_header:) — e.g.X-Client-Country. - Known provider headers — Cloudflare (
CF-IPCountry), AWS CloudFront, Fastly, Akamai Edgescape, Azure Front Door, Vercel (X-Vercel-IP-Country), and a few semi-standard names (X-Geo-Country,X-Country-Code,Country-Code). - Custom resolver (
GeoResolver.custom_resolver) — your own callable. Unlike the other geo settings, this is class-level (see Configuration). - Local MMDB database (
geo_db_path:/geo_db_reader:) — a MaxMind-DB country database. '**'— the unknown sentinel, when nothing else matches.
Steps 1 and 2 are only consulted when geo headers can be trusted (see Header trust below).
Resolution is honest: Otto does not guess from a hardcoded IP-range table.
When no header, custom resolver, or database resolves a country, the result is
'**'.
Privacy: masked IP and masked env
The database lookup in step 4 runs on the request’s masked IP
(e.g. 203.0.113.0), never the real address. check_geo_database masks the IP
internally with the config’s octet_precision before the lookup, so even a
direct GeoResolver.resolve caller passing a real IP does not expose it to the
database. Country-level MMDB networks are almost always ≥ /24, so the default
/24-masked value (octet_precision: 1) resolves to the same country.
In the middleware path Otto additionally hands resolve a masked env view:
REMOTE_ADDR, X-Forwarded-For, X-Real-IP, X-Client-IP, and the RFC 7239
Forwarded header are masked. So a custom_resolver cannot read the raw client
IP out of env either — use the ip argument (already masked), not env.
octet_precision: 2masks two octets (a /16). That is coarser than most country networks, so it can reduce database hit rate for the small share of countries whose ranges are finer than /16 — those requests fall through to'**'. Header and custom-resolver sources are unaffected (they ignore the IP). Keep the default precision if you rely on the MMDB fallback.
Configuration
All geo configuration is boot-time only (set once during single-threaded
initialization, before serving requests), matching custom_resolver’s
contract. geo_header, geo_db_path, and geo_db_reader are stored on the
instance’s Otto::Privacy::Config, so separate Otto instances hold independent
geo configuration.
custom_resolveris the exception — it is class-level, not per-instance.GeoResolver.custom_resolver=sets a singleton on theGeoResolverclass, so it is shared across every Otto instance in the process (last write wins). If you run multiple Otto instances that need different resolver strategies, the custom resolver cannot distinguish them — branch inside a single resolver onenv, or use per-instancegeo_db_readerinstead.
ruby
otto.configure_ip_privacy(
geo: true, # default; false disables geo entirely
geo_header: 'X-Client-Country', # trusted app header (optional)
geo_db_path: 'data/geo-whois-asn-country.mmdb', # local MMDB fallback (optional)
# geo_db_reader: MaxMind::DB.new(path), # or bring your own reader (optional)
)
geo: falseshort-circuits everything: no header reads, and any loaded database is unloaded from memory (req.geo_countrybecomesnil).geo_header:accepts either the HTTP header name (X-Client-Country) or the Rack CGI env key (HTTP_X_CLIENT_COUNTRY), in any case, and is canonicalized to the env-key form. Pass''to clear.geo_db_path:is loaded once at boot inMODE_MEMORY. An unreadable path, a corrupt/non-MMDB file, or a missingmaxmind-dbgem raisesArgumentErrorat configuration time, not per-request. Pass''to unload.geo_db_reader:injects any object responding to#get(ip)(a preconfiguredMaxMind::DBreader or a test double), keeping the reader and data-source choice independent of Otto. It overridesgeo_db_pathwhen both are given in the same call; supplyinggeo_db_pathalone in a later call clears a prior reader override.
Each keyword follows a nil = “leave unchanged” contract; pass '' to a header
or path to clear it. Any geo-affecting change triggers the boot-time database
(re)load, so a bad geo_db_path fails at the configure_ip_privacy call.
The database: gem and datafile
The reader and the data file are independent — the MMDB format is the interop point.
Reader gem (maxmind-db)
The maxmind-db gem (official MaxMind
reader, Apache-2.0, pure Ruby, zero runtime deps) is an optional
dependency. Otto only requires it when a database is configured. Add it to
your app when you use the database fallback:
ruby
# Gemfile
gem 'maxmind-db', '~> 1.4'
Data file (geo-whois-asn-country)
The recommended data file is
geo-whois-asn-country from
sapics/ip-location-db: PDDL v1.0 (public domain, no attribution required),
rebuilt daily, shipped as MMDB. Otto vendors no database — country data goes
stale, and a public-domain file you refresh on your own schedule keeps
licensing and freshness in your control.
Download it (IPv4+IPv6) into a path of your choosing:
bash
mkdir -p data
curl -fsSL -o data/geo-whois-asn-country.mmdb \
https://github.com/sapics/ip-location-db/releases/download/latest/geo-whois-asn-country.mmdb
Refresh it on your own schedule (e.g. a daily cron job running the same curl).
Any MMDB country database works — GeoLite2-Country, DB-IP Country Lite,
iplocate, etc. — since GeoResolver tolerates the record shapes country
databases actually use: nested country.iso_code (GeoLite2-Country style), a
flat country_code string, and a bare-string country.
Note on GeoLite2: its EULA requires a MaxMind account/license key and obliges consumers to refresh within 30 days of each release. A PDDL dataset avoids both obligations.
Header trust and spoofing
Every geo header is trivially client-spoofable unless the request actually
arrived through the CDN that sets it. Otto trusts geo headers — both the
configured geo_header and the provider headers — only for a request that
demonstrably arrived via a configured CIDR trusted proxy
(env['otto.via_trusted_proxy'] with trusted_proxies configured). A spoofed
header on a direct connection is ignored, and resolution falls through to the
custom resolver / database.
Origins Otto cannot verify are not trusted:
- No trusted-proxy configuration. A direct internet client could otherwise
pick its own country by sending
CF-IPCountry/X-Client-Country, so with notrusted_proxiesconfigured, header steps are skipped and resolution falls to the resolver / database ('**'if neither is set). - Count-based
trusted_proxy_depthmode. The header-setting hop cannot be verified as a geo-CDN, so depth mode does not enable header trust.
Migration: to keep header-based geo, configure trusted_proxies (CIDR
matchers) so Otto can verify the proxy origin. Depth-mode and header-only
deployments should set geo_db_path for a local database instead; otherwise
resolution returns '**'.
Acceptance behavior summary
| Scenario | Result |
|---|---|
Configured geo_header present and trusted |
wins over provider headers |
| Request not via a verified CIDR trusted proxy | geo headers skipped |
No trusted_proxies configured |
geo headers skipped (not trusted) |
| Database lookup | uses the masked IP only |
geo: false |
nil, no database in memory |
Bad geo_db_path |
raises at boot, not per-request |
| Nothing matches | '**' |