Class: Otto::Privacy::GeoResolver

Inherits:
Object
  • Object
show all
Defined in:
lib/otto/privacy/geo_resolver.rb

Overview

Lightweight geo-location resolution for IP addresses

Provides country-level geo-location. Headers from major CDN/infrastructure providers are checked first; an optional local MaxMind-format (.mmdb) database gives an offline fallback that operates on Otto’s already-MASKED IP (no external API calls, and the unmasked address never reaches the resolver).

Resolution order (first hit wins), when a privacy Config is supplied: 1. App-configured trusted header (Config#geo_header), e.g. ‘X-Client-Country’ 2. Built-in CDN/infrastructure provider headers (see below) 3. Custom resolver hook (GeoResolver.custom_resolver) 4. Local MMDB lookup, masked before lookup (Config#geo_db_reader) 5. ‘**’ (unknown)

Steps 1 and 2 are SKIPPED when the request’s geo headers are not trusted. Every geo header is client-spoofable unless you are actually behind the CDN that sets it, so the middleware trusts them only for a request that arrived via a configured trusted proxy; otherwise resolution falls straight to the custom resolver / database.

Resolution is HONEST: when no header, custom resolver, or database resolves a country, the answer is ‘**’ (unknown). Otto does not guess from a hardcoded IP-range table — configure a database or an edge header for real geo-location.

Supported CDN/Infrastructure Headers: - Cloudflare: CF-IPCountry - AWS CloudFront: CloudFront-Viewer-Country - Fastly: Fastly-Client-IP-Country - Akamai: X-Akamai-Edgescape (country_code=XX format) - Azure Front Door: X-Azure-ClientIP-Country - Vercel: X-Vercel-IP-Country - Semi-standard: X-Geo-Country, X-Country-Code, Country-Code

Resolution flow

Request → Headers trusted? ├─ Yes → Config#geo_header set & valid? → Return country │ └─ Provider header present & valid? → Return country └─ (headers skipped when not trusted) → Custom Resolver configured? ├─ Valid → Return country └─ Invalid/Error → Continue → Local MMDB reader configured? ├─ Hit → Return country └─ Miss → Unknown (‘**’)

Examples:

Resolve country from Cloudflare header

env = { 'HTTP_CF_IPCOUNTRY' => 'US' }
GeoResolver.resolve('1.2.3.4', env)
# => 'US'

Resolve from AWS CloudFront

env = { 'HTTP_CLOUDFRONT_VIEWER_COUNTRY' => 'GB' }
GeoResolver.resolve('1.2.3.4', env)
# => 'GB'

Resolve without any header, resolver, or database

GeoResolver.resolve('8.8.8.8', {})
# => '**' (unknown — Otto does not guess)

Using a custom resolver (MaxMind)

GeoResolver.custom_resolver = ->(ip, env) {
  reader = MaxMind::DB.new('GeoLite2-Country.mmdb')
  result = reader.get(ip)
  result&.dig('country', 'iso_code')
}
GeoResolver.resolve('1.2.3.4', {})  # Uses custom resolver

Extending via subclass

class MyGeoResolver < Otto::Privacy::GeoResolver
  def self.check_geo_database(ip, config)
    # Custom logic here
    super  # Fall back to parent
  end
end

Constant Summary collapse

UNKNOWN =

Unknown country code (not ISO 3166-1 alpha-2, intentionally distinct)

'**'
PRIMARY_COUNTRY_HEADERS =

Check CDN/infrastructure provider geo headers

Headers are checked in order of reliability and deployment frequency: 1. Cloudflare (CF-IPCountry) - Most widely deployed 2. AWS CloudFront (CloudFront-Viewer-Country) 3. Fastly (Fastly-Client-IP-Country) 4. Akamai (X-Akamai-Edgescape) - Complex format, extract country 5. Azure Front Door (X-Azure-ClientIP-Country) 6. Vercel (X-Vercel-IP-Country) 7. Semi-standard headers (X-Geo-Country, X-Country-Code, Country-Code)

Simple provider headers whose value is the country code directly, checked ahead of Akamai (whose value is a compound Edgescape string). Cloudflare first (most widely deployed), then AWS CloudFront, then Fastly.

%w[
  HTTP_CF_IPCOUNTRY
  HTTP_CLOUDFRONT_VIEWER_COUNTRY
  HTTP_FASTLY_CLIENT_IP_COUNTRY
].freeze
SECONDARY_COUNTRY_HEADERS =

Remaining direct country-code headers, checked after Akamai: Azure Front Door, Vercel, then the least-reliable semi-standard headers.

%w[
  HTTP_X_AZURE_CLIENTIP_COUNTRY
  HTTP_X_VERCEL_IP_COUNTRY
  HTTP_X_GEO_COUNTRY
  HTTP_X_COUNTRY_CODE
  HTTP_COUNTRY_CODE
].freeze

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.custom_resolverObject

Returns the value of attribute custom_resolver.



118
119
120
# File 'lib/otto/privacy/geo_resolver.rb', line 118

def custom_resolver
  @custom_resolver
end

Class Method Details

.resolve(ip, env = {}, config = nil, headers_trusted: true) ⇒ String

Resolve country code for an IP address.

Resolution order (first hit wins). Header steps (1–2) are skipped when +headers_trusted+ is false, since geo headers are client-spoofable unless the request actually arrived through the trusted CDN/proxy: 1. App-configured trusted header (+config.geo_header+) 2. Built-in CDN/infrastructure provider headers 3. Custom resolver hook (custom_resolver) 4. Local MMDB lookup (+config.geo_db_reader+), masked before lookup 5. ‘**’ for unknown (no guessing)

Country-level MMDB networks are almost always >= /24, so a /24-masked +x.y.z.0+ resolves to the same country as the real IP. The database lookup masks internally, and the Otto middleware additionally hands this method a masked IP and a masked env, so neither the database nor a custom resolver ever sees the unmasked address.

Parameters:

  • ip (String)

    IP address to resolve. When called from the Otto middleware this is already the masked IP; the database lookup masks again internally, so a direct caller passing a real IP still never exposes the unmasked address to the database.

  • env (Hash) (defaults to: {})

    Rack environment (may contain geo headers). In the framework path this is a masked view (REMOTE_ADDR/forwarded headers masked), so a custom resolver never sees the raw IP through env either.

  • config (Otto::Privacy::Config, nil) (defaults to: nil)

    privacy config supplying the configured header and MMDB reader. When nil, only the built-in provider headers and the custom resolver are consulted.

  • headers_trusted (Boolean) (defaults to: true)

    whether request geo headers may be trusted for this request (default true; the middleware computes this from the trusted-proxy decision).

Returns:

  • (String)

    ISO 3166-1 alpha-2 country code or ‘**’



168
169
170
171
172
173
174
# File 'lib/otto/privacy/geo_resolver.rb', line 168

def self.resolve(ip, env = {}, config = nil, headers_trusted: true)
  return UNKNOWN if ip.nil? || ip.empty?

  # Resolution is honest: when no header, custom resolver, or database
  # resolves a country, the answer is '**' (unknown) — never a guess.
  resolve_from_sources(ip, env, config, headers_trusted) || UNKNOWN
end