Class: Otto::Security::Config

Inherits:
Object
  • Object
show all
Includes:
Core::Freezable
Defined in:
lib/otto/security/config.rb

Overview

Security configuration for Otto applications

This class manages all security-related settings including CSRF protection, input validation, trusted proxies, and security headers. Security features are disabled by default for backward compatibility.

Examples:

Basic usage

config = Otto::Security::Config.new
config.enable_csrf_protection!
config.add_trusted_proxy('10.0.0.0/8')

Custom limits

config = Otto::Security::Config.new
config.max_request_size = 5 * 1024 * 1024  # 5MB
config.max_param_depth = 16

Defined Under Namespace

Classes: SecurityHeaders

Constant Summary collapse

REFERRER_POLICIES =

Otto accepts exactly one W3C Referrer Policy token for its referrer_policy setting. The supported tokens are enumerated below: https://www.w3.org/TR/referrer-policy/#referrer-policy-header-dfn

%w[
  no-referrer
  no-referrer-when-downgrade
  strict-origin
  strict-origin-when-cross-origin
  same-origin
  origin
  origin-when-cross-origin
  unsafe-url
].freeze
DEFAULT_REFERRER_POLICY =
'strict-origin-when-cross-origin'
PROXY_MODE_CONFLICT_MESSAGE =

Trusted-proxy error messages and values, owned by TrustedProxyConfig and aliased here because callers have always referenced them on Config.

TrustedProxyConfig::PROXY_MODE_CONFLICT_MESSAGE
TRUST_NO_PROXIES_CONFLICT_MESSAGE =
TrustedProxyConfig::TRUST_NO_PROXIES_CONFLICT_MESSAGE
TRUST_NO_PROXIES_ENTRY_MESSAGE =
TrustedProxyConfig::TRUST_NO_PROXIES_ENTRY_MESSAGE
FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE =
TrustedProxyConfig::FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE
TRUST_NO_PROXIES =
TrustedProxyConfig::TRUST_NO_PROXIES
TRUSTED_PROXY_HEADERS =
TrustedProxyConfig::HEADERS
DEFAULT_TRUSTED_PROXY_HEADER =
TrustedProxyConfig::DEFAULT_HEADER
GEO_HEADER_DEPTH_CONFLICT_MESSAGE =

Error raised when an app-configured trusted geo header (ip_privacy geo_header) is combined with count-based depth mode. Geo headers are honored only for peers matching enumerated trusted_proxies CIDRs (geo_headers_trusted? gates on trusted_proxies_configured?) — a hop trusted by count cannot be verified as the geo-setting CDN — so a geo_header configured alongside a depth could never be consulted. Failing loud at config time replaces a silent database/'**' fallback at request time.

"Cannot configure a trusted geo header (ip_privacy geo_header) together\nwith trusted_proxy_depth (count mode): geo headers are only honored\nfor peers matching enumerated trusted_proxies CIDRs, so the header\nwould be silently ignored. Use filter mode (add_trusted_proxy) for\nheader-based geo, or drop geo_header and use database-backed geo\n(geo_db_path or geo_db_reader).\n".gsub(/\s+/, ' ').strip.freeze
RACK_REQUEST =

Rack uses one process-global priority for forwarded host, port, scheme, and IP resolution. Keep it aligned with Otto's configured forwarding family so the two request views cannot silently disagree.

::Rack::Request
DEFAULT_RACK_FORWARDED_PRIORITY =
RACK_REQUEST.forwarded_priority.dup.freeze
RACK_FORWARDED_PRIORITIES =
{
  'X-Forwarded-For' => [:x_forwarded].freeze,
  'Forwarded' => [:forwarded].freeze,
  'Both' => i[forwarded x_forwarded].freeze,
}.freeze
FORWARDING_FAMILY_CONFLICT_MESSAGE =
"Cannot use forwarding family %s (trusted_proxy_header) because another\nOtto application in this process already uses %s. Rack's forwarded\nhost, port, scheme, and IP policy is process-global, so every Otto\napplication in one process that resolves proxied requests must use the\nsame forwarding family. A test suite that builds applications with\ndifferent families must clear this between tests: require\n'otto/testing' and call Otto::Testing.reset!.\n".gsub(/\s+/, ' ').strip.freeze
CSP_REPORTING_GROUP =

Endpoint group name shared by the CSP report-to directive and the Reporting-Endpoints response header (modern Reporting API). Browsers match the directive's group to the header's key, so both must agree. Aliases Otto::Security::CSP::Policy::REPORTING_GROUP — the one source the policy builder uses — so the header and the directive cannot drift.

Otto::Security::CSP::Policy::REPORTING_GROUP
CSRF_SECRET_REQUIRED_MESSAGE =

Error raised when CSRF protection is enabled in production without an explicitly configured secret. A randomly-generated per-process secret silently breaks token verification across workers and restarts, so we refuse it in production rather than serve intermittently-failing tokens.

"CSRF protection is enabled in production without a configured secret.\nSet OTTO_CSRF_SECRET (or config.csrf_secret=) to a stable random value\n(e.g. SecureRandom.hex(32)); a per-process random secret is not valid\nacross workers or restarts.\n".gsub(/\s+/, ' ').strip.freeze

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize ⇒ Config

Initialize security configuration with safe defaults

All security features are disabled by default to maintain backward compatibility with existing Otto applications.



413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'lib/otto/security/config.rb', line 413

