Privacy-preserving request data

Otto's default request posture is to reduce the precision of public client information before application code, logging, authentication, rate limiting, and other middleware see it. The privacy pipeline is a Rack concern, not only a router feature: if an outer Rack middleware logs the request before Otto runs, place the privacy middleware in the common stack first.

Default behavior

With the default :masked profile:

  • public IP addresses are masked by the configured octet precision (one octet by default: 203.0.113.9 becomes 203.0.113.0);
  • requests from private and loopback addresses are exempt from the privacy fingerprint by default, so their IP, user agent, and referer remain unchanged;
  • for requests that are masked, user-agent version details are anonymized and referer query parameters are removed;
  • original public values are not retained in the Rack environment; and
  • country resolution is country-level only and returns ** when enabled but no configured source answers.

Downstream code should read req.ip, req.masked_ip, and the documented privacy environment keys rather than re-resolving an address from forwarded headers.

These profiles are technical data-minimization controls, not a compliance certification. Whether a deployment meets GDPR, CCPA, or another legal regime also depends on its purposes, notices, retention, access controls, vendors, and jurisdiction.

Profiles

Choose a named profile when the deployment posture should be obvious in review:

otto = Otto.new('routes')
otto.configure_ip_privacy(profile: :masked)     # default
# or:
otto.configure_ip_privacy(profile: :anonymous)  # mask private/localhost too
# or:
otto.configure_ip_privacy(profile: :audit)      # disable IP privacy
Profile Public IPs Private/localhost IPs Use when
:masked Masked Exempt by default General privacy-by-default deployments and local development.
:anonymous Masked Masked Internal addresses must also be treated as identifying data.
:audit Not masked Not masked The operator has deliberately accepted raw-IP retention and controls logs and downstream systems.

The :audit profile transfers retention responsibility to the operator. It is not a way to obtain precise matching while keeping privacy enabled; use env['otto.ip_match'] for that narrower need.

Configuration is boot-time only. The first request freezes configuration in normal operation, so set profiles, trusted proxies, database readers, and other privacy settings before serving traffic.

Privacy-safe request values

Inside a handler:

class Analytics
  def self.record(req, res)
    event = {
      ip: req.ip,
      country: req.geo_country,
      asn: req.asn,
      anonymizer: req.anonymizer,
      user_agent: req.user_agent,
    }

    AuditLog.write(event)
    res.status = 204
    res.body = []
  end
end

Common values are also available in the Rack environment:

Value Environment key Contract
Canonical client IP otto.client_ip Masked IP when masking applies; resolved full IP under :audit or for an exempt private/loopback request.
Precise CIDR verdict otto.ip_match Callable that checks the resolved full IP against CIDRs and returns only true or false.
Masked IP otto.privacy.masked_ip Set when the request runs through the privacy fingerprint; absent for exempt or :audit requests.
Rotating IP hash otto.privacy.hashed_ip Correlation value computed with Otto's rotating key; absent for exempt or :audit requests.
Stable correlation hash otto.privacy.correlation_hash HMAC value when correlation_secret: is configured; otherwise nil.
Country otto.privacy.geo_country ISO 3166-1 alpha-2 code, ** when enabled but unresolved, or nil when geo/privacy is disabled.
ASN otto.privacy.asn nil when off, ** when enabled but unresolved, or a value such as AS15169.
Anonymizer otto.privacy.anonymizer nil when off, ** when no database answers, or a classification label.

req.hashed_ip is designed for short-lived correlation using a rotating key. For long-lived correlation, explicitly configure a stable secret and protect that secret as sensitive configuration. Changing the secret changes every correlation hash.

Country, ASN, and anonymizer data

These signals have different trust and precision models:

  • Geo-country resolution documents trusted provider headers, CIDR trusted-proxy requirements, masked MMDB lookup, and the unknown sentinel.
  • ASN and anonymizer enrichment documents the opt-in database contracts. ASN uses a masked address; anonymizer classification deliberately uses the unmasked address internally and emits only a label.

Enable optional signals explicitly:

otto.configure_ip_privacy(
  geo: true,
  geo_db_path: 'data/country.mmdb',
  asn: true,
  asn_db_path: 'data/origin-asn.mmdb',
  anonymizer: true,
  anonymizer_db_path: 'data/anonymizer.mmdb'
)

The maxmind-db gem is optional and is required only when a database path is configured. A reader object responding to #get(ip) can be injected instead. Database paths are opened at configuration time; invalid paths fail during boot rather than on an arbitrary request.

Trusted proxy and matching boundaries

Configure trusted proxies before relying on forwarded client information:

otto = Otto.new(
  'routes',
  trusted_proxies: ['10.0.0.0/8', '192.0.2.0/24']
)

CIDR-based trust lets Otto verify the proxy peer. Count-based trusted_proxy_depth is a separate mode and does not make geo headers trustworthy. A configured geo_header combined with depth mode is rejected at configuration time; use a local database in depth-mode deployments instead.

The same trust decision also gates the forwarded host, scheme, and port headers that Rack::Request#host reads. See Forwarded host authority, including the explicit trusted_proxies: :none assertion for directly exposed applications.

For precise access control without exposing the address to application code, call the automatically installed env['otto.ip_match'] capability. It matches the resolved full client IP against application CIDRs and returns only true/false; it fails closed when no client IP resolves. Do not log or persist the closure.

allowed = req.env.fetch('otto.ip_match').call(['192.0.2.0/24', '2001:db8::/32'])

Middleware placement

When building a larger Rack stack, put privacy before components that log or inspect the request:

builder.use Otto::Security::Middleware::IPPrivacyMiddleware, otto.security_config
builder.use Rack::CommonLogger
builder.use Sentry::Rack::CaptureExceptions
# Then mount the Otto application.

Otto also installs its privacy middleware internally. The outer common-stack placement is needed when middleware outside the Otto app would otherwise see the raw peer first.

Pass otto.security_config as shown. The outer instance resolves otto.client_ip first and the inner one then short-circuits, so an outer instance constructed without the configuration would silently apply defaults instead of the application's profile, precision, correlation secret, and enrichment settings. Proxy trust is the exception: the inner instance re-applies its own trust posture even after an outer pass, and a CIDR configuration that can no longer see the connecting peer fails closed. See Forwarded host authority.

Operational rules

  • Treat nil, '**', 'none', and a real label as different states. In particular, anonymizer 'none' means the database was consulted and did not list the address; '**' means no database answered.
  • Do not use provider geo headers without a verifiable CIDR trusted proxy. A client can otherwise submit its own country.
  • Do not confuse the masked client IP with the raw peer used to authenticate a local network service. Integrations such as Caddy TLS have their own trust boundary.
  • Test logs and monitoring outside Otto's stack. Privacy is only effective if those components receive the masked environment first.