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

Constant Summary collapse

PROXY_MODE_CONFLICT_MESSAGE =

Error raised when the two mutually-exclusive trusted-proxy resolution modes are configured together: CIDR-walk (enumerated #trusted_proxies) and count-based depth (#trusted_proxy_depth >= 1).

<<~MSG.gsub(/\s+/, ' ').strip.freeze
  Cannot configure both trusted_proxies (CIDR filter mode) and
  trusted_proxy_depth >= 1 (count mode). Enumerate proxy CIDRs OR set a
  hop count, not both.
MSG
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.

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

Error raised when the explicit "trust no proxy" assertion (#trust_no_proxies!, trusted_proxies: :none) is combined with an actual trust grant (enumerated CIDRs or a depth >= 1). The two say opposite things about the same peer, so the combination is refused at configuration time rather than silently resolved in one direction.

<<~MSG.gsub(/\s+/, ' ').strip.freeze
  Cannot combine trusted_proxies: :none (trust no proxy) with
  trusted_proxies CIDRs or trusted_proxy_depth >= 1. Assert :none OR
  grant trust, not both.
MSG
TRUST_NO_PROXIES_ENTRY_MESSAGE =

Error raised when the trust-nobody sentinel arrives as a proxy ENTRY (trusted_proxies: ['none'], as a YAML/JSON list naturally yields, or add_trusted_proxy('none')) instead of as the whole option. Inside a list it would otherwise register a legacy string-prefix matcher that matches nothing: peers would be untrusted, but trust_no_proxies? would stay false and the config would stake a forwarding-family claim, so the explicit assertion would be silently replaced by a lookalike.

<<~MSG.gsub(/\s+/, ' ').strip.freeze
  trusted_proxies entry :none is the trust-nobody assertion, not a proxy
  address. Pass trusted_proxies: :none as the whole option (not inside a
  list) or call trust_no_proxies! instead.
MSG
TRUST_NO_PROXIES =

Sentinel accepted wherever a trusted_proxies list is accepted, meaning "the operator asserts that NO proxy is trusted". See #trust_no_proxies!.

:none
TRUSTED_PROXY_HEADERS =

Forwarded-header sources depth mode (#trusted_proxy_depth) can count hops from: X-Forwarded-For (default), the RFC 7239 Forwarded header, or Both (Forwarded when present, else X-Forwarded-For). Mirrors OneTimeSecret's site.network.trusted_proxy.header. Only consulted in depth mode; CIDR-walk is unaffected.

%w[X-Forwarded-For Forwarded Both].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
DEFAULT_TRUSTED_PROXY_HEADER =
'X-Forwarded-For'
FORWARDING_FAMILY_CONFLICT_MESSAGE =
<<~MSG.gsub(/\s+/, ' ').strip.freeze
  Cannot use forwarding family %s (trusted_proxy_header) because another
  Otto application in this process already uses %s. Rack's forwarded
  host, port, scheme, and IP policy is process-global, so every Otto
  application in one process that resolves proxied requests must use the
  same forwarding family.
MSG
FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE =

Error raised when a non-default trusted_proxy_header is combined with CIDR filter mode. Otto's CIDR-walk resolves the client IP from the X-Forwarded-For family only (X-Forwarded-For, then X-Real-IP, then X-Client-IP — Otto::Utils::FORWARDED_FOR_HEADERS), never RFC 7239 Forwarded, while trusted_proxy_header also pins Rack's forwarding family; honoring 'Forwarded' or 'Both' there would make Rack read a header Otto ignores, recreating the disagreement the pin exists to close.

<<~MSG.gsub(/\s+/, ' ').strip.freeze
  Cannot configure trusted_proxy_header 'Forwarded' or 'Both' together
  with trusted_proxies (CIDR filter mode): CIDR-walk resolves client IPs
  from the X-Forwarded-For family only (X-Forwarded-For, X-Real-IP,
  X-Client-IP), never RFC 7239 Forwarded. Use trusted_proxy_depth (count
  mode) to read the RFC 7239 Forwarded header.
MSG
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.

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

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeConfig

Initialize security configuration with safe defaults

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



267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/otto/security/config.rb', line 267

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_proxies        = []
  @trusted_proxy_matchers = []
  @trust_no_proxies       = false
  @trusted_proxy_depth    = nil
  @trusted_proxy_header   = DEFAULT_TRUSTED_PROXY_HEADER
  @require_secure_cookies = false
  @security_headers       = 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_familyString? (readonly)

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

Returns:

  • (String, nil)


210
211
212
# File 'lib/otto/security/config.rb', line 210

def rack_forwarding_family
  @rack_forwarding_family
end

Instance Attribute Details

#csp_directive_overridesObject

Returns the value of attribute csp_directive_overrides.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def csp_directive_overrides
  @csp_directive_overrides
end

#csp_nonce_enabledObject (readonly)

Returns the value of attribute csp_nonce_enabled.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def csp_nonce_enabled
  @csp_nonce_enabled
end

#csp_nonce_keyObject

Returns the value of attribute csp_nonce_key.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def csp_nonce_key
  @csp_nonce_key
end

#csp_report_to_urlObject

Returns the value of attribute csp_report_to_url.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def csp_report_to_url
  @csp_report_to_url
end

#csp_report_uriObject

Returns the value of attribute csp_report_uri.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def csp_report_uri
  @csp_report_uri
end

#csp_request_extras_enabledObject (readonly)

Returns the value of attribute csp_request_extras_enabled.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def csp_request_extras_enabled
  @csp_request_extras_enabled
end

#csp_violation_callbackObject (readonly)

Returns the value of attribute csp_violation_callback.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def csp_violation_callback
  @csp_violation_callback
end

#csrf_header_keyObject (readonly)

Returns the value of attribute csrf_header_key.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def csrf_header_key
  @csrf_header_key
end

#csrf_protectionObject (readonly)

Returns the value of attribute csrf_protection.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def csrf_protection
  @csrf_protection
end

#csrf_session_keyObject

Returns the value of attribute csrf_session_key.



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

def csrf_session_key
  @csrf_session_key
end

#csrf_token_keyObject

Returns the value of attribute csrf_token_key.



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

def csrf_token_key
  @csrf_token_key
end

#debug_cspObject (readonly)

Returns the value of attribute debug_csp.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def debug_csp
  @debug_csp
end

#input_validationObject

Returns the value of attribute input_validation.



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

def input_validation
  @input_validation
end

#ip_privacy_configObject (readonly)

Returns the value of attribute ip_privacy_config.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def ip_privacy_config
  @ip_privacy_config
end

#max_param_depthObject

Returns the value of attribute max_param_depth.



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

def max_param_depth
  @max_param_depth
end

#max_param_keysObject

Returns the value of attribute max_param_keys.



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

def max_param_keys
  @max_param_keys
end

#max_request_sizeObject

Returns the value of attribute max_request_size.



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

def max_request_size
  @max_request_size
end

#mcp_authObject

Returns the value of attribute mcp_auth.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def mcp_auth
  @mcp_auth
end

#rate_limiting_configObject

Returns the value of attribute rate_limiting_config.



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

def rate_limiting_config
  @rate_limiting_config
end

#require_secure_cookiesObject (readonly)

Returns the value of attribute require_secure_cookies.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def require_secure_cookies
  @require_secure_cookies
end

#security_headersObject (readonly)

Returns the value of attribute security_headers.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def security_headers
  @security_headers
end

#trusted_proxiesObject (readonly)

Returns the value of attribute trusted_proxies.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def trusted_proxies
  @trusted_proxies
end

#trusted_proxy_depthObject

Returns the value of attribute trusted_proxy_depth.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def trusted_proxy_depth
  @trusted_proxy_depth
end

#trusted_proxy_headerObject

Returns the value of attribute trusted_proxy_header.



255
256
257
# File 'lib/otto/security/config.rb', line 255

def trusted_proxy_header
  @trusted_proxy_header
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



174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/otto/security/config.rb', line 174

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:



200
201
202
203
204
205
# File 'lib/otto/security/config.rb', line 200

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.



215
216
217
218
219
220
221
222
223
# File 'lib/otto/security/config.rb', line 215

def reset_rack_forwarding_family_for_testing!
  raise 'reset_rack_forwarding_family_for_testing! is only available in RSpec test environment' unless defined?(RSpec)

  RACK_FORWARDING_MUTEX.synchronize do
    @rack_forwarding_owners = nil
    @rack_forwarding_family = nil
    RACK_REQUEST.forwarded_priority = DEFAULT_RACK_FORWARDED_PRIORITY.dup
  end
end

.trust_no_proxies_option?(value) ⇒ Boolean

Whether a trusted_proxies option value is the trust-nobody sentinel. Accepts the symbol and the String spelling 'none' (case-insensitive), which is what YAML/ENV-driven configuration naturally produces; without this, 'none' would fall through to add_trusted_proxy and install a legacy string-prefix matcher, silently inverting the assertion.

Parameters:

  • value (Object)

    raw trusted_proxies option

Returns:

  • (Boolean)


95
96
97
# File 'lib/otto/security/config.rb', line 95

def self.trust_no_proxies_option?(value)
  (value.is_a?(Symbol) || value.is_a?(String)) && value.to_s.casecmp?('none')
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.

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, Array)

    IP address, CIDR range, or array of addresses

Raises:

  • (ArgumentError)

    if proxy is not a String or Array

  • (FrozenError)

    if configuration is frozen



353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
# File 'lib/otto/security/config.rb', line 353

def add_trusted_proxy(proxy)
  ensure_not_frozen!
  # CIDR-walk and count-based depth are mutually exclusive. Catch the
  # conflict eagerly here (and in #trusted_proxy_depth=) so it surfaces at
  # configuration time, not only at freeze (which the test harness skips).
  raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if trusted_proxy_depth_mode?
  raise ArgumentError, TRUST_NO_PROXIES_CONFLICT_MESSAGE if @trust_no_proxies
  # Same pattern for header-then-proxies; proxies-then-header is caught
  # in #trusted_proxy_header=.
  raise ArgumentError, FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE unless default_trusted_proxy_header?

  # The trust-nobody sentinel is an option value, never an entry; validate
  # the whole list before registering anything so a bad list leaves the
  # config untouched.
  Array(proxy).each do |entry|
    raise ArgumentError, TRUST_NO_PROXIES_ENTRY_MESSAGE if self.class.trust_no_proxies_option?(entry)
  end

  case proxy
  when String, Regexp
    @trusted_proxies << proxy
    @trusted_proxy_matchers << register_proxy_matcher(proxy)
  when Array
    proxy.each { |entry| @trusted_proxy_matchers << register_proxy_matcher(entry) }
    @trusted_proxies.concat(proxy)
  else
    raise ArgumentError, 'Proxy must be a String, Regexp, or Array'
  end
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



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

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



592
593
594
595
596
597
# File 'lib/otto/security/config.rb', line 592

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



819
820
821
# File 'lib/otto/security/config.rb', line 819

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:



852
853
854
# File 'lib/otto/security/config.rb', line 852

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



330
331
332
# File 'lib/otto/security/config.rb', line 330

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.



650
651
652
653
654
655
# File 'lib/otto/security/config.rb', line 650

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



875
876
877
# File 'lib/otto/security/config.rb', line 875

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



1049
1050
1051
1052
1053
1054
1055
# File 'lib/otto/security/config.rb', line 1049

def deep_freeze!
  # Ensure custom_rules is initialized (should already be done in constructor)
  @rate_limiting_config[:custom_rules] ||= {}
  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)


602
603
604
# File 'lib/otto/security/config.rb', line 602

def default_trusted_proxy_header?
  @trusted_proxy_header == DEFAULT_TRUSTED_PROXY_HEADER
end

#disable_csp_nonce!void

This method returns an undefined value.

Disable CSP nonce support

Raises:

  • (FrozenError)

    if configuration is frozen



810
811
812
813
814
# File 'lib/otto/security/config.rb', line 810

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



321
322
323
324
325
# File 'lib/otto/security/config.rb', line 321

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:



974
975
976
977
978
979
980
981
# File 'lib/otto/security/config.rb', line 974

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



714
715
716
717
718
719
# File 'lib/otto/security/config.rb', line 714

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



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

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



745
746
747
748
749
750
751
752
753
754
# File 'lib/otto/security/config.rb', line 745

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



311
312
313
314
315
# File 'lib/otto/security/config.rb', line 311

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



1020
1021
1022
1023
1024
# File 'lib/otto/security/config.rb', line 1020

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



695
696
697
698
699
700
701
# File 'lib/otto/security/config.rb', line 695

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)