def initialize
  @csrf_protection        = false
  @csrf_token_key         = '_csrf_token'
  @csrf_header_key        = 'HTTP_X_CSRF_TOKEN'
  @csrf_session_key       = '_csrf_session_id'
  @max_request_size       = 10 * 1024 * 1024 # 10MB
  @max_param_depth        = 32
  @max_param_keys         = 64
  @trusted_proxy_config   = TrustedProxyConfig.new
  @require_secure_cookies = false
  @security_headers       = SecurityHeaders.new(method(:validate_referrer_policy!))
  @security_headers.merge!(default_security_headers)
  @input_validation       = true
  @csp_nonce_enabled      = false
  @debug_csp              = false
  @csp_nonce_key          = 'otto.nonce'
  @csp_policy             = nil
  @csp_report_uri         = nil
  @csp_report_to_url      = nil
  @csp_violation_callback = nil
  @csp_directive_overrides = {}
  @csp_request_extras_enabled = false
  @csp_script_src_override_warned = false
  @rate_limiting_config   = { custom_rules: {} }
  @ip_privacy_config      = Otto::Privacy::Config.new

  configured_secret      = ENV.fetch('OTTO_CSRF_SECRET', nil)
  @csrf_secret_generated = configured_secret.nil? || configured_secret.empty?
  @csrf_secret           = @csrf_secret_generated ? SecureRandom.hex(32) : configured_secret
end

Class Attribute Details

.rack_forwarding_family ⇒ String? (readonly)

The forwarding family explicitly committed for this process, or nil.

Returns:

  • (String, nil)


345
346
347
# File 'lib/otto/security/config.rb', line 345

def rack_forwarding_family
  @rack_forwarding_family
end

Instance Attribute Details

#csp_directive_overrides ⇒ Object

Returns the value of attribute csp_directive_overrides.



401
402
403
# File 'lib/otto/security/config.rb', line 401

def csp_directive_overrides
  @csp_directive_overrides
end

#csp_nonce_enabled ⇒ Object (readonly)

Returns the value of attribute csp_nonce_enabled.



401
402
403
# File 'lib/otto/security/config.rb', line 401

def csp_nonce_enabled
  @csp_nonce_enabled
end

#csp_nonce_key ⇒ Object

Returns the value of attribute csp_nonce_key.



401
402
403
# File 'lib/otto/security/config.rb', line 401

def csp_nonce_key
  @csp_nonce_key
end

#csp_report_to_url ⇒ Object

Returns the value of attribute csp_report_to_url.



401
402
403
# File 'lib/otto/security/config.rb', line 401

def csp_report_to_url
  @csp_report_to_url
end

#csp_report_uri ⇒ Object

Returns the value of attribute csp_report_uri.



401
402
403
# File 'lib/otto/security/config.rb', line 401

def csp_report_uri
  @csp_report_uri
end

#csp_request_extras_enabled ⇒ Object (readonly)

Returns the value of attribute csp_request_extras_enabled.



401
402
403
# File 'lib/otto/security/config.rb', line 401

def csp_request_extras_enabled
  @csp_request_extras_enabled
end

#csp_violation_callback ⇒ Object (readonly)

Returns the value of attribute csp_violation_callback.



401
402
403
# File 'lib/otto/security/config.rb', line 401

def csp_violation_callback
  @csp_violation_callback
end

#csrf_header_key ⇒ Object (readonly)

Returns the value of attribute csrf_header_key.



401
402
403
# File 'lib/otto/security/config.rb', line 401

def csrf_header_key
  @csrf_header_key
end

#csrf_protection ⇒ Object (readonly)

Returns the value of attribute csrf_protection.



401
402
403
# File 'lib/otto/security/config.rb', line 401

def csrf_protection
  @csrf_protection
end

#csrf_session_key ⇒ Object

Returns the value of attribute csrf_session_key.



397
398
399
# File 'lib/otto/security/config.rb', line 397

def csrf_session_key
  @csrf_session_key
end

#csrf_token_key ⇒ Object

Returns the value of attribute csrf_token_key.



397
398
399
# File 'lib/otto/security/config.rb', line 397

def csrf_token_key
  @csrf_token_key
end

#debug_csp ⇒ Object (readonly)

Returns the value of attribute debug_csp.



401
402
403
# File 'lib/otto/security/config.rb', line 401

def debug_csp
  @debug_csp
end

#input_validation ⇒ Object

Returns the value of attribute input_validation.



397
398
399
# File 'lib/otto/security/config.rb', line 397

def input_validation
  @input_validation
end

#ip_privacy_config ⇒ Object (readonly)

Returns the value of attribute ip_privacy_config.



401
402
403
# File 'lib/otto/security/config.rb', line 401

def ip_privacy_config
  @ip_privacy_config
end

#max_param_depth ⇒ Object

Returns the value of attribute max_param_depth.



397
398
399
# File 'lib/otto/security/config.rb', line 397

def max_param_depth
  @max_param_depth
end

#max_param_keys ⇒ Object

Returns the value of attribute max_param_keys.



397
398
399
# File 'lib/otto/security/config.rb', line 397

def max_param_keys
  @max_param_keys
end

#max_request_size ⇒ Object

Returns the value of attribute max_request_size.



397
398
399
# File 'lib/otto/security/config.rb', line 397

def max_request_size
  @max_request_size
end

#mcp_auth ⇒ Object

Returns the value of attribute mcp_auth.



401
402
403
# File 'lib/otto/security/config.rb', line 401

def mcp_auth
  @mcp_auth
end

#rate_limiting_config ⇒ Object

