Class: Otto::Request

Inherits:
Rack::Request
  • Object
show all
Defined in:
lib/otto/request.rb

Overview

Otto’s enhanced Rack::Request class with built-in helpers

This class extends Rack::Request with Otto’s framework helpers for HTTP request handling, privacy, security, and locale management. Projects can register additional helpers via Otto#register_request_helpers.

Examples:

Using Otto’s request in route handlers

def show(req, res)
  req.masked_ip      # Privacy-safe masked IP
  req.geo_country    # ISO country code
  req.asn            # Network operator, when enabled
  req.anonymizer     # Tor/VPN/proxy label, when enabled
  req.check_locale!  # Set locale for request
end

See Also:

  • #register_request_helpers

Instance Method Summary collapse

Instance Method Details

#absolute_suri(host = current_server_name) ⇒ Object



242
243
244
245
# File 'lib/otto/request.rb', line 242

def absolute_suri(host = current_server_name)
  prefix = local? ? 'http://' : 'https://'
  [prefix, host, request_path].join
end

#ajax?Boolean

See: http://stackoverflow.com/questions/10013812/how-to-prevent-jquery-ajax-from-following-a-redirect-after-a-post

Returns:

  • (Boolean)


308
309
310
# File 'lib/otto/request.rb', line 308

def ajax?
  env['HTTP_X_REQUESTED_WITH'].to_s.downcase == 'xmlhttprequest'
end

#anonymized_user_agentString?

Deprecated.

Use env[‘HTTP_USER_AGENT’] directly (already anonymized when privacy enabled)

Get anonymized user agent string

Returns user agent with version numbers stripped for privacy. When privacy is enabled (default), env[‘HTTP_USER_AGENT’] is already anonymized by IPPrivacyMiddleware, so this just returns that value. When privacy is disabled, returns the raw user agent.

Examples:

req.anonymized_user_agent
# => 'Mozilla/X.X (Windows NT X.X; Win64; x64) AppleWebKit/X.X'

Returns:

  • (String, nil)

    Anonymized (or raw if privacy disabled) user agent



146
147
148
# File 'lib/otto/request.rb', line 146

def anonymized_user_agent
  user_agent
end

#anonymizerString?

Get the anonymizing-egress classification for the client address

Opt-in: nil unless the operator enabled classification. ‘none’ means the database was consulted and did not list the address; ‘’ means no database answered. Do not collapse the two — ‘none’ is evidence that the address is not a known egress, ‘’ is the absence of any evidence.

Examples:

req.anonymizer  # => 'tor'

Returns:

  • (String, nil)

    ‘tor’, ‘proxy’, ‘vpn’, ‘residential_proxy’, ‘hosting’, ‘anonymous’, ‘none’, ‘**’, or nil when disabled



130
131
132
# File 'lib/otto/request.rb', line 130

def anonymizer
  env['otto.privacy.anonymizer']
end

#app_path(*paths) ⇒ String

Build application path by joining path segments

This method safely joins multiple path segments, handling duplicate slashes and ensuring proper path formatting. Includes the script name (mount point) as the first segment.

Examples:

app_path('api', 'v1', 'users')
# => "/myapp/api/v1/users"
app_path(['admin', 'settings'])
# => "/myapp/admin/settings"

Parameters:

  • paths (Array<String>)

    Path segments to join

Returns:

  • (String)

    Properly formatted path



486
487
488
489
490
# File 'lib/otto/request.rb', line 486

def app_path(*paths)
  paths = paths.flatten.compact
  paths.unshift(env['SCRIPT_NAME']) if env['SCRIPT_NAME']
  paths.join('/').gsub('//', '/')
end

#asnString?

Get the Autonomous System Number of the client’s network

Opt-in: nil unless the operator enabled ASN resolution. ‘**’ means it is enabled but nothing resolved — distinguishable from “switched off”, which is what makes the three-state contract worth keeping.