488
489
490
# File 'lib/otto/security/config.rb', line 488

def forwarding_family_dependent?
  trusted_proxies_configured? || trusted_proxy_depth_mode?
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)


660
661
662
663
664
665
666
667
668
# File 'lib/otto/security/config.rb', line 660

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



1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
# File 'lib/otto/security/config.rb', line 1003

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



1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
# File 'lib/otto/security/config.rb', line 1057

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



798
799
800
801
802
803
804
# File 'lib/otto/security/config.rb', line 798

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



962
963
964
965
966
# File 'lib/otto/security/config.rb', line 962

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



441
442
443
# File 'lib/otto/security/config.rb', line 441

def proxy_trust_configured?
  trusted_proxies_configured? || trusted_proxy_depth_mode? || trust_no_proxies?
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



1037
1038
1039
1040
1041
# File 'lib/otto/security/config.rb', line 1037

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



468
469
470
471
472
473
# File 'lib/otto/security/config.rb', line 468

def trust_no_proxies!
  ensure_not_frozen!
  raise ArgumentError, TRUST_NO_PROXIES_CONFLICT_MESSAGE if trusted_proxies_configured? || trusted_proxy_depth_mode?

  @trust_no_proxies = true
end

#trust_no_proxies?Boolean

Whether the operator explicitly asserted that no proxy is trusted.