Returns the value of attribute rate_limiting_config.



397
398
399
# File 'lib/otto/security/config.rb', line 397

def rate_limiting_config
  @rate_limiting_config
end

#require_secure_cookies ⇒ Object (readonly)

Returns the value of attribute require_secure_cookies.



401
402
403
# File 'lib/otto/security/config.rb', line 401

def require_secure_cookies
  @require_secure_cookies
end

#security_headers ⇒ Object (readonly)

Returns the value of attribute security_headers.



401
402
403
# File 'lib/otto/security/config.rb', line 401

def security_headers
  @security_headers
end

Class Method Details

.apply_rack_forwarding_family!(config, header, claim: true) ⇒ Object

Pin Rack's process-global forwarding family to Otto's.

Rack::Request.forwarded_priority is one setting per process, so two Otto applications that commit to different families cannot coexist: the later commitment raises. A config commits (claim: true) when the operator sets trusted_proxy_header or configures proxy trust at all (trusted_proxies or a depth): an app that resolves proxied requests from X-Forwarded-For depends on Rack reading the same family, even though it never named one. A config may revise its own commitment before it freezes, as long as no other config has committed to the previous family.

A config with no proxy trust and no explicit header (claim: false, a bare Otto.new) is indifferent: it pins Rack to the default only while nothing is committed and otherwise defers, so a no-options sub-mount constructed first cannot block a later trusted_proxy_header: 'Forwarded' with an error naming a family nobody chose.

Committed configs are held strongly on purpose. A weak registry made the conflict check depend on whether the earlier config had been garbage collected, so boot order and GC timing decided whether the app raised. Otto#initialize releases the claim when construction fails after the config committed (see .release_rack_forwarding_family!).

Parameters:

  • config (Otto::Security::Config) —

    the config applying the family

  • header (String) —

    canonical TRUSTED_PROXY_HEADERS value

  • claim (Boolean) (defaults to: true) —

    whether this config depends on the family

Raises:

  • (ArgumentError) —

    on a conflict with another committed config



309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/otto/security/config.rb', line 309

def apply_rack_forwarding_family!(config, header, claim: true)
  RACK_FORWARDING_MUTEX.synchronize do
    if claim
      committed = rack_forwarding_family
      if committed && committed != header &&
         rack_forwarding_owners.each_key.any? { |owner| !owner.equal?(config) }
        raise ArgumentError, format(FORWARDING_FAMILY_CONFLICT_MESSAGE, header, committed)
      end

      rack_forwarding_owners[config] = true
      @rack_forwarding_family = header
    elsif rack_forwarding_family
      # A committed choice already governs Rack; the default defers.
      next
    end

    RACK_REQUEST.forwarded_priority = RACK_FORWARDED_PRIORITIES.fetch(header).dup
  end
end

.release_rack_forwarding_family!(config) ⇒ void

This method returns an undefined value.

Withdraw a config's commitment, e.g. when Otto.new fails after the config committed. Rack's priority is left as-is: it is either still backed by another owner or will be re-pinned by the next app.

Parameters:



335
336
337
338
339
340
# File 'lib/otto/security/config.rb', line 335

def release_rack_forwarding_family!(config)
  RACK_FORWARDING_MUTEX.synchronize do
    rack_forwarding_owners.delete(config)
    @rack_forwarding_family = nil if rack_forwarding_owners.empty?
  end
end

.reset_rack_forwarding_family_for_testing! ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Clear process-global forwarding state between isolated RSpec examples. Otto::Testing.reset! does the same under any test framework.



351
352
353
354
355
356
357
358
# File 'lib/otto/security/config.rb', line 351

def reset_rack_forwarding_family_for_testing!
  unless defined?(RSpec)
    raise 'reset_rack_forwarding_family_for_testing! is only available in RSpec test environment; ' \
          "outside RSpec, require 'otto/testing' and call Otto::Testing.reset!"
  end

  reset_rack_forwarding_family!
end

.trust_no_proxies_option?(value) ⇒ Boolean

Whether a trusted_proxies option value is the trust-nobody sentinel (:none, or 'none' in any case). See TrustedProxyConfig.trust_no_proxies_option?.

Parameters:

  • value (Object) —

    raw trusted_proxies option

Returns:

  • (Boolean)


251
252
253
# File 'lib/otto/security/config.rb', line 251

def self.trust_no_proxies_option?(value)
  TrustedProxyConfig.trust_no_proxies_option?(value)
end

Instance Method Details

#add_trusted_proxy(proxy) ⇒ void

This method returns an undefined value.

Add a trusted proxy server for accurate client IP detection

Only requests from trusted proxies will have their X-Forwarded-For and similar headers honored for IP detection. This prevents IP spoofing from untrusted sources.

Mutually exclusive with count-based depth, with the trust-nobody assertion, and with a trusted_proxy_header other than X-Forwarded-For; each conflict raises here rather than only at freeze (which the test harness skips). A list is validated whole before any entry is registered. See TrustedProxyConfig#add.

Examples:

Add single proxy

config.add_trusted_proxy('10.0.0.1')

Add CIDR range

config.add_trusted_proxy('192.168.0.0/16')

Add multiple proxies

config.add_trusted_proxy(['10.0.0.1', '172.16.0.0/12'])

Parameters:

  • proxy (String, Regexp, Array) —

    IP address, CIDR range, Regexp, or array of these

Raises:

  • (ArgumentError) —

    if proxy is not a String, Regexp, or Array, or on a conflict

  • (FrozenError) —

    if configuration is frozen