Read straight from env rather than through the fingerprint. The fingerprint&.x || env[...] idiom used above treats any falsey value as “fall through”, which is wrong for a value whose vocabulary is fixed and whose absence is itself meaningful.

Examples:

req.asn  # => 'AS15169'

Returns:

  • (String, nil)

    ‘AS15169’, ‘**’, or nil when disabled



115
116
117
# File 'lib/otto/request.rb', line 115

def asn
  env['otto.privacy.asn']
end

#blocked_user_agent?(blocked_agents: []) ⇒ Boolean

Check if user agent matches blocked patterns

This method checks if the current request’s user agent string matches any of the provided blocked agent patterns.

Examples:

blocked_user_agent?([:bot, :crawler, 'BadAgent'])
# => false if user agent contains 'bot', 'crawler', or 'BadAgent'

Parameters:

  • blocked_agents (Array<String, Symbol, Regexp>) (defaults to: [])

    Patterns to check against

Returns:

  • (Boolean)

    true if user agent is allowed, false if blocked



454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
# File 'lib/otto/request.rb', line 454

def blocked_user_agent?(blocked_agents: [])
  return true if blocked_agents.empty?

  user_agent_string = user_agent.to_s.downcase
  return true if user_agent_string.empty?

  blocked_agents.flatten.any? do |agent|
    case agent
    when Regexp
      user_agent_string.match?(agent)
    else
      user_agent_string.include?(agent.to_s.downcase)
    end
  end
end

#check_locale!(locale = nil, opts = {}) ⇒ String

Set the locale for the request based on multiple sources

This method determines the locale to be used for the request by checking the following sources in order of precedence: 1. The locale parameter passed to the method 2. The locale query parameter in the request 3. The user’s saved locale preference (if provided) 4. The rack.locale environment variable

If a valid locale is found, it’s stored in the request environment. If no valid locale is found, the default locale is used.

Examples:

Basic usage

check_locale!(
  available_locales: { 'en' => 'English', 'es' => 'Spanish' },
  default_locale: 'en'
)
# => 'en'

With user preference

check_locale!(nil, {
  available_locales: { 'en' => 'English', 'es' => 'Spanish' },
  default_locale: 'en',
  preferred_locale: 'es'
})
# => 'es'

Using Otto-level configuration

# Otto configured with: Otto.new(routes, { locale_config: { available: {...}, default: 'en' } })
check_locale!('es')  # Uses Otto's config automatically
# => 'es'

Parameters:

  • locale (String, nil) (defaults to: nil)

    The locale to use, if specified

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

    Configuration options

Options Hash (opts):

  • :available_locales (Hash)

    Hash of available locales to validate against (required unless configured at Otto level)

  • :default_locale (String)

    Default locale to use as fallback (required unless configured at Otto level)

  • :preferred_locale (String, nil)

    User’s saved locale preference

  • :locale_env_key (String)

    Environment key to store the locale (default: ‘locale’)

  • :debug (Boolean)

    Enable debug logging for locale selection

Returns:

  • (String)

    The selected locale



533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
# File 'lib/otto/request.rb', line 533