Returns:

  • (Boolean)


478
479
480
# File 'lib/otto/security/config.rb', line 478

def trust_no_proxies?
  @trust_no_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



427
428
429
# File 'lib/otto/security/config.rb', line 427

def trusted_proxies_configured?
  @trusted_proxy_matchers.any?
end

#trusted_proxy?(ip) ⇒ Boolean

Check if an IP address is from a trusted proxy

String entries that parse as an IP or CIDR range are matched with proper IPAddr containment (IPv4 and IPv6). Entries that are not valid IPs (e.g. a bare prefix like '172.16.') fall back to the legacy exact/prefix string match for backward compatibility. Regexp entries are matched against the raw IP string.

Proxy entries are parsed once at registration (see #add_trusted_proxy) into @trusted_proxy_matchers, so this never re-parses per request.

Parameters:

  • ip (String)

    IP address to check

Returns:

  • (Boolean)

    true if the IP is from a trusted proxy



396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
# File 'lib/otto/security/config.rb', line 396

def trusted_proxy?(ip)
  return false if @trusted_proxy_matchers.empty? || ip.nil? || ip.empty?

  # Fold IPv4-mapped IPv6 (::ffff:a.b.c.d) to plain IPv4 so a dual-stack
  # peer presented in mapped form still matches an IPv4 proxy entry.
  client = parse_ipaddr(ip)&.native

  @trusted_proxy_matchers.any? do |entry, range|
    if range
      # Pre-parsed IP/CIDR entry -> proper containment
      client && ip_in_range?(range, client)
    elsif entry.is_a?(Regexp)
      entry.match?(ip)
    elsif entry.is_a?(String)
      # Legacy non-IP entry (e.g. '172.16.') -> exact/prefix match
      ip == entry || ip.start_with?(entry)
    else
      false
    end
  end
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



501
502
503
# File 'lib/otto/security/config.rb', line 501

def trusted_proxy_depth_mode?
  @trusted_proxy_depth.is_a?(Integer) && @trusted_proxy_depth >= 1
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:



611
612
613
614
615
616
617
618
619
620
# File 'lib/otto/security/config.rb', line 611

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.



672
673
674
675
676
677
678
679
680
681
682
683
# File 'lib/otto/security/config.rb', line 672

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