534
535
536
537
538
# File 'lib/otto/security/config.rb', line 534

def add_trusted_proxy(proxy)
  ensure_not_frozen!

  @trusted_proxy_config.add(proxy)
end

#apply_default_rack_forwarding_family! ⇒ void

This method returns an undefined value.

Align Rack's forwarding family with this config's current value. Used by Otto.new when no trusted_proxy_header option was given, so a bare app still pins Rack to X-Forwarded-For instead of inheriting Rack's process-global default. The pin is a commitment only when this config already depends on the family (proxy trust configured); otherwise it defers to any commitment made elsewhere in the process.

Raises:

  • (FrozenError) —

    if configuration is frozen



706
707
708
709
710
# File 'lib/otto/security/config.rb', line 706

def apply_default_rack_forwarding_family!
  ensure_not_frozen!

  self.class.apply_rack_forwarding_family!(self, trusted_proxy_header, claim: forwarding_family_dependent?)
end

#commit_rack_forwarding_family! ⇒ void

This method returns an undefined value.

Commit this config's current family process-wide once it depends on one (proxy trust configured). Not called from the trusted_proxies / depth setters themselves, so assignment order relative to trusted_proxy_header= cannot raise a spurious conflict; instead it runs at the configuration boundaries: Otto.new (#apply_default_rack_forwarding_family!), Configurator#configure, and freeze (#validate_trusted_proxy_config!). A no-op without proxy trust.

Raises:

  • (ArgumentError) —

    on a conflict with another committed config



722
723
724
725
726
727
# File 'lib/otto/security/config.rb', line 722

def commit_rack_forwarding_family!
  ensure_not_frozen!
  return unless forwarding_family_dependent?

  self.class.apply_rack_forwarding_family!(self, trusted_proxy_header)
end

#csp_nonce_enabled? ⇒ Boolean

Check if CSP nonce support is enabled

Returns:

  • (Boolean) —

    true if CSP nonce support is enabled



949
950
951
# File 'lib/otto/security/config.rb', line 949

def csp_nonce_enabled?
  @csp_nonce_enabled
end

#csp_request_extras_enabled? ⇒ Boolean

Check if the request-scoped CSP directive extras channel is enabled

Returns:



982
983
984
# File 'lib/otto/security/config.rb', line 982

def csp_request_extras_enabled?
  @csp_request_extras_enabled
end

#csrf_enabled? ⇒ Boolean

Check if CSRF protection is currently enabled

Returns:

  • (Boolean) —

    true if CSRF protection is enabled



473
474
475
# File 'lib/otto/security/config.rb', line 473

def csrf_enabled?
  @csrf_protection
end

#csrf_secret=(secret) ⇒ Object

Set the server-side secret used to sign (HMAC) CSRF tokens. Set this to a stable value (e.g. ENV) in multi-process or multi-host deployments so tokens stay valid across workers and restarts.

Write-only by design: the signing key has no public reader, so it is not exposed to inspection/logging/serialization via the config object.



780
781
782
783
784
785
# File 'lib/otto/security/config.rb', line 780

def csrf_secret=(secret)
  ensure_not_frozen!

  @csrf_secret           = secret
  @csrf_secret_generated = false
end

#debug_csp? ⇒ Boolean

Check if CSP debug logging is enabled

Returns:

  • (Boolean) —

    true if CSP debug logging is enabled



1005
1006
1007
# File 'lib/otto/security/config.rb', line 1005

def debug_csp?
  @debug_csp
end

#deep_freeze! ⇒ self

Override deep_freeze! to ensure rate_limiting_config has custom_rules initialized

This pre-initializes any lazy values before freezing to prevent FrozenError when accessing configuration after it's frozen.

Returns:

  • (self) —

    The frozen configuration



1212
1213
1214
1215
1216
1217
1218
1219
# File 'lib/otto/security/config.rb', line 1212

def deep_freeze!
  # Ensure custom_rules is initialized (should already be done in constructor)
  @rate_limiting_config[:custom_rules] ||= {}
  validate_referrer_policy!(@security_headers['referrer-policy'])
  validate_trusted_proxy_config!
  validate_csrf_secret_config!
  super
end

#default_trusted_proxy_header? ⇒ Boolean

Whether trusted_proxy_header is the X-Forwarded-For default.

Returns:

  • (Boolean)


732
733
734
# File 'lib/otto/security/config.rb', line 732

def default_trusted_proxy_header?
  @trusted_proxy_config.default_header?
end

#disable_csp_nonce! ⇒ void

This method returns an undefined value.

Disable CSP nonce support

Raises:

  • (FrozenError) —

    if configuration is frozen



940
941
942
943
944
# File 'lib/otto/security/config.rb', line 940

def disable_csp_nonce!
  ensure_not_frozen!

  @csp_nonce_enabled = false
end

#disable_csrf_protection! ⇒ void

This method returns an undefined value.

Disable CSRF protection

Raises:

  • (FrozenError) —

    if configuration is frozen



464
465
466
467
468
# File 'lib/otto/security/config.rb', line 464

def disable_csrf_protection!
  ensure_not_frozen!

  @csrf_protection = false
end

#dispatch_csp_violation(report) ⇒ void

This method returns an undefined value.

Invoke the registered violation callback for a report, isolating any error it raises. A misbehaving application callback must never break the report receiver (which always answers 204).

Parameters:



1119
1120
1121
1122
1123
1124
1125
1126
# File 'lib/otto/security/config.rb', line 1119