def check_locale!(locale = nil, opts = {})
  # Get configuration from options, Otto config, or environment (in that order)
  otto_config = env['otto.locale_config']

  available_locales = opts[:available_locales] ||
                      otto_config&.dig(:available_locales) ||
                      env['otto.available_locales']
  default_locale    = opts[:default_locale] ||
                      otto_config&.dig(:default_locale) ||
                      env['otto.default_locale']
  preferred_locale  = opts[:preferred_locale]
  locale_env_key    = opts[:locale_env_key] || 'locale'
  debug_enabled     = opts[:debug] || false

  # Guard clause - required configuration must be present
  unless available_locales.is_a?(Hash) && !available_locales.empty? && default_locale && available_locales.key?(default_locale)
    raise ArgumentError,
          'available_locales must be a non-empty Hash and include default_locale (provide via opts or Otto configuration)'
  end

  # Check sources in order of precedence
  locale ||= env['rack.request.query_hash'] && env['rack.request.query_hash']['locale']
  locale ||= preferred_locale if preferred_locale
  locale ||= (env['rack.locale'] || []).first

  # Validate locale against available translations
  have_translations = locale && available_locales.key?(locale.to_s)

  # Debug logging if enabled
  if debug_enabled && defined?(Otto.logger)
    message = format(
      '[check_locale!] sources[param=%s query=%s user=%s rack=%s] valid=%s',
      locale,
      env.dig('rack.request.query_hash', 'locale'),
      preferred_locale,
      (env['rack.locale'] || []).first,
      have_translations
    )
    Otto.logger.debug message
  end

  # Set the locale in request environment
  selected_locale = have_translations ? locale : default_locale
  env[locale_env_key] = selected_locale

  selected_locale
end

#client_ipaddressObject



202
203
204
205
206
207
208
209
210
211
212
# File 'lib/otto/request.rb', line 202

def client_ipaddress
  # Prefer the canonical client IP resolved once by IPPrivacyMiddleware
  # ("resolve once, read everywhere"). Falls back to the shared resolver
  # (Otto::Utils.resolve_client_ip) for standalone use without the
  # middleware, so the with- and without-middleware paths agree on which
  # forwarded headers to trust and how to walk a proxy chain.
  canonical = env['otto.client_ip']
  return canonical if canonical && !canonical.empty?

  Otto::Utils.resolve_client_ip(env, otto_security_config)
end

#collect_proxy_headers(header_prefix: nil, additional_keys: []) ⇒ String

Collect and format HTTP header details from the request environment

This method extracts and formats specific HTTP headers, including Cloudflare and proxy-related headers, for logging and debugging purposes.

Examples:

Basic usage

collect_proxy_headers
# => "X-Forwarded-For: 203.0.113.195 Remote-Addr: 192.0.2.1"

With custom prefix

collect_proxy_headers(header_prefix: 'X_CUSTOM_')
# => "X-Forwarded-For: 203.0.113.195 X-Custom-Token: abc123"

Parameters:

  • header_prefix (String, nil) (defaults to: nil)

    Custom header prefix to include (e.g. ‘X_SECRET_’)

  • additional_keys (Array<String>) (defaults to: [])

    Additional header keys to collect

Returns:

  • (String)

    Formatted header details as “key: value” pairs



384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# File 'lib/otto/request.rb', line 384

def collect_proxy_headers(header_prefix: nil, additional_keys: [])
  keys = %w[
    HTTP_FLY_REQUEST_ID
    HTTP_VIA
    HTTP_X_FORWARDED_PROTO
    HTTP_X_FORWARDED_FOR
    HTTP_X_FORWARDED_HOST
    HTTP_X_FORWARDED_PORT
    HTTP_X_SCHEME
    HTTP_X_REAL_IP
    HTTP_CF_IPCOUNTRY
    HTTP_CF_RAY
    REMOTE_ADDR
  ]

  # Add any header that begins with the specified prefix
  if header_prefix
    prefix_keys = env.keys.select { _1.upcase.start_with?("HTTP_#{header_prefix.upcase}") }
    keys.concat(prefix_keys)
  end

  # Add any additional keys requested
  keys.concat(additional_keys) if additional_keys.any?

  keys.sort.filter_map do |key|
    value = env[key]
    next unless value

    # Normalize the header name to look like browser dev console
    # e.g. Content-Type instead of HTTP_CONTENT_TYPE
    pretty_name = key.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-')
    "#{pretty_name}: #{value}"
  end.join(' ')
end


312
313
314
# File 'lib/otto/request.rb', line 312

def cookie(name)
  cookies[name.to_s]
end

#cookie?(name) ⇒ Boolean

Returns:

  • (Boolean)


316
317
318
# File 'lib/otto/request.rb', line 316

def cookie?(name)
  !cookie(name).to_s.empty?
end

#csp_nonceString

Framework-owned, request-scoped CSP nonce, generated lazily on first access and memoized into the request env. Views call this to stamp nonce="…" onto their inline <script>/<link> tags; the same value is what Security::CSP::EmitMiddleware writes into the script-src 'nonce-…' header — so the header and the views agree structurally, not by convention. An untouched request generates nothing.

The env key is configurable via Security::Config#csp_nonce_key.

Returns:

  • (String)

    this request’s nonce (base64)



39
40
41
# File 'lib/otto/request.rb', line 39

def csp_nonce
  Otto::Security::CSP.nonce(env)
end

#current_absolute_uriObject



320
321
322
323
# File 'lib/otto/request.rb', line 320

def current_absolute_uri
  prefix = secure? && !local? ? 'https://' : 'http://'
  [prefix, http_host, request_path].join
end

#current_serverObject



218
219
220
# File 'lib/otto/request.rb', line 218

def current_server
  [current_server_name, env['SERVER_PORT']].join(':')
end

#current_server_nameObject



222
223
224
# File 'lib/otto/request.rb', line 222

def current_server_name
  env['SERVER_NAME']
end

#format_request_details(header_prefix: nil) ⇒ String

Format request details as a single string for logging

This method combines IP address, HTTP method, path, query parameters, and proxy header details into a single formatted string suitable for logging.

Examples:

format_request_details
# => "192.0.2.1; GET /path?query=string; Proxy[X-Forwarded-For: 203.0.113.195 Remote-Addr: 192.0.2.1]"

Parameters:

  • header_prefix (String, nil) (defaults to: nil)

    Custom header prefix for proxy headers

Returns:

  • (String)

    Formatted request details



431
432
433
434
435
436
437
438
439
440
441
# File 'lib/otto/request.rb', line 431

def format_request_details(header_prefix: nil)
  header_details = collect_proxy_headers(header_prefix: header_prefix)

  details = [
    client_ipaddress,
    "#{request_method} #{env['PATH_INFO']}?#{env['QUERY_STRING']}",
    "Proxy[#{header_details}]",
  ]

  details.join('; ')
end

#forwarded_by_trusted_proxy?Boolean

Whether the request arrived through a trusted proxy.

Prefers the canonical decision recorded once by IPPrivacyMiddleware in env[‘otto.via_trusted_proxy’] — evaluated against the original peer before REMOTE_ADDR is masked, so it stays correct even after masking. The key is tri-state: written only when proxy trust is configured, so its absence covers both “middleware not mounted” (standalone request use) and “no proxy trust configured” — the fallback below answers both by evaluating the config directly against the current REMOTE_ADDR (an unconfigured config yields false, matching what the middleware would have implied).