def dispatch_csp_violation(report)
  callback = @csp_violation_callback
  return if callback.nil?

  callback.call(report)
rescue StandardError => e
  Otto.logger.error("[Otto::CSP] violation callback raised #{e.class}: #{e.message}")
end

#enable_csp!(policy = "default-src 'self'") ⇒ void

This method returns an undefined value.

Enable Content Security Policy (CSP) header

CSP helps prevent XSS attacks by controlling which resources can be loaded. The default policy only allows resources from the same origin.

Examples:

Custom policy

config.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")

Parameters:

  • policy (String) (defaults to: "default-src 'self'") —

    CSP policy string (default: "default-src 'self'")

Raises:

  • (FrozenError) —

    if configuration is frozen



844
845
846
847
848
849
# File 'lib/otto/security/config.rb', line 844

def enable_csp!(policy = "default-src 'self'")
  ensure_not_frozen!

  @csp_policy = policy
  @security_headers['content-security-policy'] = build_static_csp(policy)
end

#enable_csp_request_extras! ⇒ void

This method returns an undefined value.

Enable the request-scoped CSP directive extras channel (delano/otto#243)

Off by default: env['otto.csp.extra_directives'] is a write surface that ANY middleware in the Rack stack can reach — a lower-trust position than boot code — so the channel does not exist until the app explicitly opts in here. Until then the Writer ignores the env key entirely (no sanitize work, no logs).

With the channel enabled, a handler (or middleware) can widen directives with values only known at request time by writing a hash of directive name => additional origin tokens to the env before the response is finalized. Extras are additive-only and sanitized defensively; see Otto::Security::CSP::RequestExtras.

Examples:

At boot, alongside nonce CSP

config.enable_csp_with_nonce!
config.enable_csp_request_extras!

Raises:

  • (FrozenError) —

    if configuration is frozen



973
974
975
976
977
# File 'lib/otto/security/config.rb', line 973

def enable_csp_request_extras!
  ensure_not_frozen!

  @csp_request_extras_enabled = true
end

#enable_csp_with_nonce!(debug: false, directives: {}) ⇒ void

This method returns an undefined value.

Enable Content Security Policy (CSP) with nonce support

This enables dynamic CSP header generation with nonces for enhanced security. Unlike enable_csp!, this doesn't set a static policy but enables the response helper to generate CSP headers with nonces on a per-request basis.

Per-directive overrides may be supplied to customize the emitted nonce policy without vendoring the gem. They merge into Otto's base directive sets (Otto::Security::CSP::Policy.development_directives / Otto::Security::CSP::Policy.production_directives): a matching directive is replaced in place, a new directive is appended, and a nil/false value removes a directive. See #csp_directive_overrides= for the accepted shape.

Examples:

config.enable_csp_with_nonce!(debug: true)

Restore data: workers (blob: is the default worker-src token)

config.enable_csp_with_nonce!(directives: { 'worker-src' => "'self' data: blob:" })

Parameters:

  • debug (Boolean) (defaults to: false) —

    Enable debug logging for CSP headers (default: false)

  • directives (Hash) (defaults to: {}) —

    per-directive overrides merged into the base set

Raises:

  • (FrozenError) —

    if configuration is frozen



875
876
877
878
879
880
881
882
883
884
# File 'lib/otto/security/config.rb', line 875

def enable_csp_with_nonce!(debug: false, directives: {})
  ensure_not_frozen!

  # Apply overrides before toggling state so a bad +directives+ argument
  # raises without leaving the config half-updated (nonce enabled but
  # overrides not merged).
  merge_csp_directives(directives) unless directives.nil? || directives.empty?
  @csp_nonce_enabled = true
  @debug_csp         = debug
end

#enable_csrf_protection! ⇒ void

This method returns an undefined value.

Enable CSRF (Cross-Site Request Forgery) protection

When enabled, Otto will:

  • Generate CSRF tokens for safe HTTP methods (GET, HEAD, OPTIONS, TRACE)
  • Validate CSRF tokens for unsafe methods (POST, PUT, DELETE, PATCH)
  • Automatically inject CSRF meta tags into HTML responses
  • Provide helper methods for forms and AJAX requests

Raises:

  • (FrozenError) —

    if configuration is frozen



454
455
456
457
458
# File 'lib/otto/security/config.rb', line 454

def enable_csrf_protection!
  ensure_not_frozen!

  @csrf_protection = true
end

#enable_frame_protection!(option = 'SAMEORIGIN') ⇒ void

This method returns an undefined value.

Enable X-Frame-Options header to prevent clickjacking

Parameters:

  • option (String) (defaults to: 'SAMEORIGIN') —

    Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'

Raises:

  • (FrozenError) —

    if configuration is frozen



1165
1166
1167
1168
1169
# File 'lib/otto/security/config.rb', line 1165

def enable_frame_protection!(option = 'SAMEORIGIN')
  ensure_not_frozen!

  @security_headers['x-frame-options'] = option
end

#enable_hsts!(max_age: 31_536_000, include_subdomains: true) ⇒ void

This method returns an undefined value.

Enable HTTP Strict Transport Security (HSTS) header

HSTS forces browsers to use HTTPS for all future requests to this domain. WARNING: This can make your domain inaccessible if HTTPS is not properly configured. Only enable this when you're certain HTTPS is working correctly.

Parameters:

  • max_age (Integer) (defaults to: 31_536_000) —

    Maximum age in seconds (default: 1 year)

  • include_subdomains (Boolean) (defaults to: true) —

    Apply to all subdomains (default: true)

Raises:

  • (FrozenError) —

    if configuration is frozen



825
826
827
828
829
830
831
# File 'lib/otto/security/config.rb', line 825

def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
  ensure_not_frozen!

  hsts_value                                     = "max-age=#{max_age}"
  hsts_value                                    += '; includeSubDomains' if include_subdomains
  @security_headers['strict-transport-security'] = hsts_value
end

#forwarding_family_dependent? ⇒ Boolean

Whether this config's request handling DEPENDS on Rack's process-global forwarding family — i.e. it actually reads a forwarded chain. True for filter and depth mode; false for trust-nobody (reads nothing) and for unconfigured apps.

Returns:

  • (Boolean)


622
623
624
# File 'lib/otto/security/config.rb', line 622

def forwarding_family_dependent?
  @trusted_proxy_config.forwarding_family_dependent?
end

#generate_csrf_token(session_id = nil) ⇒ Object

Generate a CSRF token bound to the given session id and signed (HMAC-SHA256) with the server-side secret, so tokens cannot be self-minted and are not valid across sessions. A session binding is REQUIRED.

Raises:

  • (ArgumentError)


790
791
792
793
794
795
796
797
798
# File 'lib/otto/security/config.rb', line 790

def generate_csrf_token(session_id = nil)
  binding_id = session_id.to_s
  raise ArgumentError, 'CSRF token generation requires a session binding' if binding_id.empty?

  reject_generated_secret_in_production!
  warn_generated_csrf_secret
  token = SecureRandom.hex(32)
  "#{token}:#{sign_csrf_token(binding_id, token)}"
end

#generate_nonce_csp(nonce, development_mode: false, extra_directives: nil) {|applied, dropped| ... } ⇒ String

Generate a CSP policy string with the provided nonce

Thin facade over Otto::Security::CSP::Policy.nonce_policy; the directive sets and report-uri/report-to assembly live there now. Any configured #csp_directive_overrides are merged into the base directive set. Output is byte-identical to Otto's historical policy when no overrides or reporting are configured.

Parameters:

  • nonce (String) —

    The nonce value to include in the CSP

  • development_mode (Boolean) (defaults to: false) —

    Whether to use development-friendly directives

  • extra_directives (Hash{String=>Array<String>}, nil) (defaults to: nil) —

    request-scoped extra source tokens appended additively after the overrides merge (see Otto::Security::CSP::Policy.append_extra_sources, delano/otto#243). Per-request data — passed through, never stored on this (deep-frozen in production) config.

Yields:

Returns:

  • (String) —

    Complete CSP policy string



1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
# File 'lib/otto/security/config.rb', line 1148

def generate_nonce_csp(nonce, development_mode: false, extra_directives: nil, &extras_outcome)
  Otto::Security::CSP::Policy.nonce_policy(
    nonce,
    development_mode:    development_mode,
          report_uri:    @csp_report_uri,
       report_to_url:    @csp_report_to_url,
 directive_overrides:    @csp_directive_overrides,
    extra_directives:    extra_directives,
    &extras_outcome
  )
end

#get_or_create_session_id(request) ⇒ Object



1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
# File 'lib/otto/security/config.rb', line 1221

def get_or_create_session_id(request)
  # Try existing sources first
  session_id = extract_existing_session_id(request)

  # Create and persist if none found
  if session_id.nil? || session_id.empty?
    session_id = SecureRandom.hex(16)
    store_session_id(request, session_id)
  end

  session_id
end

#merge_csp_directives(overrides) ⇒ void

This method returns an undefined value.

Merge additional per-directive overrides into the existing set, leaving untouched any directive not named in overrides (last write wins for a repeated directive). Use this to accumulate overrides incrementally; use #csp_directive_overrides= to replace them wholesale.

Parameters:

  • overrides (Hash) —

    directive name => source list / nil

Raises:

  • (FrozenError) —

    if configuration is frozen



928
929
930
931
932
933
934
# File 'lib/otto/security/config.rb', line 928

def merge_csp_directives(overrides)
  ensure_not_frozen!

  normalized = Otto::Security::CSP::Policy.normalize_overrides(overrides || {})
  warn_if_script_src_overridden(normalized)
  @csp_directive_overrides = @csp_directive_overrides.merge(normalized)
end

#on_csp_violation {|report| ... } ⇒ void

This method returns an undefined value.

Register the callback invoked once per parsed CSP violation report.

The block receives an Otto::Security::CSP::Report. Your application decides what to do — log, emit a metric, store, forward, or ignore. Otto adds no storage or database coupling.

Registering a second callback REPLACES the first (last registration wins), matching the singular on_csp_violation semantics. Calling this with NO block clears (unregisters) any previously-set callback.

SECURITY NOTE: report URL fields may carry sensitive path/query data in some applications. Redact them in your callback before logging if needed; Otto passes them through un-redacted (see Otto::Security::CSP::Report).

Yield Parameters:

Raises:

  • (FrozenError) —

    if configuration is frozen



1107
1108
1109
1110
1111
# File 'lib/otto/security/config.rb', line 1107

def on_csp_violation(&block)
  ensure_not_frozen!

  @csp_violation_callback = block
end

#proxy_trust_configured? ⇒ Boolean

Whether ANY proxy-trust mode is configured — CIDR matchers (filter mode) or count-based depth. This is the gate for writing env at all: when neither mode is configured the key is left ABSENT (tri-state contract), so downstream consumers can distinguish "operator configured trust and this peer failed it" (false) from "no proxy trust configured" (absent) and apply their own legacy heuristics only in the latter case.

Returns:

  • (Boolean) —

    true when filter or depth mode is configured, or when the operator explicitly asserted that no proxy is trusted



576
577
578
# File 'lib/otto/security/config.rb', line 576

def proxy_trust_configured?
  @trusted_proxy_config.configured?
end

#referrer_policy ⇒ String

The Referrer-Policy value applied to Otto-generated responses.

Returns:



1174
1175
1176
# File 'lib/otto/security/config.rb', line 1174

def referrer_policy
  @security_headers['referrer-policy']
end

#referrer_policy=(policy) ⇒ String

Configure the Referrer-Policy value applied to Otto responses.

Parameters:

  • policy (String) —

    one W3C Referrer Policy HTTP policy token

Returns:

  • (String) —

    the configured policy

Raises:

  • (ArgumentError) —

    when policy is not a recognized token

  • (FrozenError) —

    if configuration is frozen



1184
1185
1186
1187
# File 'lib/otto/security/config.rb', line 1184

def referrer_policy=(policy)
  ensure_not_frozen!
  @security_headers['referrer-policy'] = policy
end

#set_custom_headers(headers) ⇒ void

This method returns an undefined value.

Set custom security headers

Examples:

config.set_custom_headers({
  'permissions-policy' => 'geolocation=(), microphone=()',
  'cross-origin-opener-policy' => 'same-origin'
})

Parameters:

  • headers (Hash) —

    Hash of header name => value pairs

Raises:

  • (FrozenError) —

    if configuration is frozen



1200
1201
1202
1203
1204
# File 'lib/otto/security/config.rb', line 1200

def set_custom_headers(headers)
  ensure_not_frozen!

  @security_headers.merge!(headers)
end

#trust_no_proxies! ⇒ void

This method returns an undefined value.

Assert that NO proxy is trusted for this application.

This is a positive operator assertion, not the absence of one: an app that never configures proxy trust leaves env ABSENT (the tri-state contract from #228) so downstream consumers may apply their own heuristics. After this call the key is written as false for EVERY peer — loopback included, since Otto has no loopback special case in either resolution mode — which means client IP resolution ignores X-Forwarded-For entirely (REMOTE_ADDR wins) and IPPrivacyMiddleware strips the forwarded host/scheme/port carriers, so Rack::Request#host resolves only from the Host header (#259).

Mutually exclusive with any actual trust grant (trusted_proxies CIDRs or trusted_proxy_depth >= 1). It stakes no claim on the process-global Rack forwarding family: an app that trusts nobody reads no forwarded chain, so it cannot conflict with another app's explicit choice.

Examples:

config.trust_no_proxies!

Raises:

  • (FrozenError) —

    if configuration is frozen

  • (ArgumentError) —

    if trusted proxies or a depth >= 1 are configured



603
604
605
606
607
# File 'lib/otto/security/config.rb', line 603

def trust_no_proxies!
  ensure_not_frozen!

  @trusted_proxy_config.trust_none!
end

#trust_no_proxies? ⇒ Boolean

Whether the operator explicitly asserted that no proxy is trusted.

Returns:

  • (Boolean)


612
613
614
# File 'lib/otto/security/config.rb', line 612

def trust_no_proxies?
  @trusted_proxy_config.trust_none?
end

#trusted_proxies ⇒ Array<String, Regexp>

Proxy entries registered with #add_trusted_proxy, in order.

Returns:

  • (Array<String, Regexp>)


490
491
492
# File 'lib/otto/security/config.rb', line 490

def trusted_proxies
  @trusted_proxy_config.proxies
end

#trusted_proxies_configured? ⇒ Boolean

Whether any trusted-proxy IP/CIDR/Regexp matchers are configured.

This mirrors #trusted_proxy?, which consults the same matcher list. It deliberately EXCLUDES count-based depth mode: depth grants the peer blanket trust for otto.via_trusted_proxy (#226), but it cannot verify that the hop is a geo-setting CDN, so header-based geo stays gated on enumerated matchers only. Used to gate geo-header trust.

Returns:

  • (Boolean) —

    true when at least one trusted-proxy matcher exists



562
563
564
# File 'lib/otto/security/config.rb', line 562

def trusted_proxies_configured?
  @trusted_proxy_config.filter?
end

#trusted_proxy?(ip) ⇒ Boolean

Check if an IP address is from a trusted proxy

IP and CIDR entries match by IPAddr containment (IPv4 and IPv6, with IPv4-mapped addresses folded), Regexp entries match the raw string, and non-IP strings fall back to legacy prefix matching. Entries are parsed once at registration. See TrustedProxyConfig#trusted?.

Parameters:

  • ip (String) —

    IP address to check

Returns:

  • (Boolean) —

    true if the IP is from a trusted proxy



549
550
551
# File 'lib/otto/security/config.rb', line 549

def trusted_proxy?(ip)
  @trusted_proxy_config.trusted?(ip)
end

#trusted_proxy_depth ⇒ Integer?

Count-based trusted-proxy depth, or nil. See #trusted_proxy_depth=.

Returns:

  • (Integer, nil)


497
498
499
# File 'lib/otto/security/config.rb', line 497

def trusted_proxy_depth
  @trusted_proxy_config.depth
end

#trusted_proxy_depth=(depth) ⇒ Object

Set the count-based trusted-proxy depth ("trust the last N hops").

Validates eagerly so a misconfiguration fails at assignment rather than only at freeze (which the test harness skips): the value must be a non-negative Integer or nil, and the mode is mutually exclusive with CIDR-walk (trusted_proxies), with the trust-nobody assertion, and with a trusted ip_privacy geo_header. nil/0 disable depth mode.

Parameters:

  • depth (Integer, nil) —

    number of trusted hops (nil/0 disables depth mode)

Raises:

  • (FrozenError) —

    if configuration is frozen

  • (ArgumentError) —

    if depth is non-integer/negative, or if trusted_proxies are already configured and depth >= 1



651
652
653
654
655
656
657
658
659
660
# File 'lib/otto/security/config.rb', line 651

def trusted_proxy_depth=(depth)
  ensure_not_frozen!

  @trusted_proxy_config.check_depth!(depth)
  # Depth-then-geo assignment order is caught by configure_ip_privacy;
  # this catches geo-then-depth so both orders fail eagerly.
  raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE if depth.to_i >= 1 && @ip_privacy_config&.geo_header

  @trusted_proxy_config.depth = depth
end

#trusted_proxy_depth_mode? ⇒ Boolean

Whether count-based ("trust the last N hops") proxy resolution is active.

When true, Otto::Utils.resolve_client_ip ignores trusted-proxy CIDRs and instead trusts a fixed number of hops from the right of the forwarded chain (Express trust proxy = N). This is the only sound model for non-enumerable proxy tiers (Fly, cloud load balancers, dynamic reverse proxies) whose addresses cannot be listed as CIDRs.

Returns:

  • (Boolean) —

    true when trusted_proxy_depth is an Integer >= 1



635
636
637
# File 'lib/otto/security/config.rb', line 635

def trusted_proxy_depth_mode?
  @trusted_proxy_config.depth?
end

#trusted_proxy_header ⇒ String

Forwarded header family depth mode counts hops from. See #trusted_proxy_header=.

Returns:

  • (String) —

    one of TRUSTED_PROXY_HEADERS



505
506
507
# File 'lib/otto/security/config.rb', line 505

def trusted_proxy_header
  @trusted_proxy_config.header
end

#trusted_proxy_header=(header) ⇒ Object

Select which forwarded header family Otto and Rack read from: 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both' (Forwarded when present, else X-Forwarded-For).

Otto consults the value in count-based depth mode (#trusted_proxy_depth_mode?) to pick the chain it counts hops from. CIDR-walk always resolves from X-Forwarded-For / X-Real-IP / X-Client-IP, so 'Forwarded' and 'Both' are rejected once trusted_proxies are configured (and vice versa in #add_trusted_proxy).

The value is matched case-insensitively (surrounding whitespace ignored) and stored in its canonical spelling, so a hand-edited config can write forwarded or both without surprise. A genuinely unrecognized value fails loud at assignment (rather than silently resolving from the wrong header, the way a permissive default would), so a typo surfaces at config time instead of as subtly-wrong client IPs at request time.

Applying this setting also pins Rack::Request.forwarded_priority to the corresponding family. Rack exposes that policy process-wide, so every Otto application in one process must agree; see Config.apply_rack_forwarding_family!.

Parameters:

  • header (String) —

    one of TRUSTED_PROXY_HEADERS (case-insensitive)

Raises:

  • (FrozenError) —

    if configuration is frozen

  • (ArgumentError) —

    if header is not a recognized value, conflicts with configured trusted_proxies, or conflicts with another Otto application's explicit choice in this process



689
690
691
692
693
694
695
# File 'lib/otto/security/config.rb', line 689

def trusted_proxy_header=(header)
  ensure_not_frozen!

  canonical_header = @trusted_proxy_config.check_header!(header)
  self.class.apply_rack_forwarding_family!(self, canonical_header)
  @trusted_proxy_config.header = canonical_header
end

#trusted_proxy_mode ⇒ Symbol?

The active trusted-proxy mode: :filter, :depth, :none, or nil when proxy trust is unconfigured. The TrustedProxyConfig behind it is not exposed, because its setters would skip the Rack forwarding-family pin and the geo_header check this class adds.

Returns:

  • (Symbol, nil)


483
484
485
# File 'lib/otto/security/config.rb', line 483

def trusted_proxy_mode
  @trusted_proxy_config.mode
end

#validate_request_size(content_length) ⇒ Boolean

Validate that a request size is within acceptable limits

Parameters:

  • content_length (String, Integer, nil) —

    Content-Length header value

Returns:

  • (Boolean) —

    true if request size is acceptable

Raises:



741
742
743
744
745
746
747
748
749
750
# File 'lib/otto/security/config.rb', line 741

def validate_request_size(content_length)
  return true if content_length.nil?

  size = content_length.to_i
  if size > @max_request_size
    raise Otto::Security::RequestTooLargeError,
          "Request size #{size} exceeds maximum #{@max_request_size}"
  end
  true
end

#verify_csrf_token(token, session_id = nil) ⇒ Object

Verify a CSRF token against its session binding using a constant-time comparison. Returns false (never raises) for blank/malformed input.



802
803
804
805
806
807
808
809
810
811
812
813
# File 'lib/otto/security/config.rb', line 802

def verify_csrf_token(token, session_id = nil)
  return false if token.nil? || token.empty?

  binding_id = session_id.to_s
  return false if binding_id.empty?

  token_part, signature = token.split(':', 2)
  return false if token_part.nil? || signature.nil?

  expected_signature = sign_csrf_token(binding_id, token_part)
  secure_compare(signature, expected_signature)
end