A peer earns trust two ways (#226): its identity matches a configured trusted-proxy CIDR (filter mode), or count-based depth mode is active — configuring a depth asserts the connecting peer is the operator’s (non-enumerable) proxy tier. The fallback mirrors the grant IPPrivacyMiddleware records so the two paths cannot disagree.

Returns:

  • (Boolean)


297
298
299
300
301
302
303
304
305
# File 'lib/otto/request.rb', line 297

def forwarded_by_trusted_proxy?
  return env['otto.via_trusted_proxy'] if env.key?('otto.via_trusted_proxy')

  config = otto_security_config
  return false unless config
  return true if config.trusted_proxy_depth_mode?

  trusted_proxy?(env['REMOTE_ADDR'])
end

#geo_countryString?

Get the geo-location country code for the request

Returns ISO 3166-1 alpha-2 country code or ‘XX’ for unknown. Only available when IP privacy is enabled (default).

Examples:

req.geo_country  # => 'US'

Returns:

  • (String, nil)

    Country code or nil if privacy disabled



97
98
99
# File 'lib/otto/request.rb', line 97

def geo_country
  redacted_fingerprint&.country || env['otto.privacy.geo_country']
end

#hashed_ipString?

Get hashed IP for session correlation

Returns daily-rotating hash of the IP address, allowing session tracking without storing the original IP. Only available when IP privacy is enabled (default).

Examples:

req.hashed_ip  # => 'a3f8b2c4d5e6f7...'

Returns:

  • (String, nil)

    Hexadecimal hash string or nil



171
172
173
# File 'lib/otto/request.rb', line 171

def hashed_ip
  redacted_fingerprint&.hashed_ip || env['otto.privacy.hashed_ip']
end

#http_hostObject



226
227
228
# File 'lib/otto/request.rb', line 226

def http_host
  env['HTTP_HOST']
end

#ipString?

Canonical client IP for the request.

Prefers env[‘otto.client_ip’] — the value resolved once, early, by IPPrivacyMiddleware (“resolve once, read everywhere”): the masked IP when privacy is enabled, or the resolved real IP when privacy is disabled or the address is exempt. This means downstream code no longer depends on REMOTE_ADDR / X-Forwarded-For rewriting being load-bearing.

Falls back to Rack’s native resolution when the middleware has not run (e.g. standalone request use without the Otto middleware stack).

Returns:

  • (String, nil)

    Canonical (privacy-applied) client IP



55
56
57
58
59
60
# File 'lib/otto/request.rb', line 55

def ip
  canonical = env['otto.client_ip']
  return canonical if canonical && !canonical.empty?

  super
end

#ip_correlation_hashString?

Get the stable-keyed correlation hash of the client IP.

Contrast with #hashed_ip: that value is keyed with a daily-rotating secret (great for correlating requests within a session, useless across days). This value is HMAC-SHA256 over the SAME full, pre-masking client IP but keyed with a caller-configured STABLE secret, so the same IP produces the same hash indefinitely — the granularity long-lived audit records need without ever handling the raw IP.

Both are computed before masking, so both reflect the per-host address (not the /24 the app is otherwise left with); the raw IP itself never reaches the application — only the hash does.

Returns nil when IP privacy is disabled, no correlation secret is configured (see Otto#configure_ip_privacy(correlation_secret:)), or the client IP is exempt from masking. By default private/localhost IPs are exempt (mask_private_ips is false), so this is nil for RFC-1918 and loopback addresses — including the common local dev path — just like #masked_ip and #hashed_ip. It targets public audit-trail traffic.

Examples:

req.ip_correlation_hash  # => 'b7e2...'  (stable across days)

Returns:

  • (String, nil)

    Hexadecimal HMAC-SHA256 hash string or nil



198
199
200
# File 'lib/otto/request.rb', line 198

def ip_correlation_hash
  env['otto.privacy.correlation_hash']
end

#local?Boolean

Returns:

  • (Boolean)


247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/otto/request.rb', line 247

def local?
  return false unless Otto.env?(:dev, :development)

  ip = client_ipaddress
  return false unless ip

  # Check both IP and server name for comprehensive localhost detection
  server_name        = env['SERVER_NAME']
  local_server_names = ['localhost', '127.0.0.1', '0.0.0.0']

  local_or_private_ip?(ip) && local_server_names.include?(server_name)
end

#local_or_private_ip?(ip) ⇒ Boolean

Returns:

  • (Boolean)


356
357
358
359
360
361
362
363
364
365
# File 'lib/otto/request.rb', line 356

def local_or_private_ip?(ip)
  return false unless ip

  # Fast path for the common localhost cases (avoids IPAddr allocation);
  # private_ip? would also catch these via IPAddr#loopback?.
  return true if ['127.0.0.1', '::1'].include?(ip)

  # Check for private IP ranges
  private_ip?(ip)
end

#masked_ipString?

Get masked IP address

Returns privacy-safe masked IP. When privacy is enabled (default), this returns the masked version. When disabled, returns original IP.

Examples:

req.masked_ip  # => '192.168.1.0'

Returns:

  • (String, nil)

    Masked or original IP address



158
159
160
# File 'lib/otto/request.rb', line 158

def masked_ip
  env['otto.privacy.masked_ip'] || env['REMOTE_ADDR']
end

#otto_security_configObject



325
326
327
328
329
330
331
332
# File 'lib/otto/request.rb', line 325

def otto_security_config
  # Try to get security config from various sources
  if respond_to?(:otto) && otto.respond_to?(:security_config)
    otto.security_config
  elsif defined?(Otto) && Otto.respond_to?(:security_config)
    Otto.security_config
  end
end

#private_ip?(ip) ⇒ Boolean

Whether the given address is non-public (private, loopback, link-local, multicast or unspecified). IPv4 and IPv6 aware via Otto::Utils.private_ip? — the previous implementation was an IPv4-only regex that silently treated every IPv6 address (including ::1 and ULA fc00::/7) as public.

Parameters:

  • ip (String, IPAddr, nil)

    address to classify

Returns:

  • (Boolean)


352
353
354
# File 'lib/otto/request.rb', line 352

def private_ip?(ip)
  Otto::Utils.private_ip?(ip)
end

#redacted_fingerprintOtto::Privacy::RedactedFingerprint?

Get the privacy-safe fingerprint for this request

Returns nil if IP privacy is disabled. The fingerprint contains anonymized request information suitable for logging and analytics.

Examples:

fingerprint = req.redacted_fingerprint
fingerprint.masked_ip    # => '192.168.1.0'
fingerprint.country      # => 'US'

Returns:



85
86
87
# File 'lib/otto/request.rb', line 85

def redacted_fingerprint
  env['otto.privacy.fingerprint']
end

#request_methodObject



214
215
216
# File 'lib/otto/request.rb', line 214

def request_method
  env['REQUEST_METHOD']
end

#request_pathObject



230
231
232
# File 'lib/otto/request.rb', line 230

def request_path
  env['REQUEST_PATH']
end

#request_uriObject



234
235
236
# File 'lib/otto/request.rb', line 234

def request_uri
  env['REQUEST_URI']
end

#root_pathObject



238
239
240
# File 'lib/otto/request.rb', line 238

def root_path
  env['SCRIPT_NAME']
end

#secure?Boolean

Returns:

  • (Boolean)


260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/otto/request.rb', line 260

def secure?
  # Check direct HTTPS connection
  return true if env['HTTPS'] == 'on' || env['SERVER_PORT'] == '443'

  # rack.url_scheme is server-/middleware-set (never a client header), so a
  # scheme normalized upstream the canonical Rack way counts as authoritative
  # — keeping this answer aligned with Rack::Request#scheme, which the
  # session Secure-cookie gate and CSRF middleware read.
  return true if env['rack.url_scheme'] == 'https'

  # Only trust forwarded proto headers when the request actually arrived via
  # a trusted proxy. Stricter than Rack::Request#scheme, which honors
  # X-Forwarded-Proto unconditionally.
  return false unless forwarded_by_trusted_proxy?

  # X-Scheme is set by nginx; X-Forwarded-Proto by elastic load balancer
  env['HTTP_X_FORWARDED_PROTO'] == 'https' || env['HTTP_X_SCHEME'] == 'https'
end

#trusted_proxy?(ip) ⇒ Boolean

Returns:

  • (Boolean)


334
335
336
337
338
339
# File 'lib/otto/request.rb', line 334

def trusted_proxy?(ip)
  config = otto_security_config
  return false unless config

  config.trusted_proxy?(ip)
end

#user_agentObject



25
26
27
# File 'lib/otto/request.rb', line 25

def user_agent
  env['HTTP_USER_AGENT']
end

#validate_ip_address(ip) ⇒ Object



341
342
343
# File 'lib/otto/request.rb', line 341

def validate_ip_address(ip)
  Otto::Utils.normalize_ip(ip)
end