otto 2.10.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.github/workflows/code-smells.yml +2 -2
  6. data/.github/workflows/release-gem.yml +1 -1
  7. data/.github/workflows/ruby-lint.yml +1 -1
  8. data/.github/workflows/yardoc.yml +1 -1
  9. data/.rubocop_todo.yml +3 -2
  10. data/AGENTS.md +18 -0
  11. data/CHANGELOG.rst +132 -0
  12. data/Gemfile +2 -2
  13. data/Gemfile.lock +8 -8
  14. data/README.md +6 -0
  15. data/docs/guides/configuration_freezing.md +17 -6
  16. data/docs/guides/forwarded-authority.md +5 -1
  17. data/docs/guides/privacy.md +5 -0
  18. data/docs/guides/routing.md +180 -5
  19. data/docs/guides/testing-guide.md +115 -2
  20. data/lib/otto/caddy_tls/localhost_guard.rb +6 -6
  21. data/lib/otto/core/configuration.rb +9 -4
  22. data/lib/otto/core/error_handler.rb +40 -2
  23. data/lib/otto/core/file_safety.rb +24 -8
  24. data/lib/otto/core/router.rb +53 -24
  25. data/lib/otto/core/static_mounts.rb +172 -0
  26. data/lib/otto/core.rb +1 -0
  27. data/lib/otto/env_keys.rb +2 -1
  28. data/lib/otto/privacy/config.rb +19 -13
  29. data/lib/otto/response.rb +5 -2
  30. data/lib/otto/route.rb +1 -1
  31. data/lib/otto/route_handlers/base.rb +1 -1
  32. data/lib/otto/route_handlers/logic_class.rb +86 -34
  33. data/lib/otto/security/config.rb +349 -303
  34. data/lib/otto/security/configurator.rb +82 -45
  35. data/lib/otto/security/core.rb +4 -2
  36. data/lib/otto/security/csp/report_middleware.rb +17 -1
  37. data/lib/otto/security/middleware/ip_privacy_middleware.rb +3 -5
  38. data/lib/otto/security/rate_limiter.rb +8 -5
  39. data/lib/otto/security/trusted_proxy_config.rb +396 -0
  40. data/lib/otto/static.rb +45 -6
  41. data/lib/otto/testing.rb +148 -0
  42. data/lib/otto/utils.rb +63 -14
  43. data/lib/otto/version.rb +1 -1
  44. data/lib/otto.rb +126 -2
  45. metadata +5 -2
@@ -50,10 +50,17 @@ class Otto
50
50
  # Note the axis this controls: what PERSISTS observably (env keys, logs,
51
51
  # fingerprints). Precise ephemeral matching against the unmasked IP does
52
52
  # not require :audit — see EnvKeys::IP_MATCH, available in every profile.
53
+ #
54
+ # This table is the only list of knobs a profile governs: #profile=
55
+ # assigns whatever keys a preset carries, through each knob's writer so
56
+ # any check or normalization the writer does still runs. Every profile
57
+ # names the same keys, so applying one yields the same state whatever
58
+ # preceded it, and each key names a Config attribute with a writer and
59
+ # a public reader.
53
60
  PROFILES = {
54
61
  anonymous: { disabled: false, mask_private_ips: true }.freeze,
55
62
  masked: { disabled: false, mask_private_ips: false }.freeze,
56
- audit: { disabled: true }.freeze,
63
+ audit: { disabled: true, mask_private_ips: false }.freeze,
57
64
  }.freeze
58
65
 
59
66
  attr_accessor :octet_precision, :hash_rotation_period, :geo_enabled, :mask_private_ips,
@@ -396,23 +403,18 @@ class Otto
396
403
 
397
404
  # Apply a named privacy profile's presets to this config.
398
405
  #
399
- # Sets only the knobs the profile names (see {PROFILES}); other settings
406
+ # Assigns every knob the preset names (see {PROFILES}), so the result
407
+ # never depends on the profile applied before it: :anonymous -> :audit
408
+ # -> enable! lands on :masked, exactly as
409
+ # Config.new(profile: :audit).enable! does. Other settings
400
410
  # (octet_precision, geo, correlation_secret, ...) are untouched.
401
411
  #
402
- # Presets are applied, not reset: a knob a profile does not name keeps its
403
- # previous value. Switching :anonymous -> :audit therefore leaves
404
- # mask_private_ips true, because :audit names only `disabled`. That is
405
- # inert rather than wrong — `disabled` short-circuits privacy_enabled?
406
- # before mask_private_ips is ever read, and #profile below tests @disabled
407
- # first, so the derived label stays accurate. Switching on to :masked
408
- # re-sets both knobs explicitly. Only surprising if you read the raw ivars.
409
- #
410
412
  # @param profile [Symbol, String] :anonymous, :masked, or :audit
411
413
  # @raise [ArgumentError] for an unknown profile name
412
414
  def profile=(profile)
413
- presets = self.class.profile_presets(profile)
414
- @disabled = presets[:disabled] if presets.key?(:disabled)
415
- @mask_private_ips = presets[:mask_private_ips] if presets.key?(:mask_private_ips)
415
+ self.class.profile_presets(profile).each do |knob, value|
416
+ send(:"#{knob}=", value)
417
+ end
416
418
  end
417
419
 
418
420
  # The profile the current knob state corresponds to.
@@ -517,6 +519,10 @@ class Otto
517
519
 
518
520
  private
519
521
 
522
+ # Profile-governed, so #profile= can assign it through a writer like the
523
+ # other knobs. Public callers use #disable! and #enable!.
524
+ attr_writer :disabled
525
+
520
526
  # Normalize a database-path option to a non-empty String or nil. Shared by
521
527
  # the geo, ASN and anonymizer paths — the rule is identical for all three.
522
528
  #
data/lib/otto/response.rb CHANGED
@@ -91,8 +91,11 @@ class Otto
91
91
  # Prevent MIME type sniffing
92
92
  headers['x-content-type-options'] = 'nosniff'
93
93
 
94
- # Add referrer policy
95
- headers['referrer-policy'] = 'strict-origin-when-cross-origin'
94
+ # Add the configured referrer policy without replacing an explicit
95
+ # response-level value.
96
+ configured_policy = request && request.env['otto.security_config']&.referrer_policy
97
+ policy = self['referrer-policy'] || configured_policy || Otto::Security::Config::DEFAULT_REFERRER_POLICY
98
+ headers['referrer-policy'] ||= policy
96
99
 
97
100
  # Add frame options
98
101
  headers['x-frame-options'] = 'DENY'
data/lib/otto/route.rb CHANGED
@@ -187,7 +187,7 @@ class Otto
187
187
  # Add security headers
188
188
  if otto.respond_to?(:security_config) && otto.security_config
189
189
  otto.security_config.security_headers.each do |header, value|
190
- res.headers[header] = value
190
+ res.headers[header] ||= value
191
191
  end
192
192
  end
193
193
 
@@ -148,7 +148,7 @@ class Otto
148
148
  # Add security headers
149
149
  if otto_instance.respond_to?(:security_config) && otto_instance.security_config
150
150
  otto_instance.security_config.security_headers.each do |header, value|
151
- res.headers[header] = value
151
+ res.headers[header] ||= value
152
152
  end
153
153
  end
154
154
 
@@ -10,14 +10,25 @@ class Otto
10
10
  #
11
11
  # Logic classes use a constrained signature: initialize(context, params, locale)
12
12
  # - context: The authentication strategy result (user info, session data)
13
- # - params: Merged request parameters (URL params + body + extra_params)
13
+ # - params: Merged request parameters. Path captures win over form and
14
+ # query parameters (Rack order between those two), and a JSON body sits
15
+ # below all of them.
14
16
  # - locale: The locale string from env['otto.locale']
15
17
  #
18
+ # A Logic class may also declare a +route_params:+ keyword on initialize
19
+ # to receive the path captures on their own, separate from anything the
20
+ # caller sent in the query string or body:
21
+ #
22
+ # def initialize(context, params, locale, route_params: {})
23
+ #
16
24
  # IMPORTANT: Logic classes do NOT receive the Rack request or env hash.
17
25
  # This is intentional - Logic classes work with clean, authenticated contexts.
18
26
  # For endpoints requiring direct request access (sessions, cookies, headers,
19
27
  # or logout flows), use controller handlers (Controller#action or Controller.action).
20
28
  class LogicClassHandler < BaseHandler
29
+ # Parameter types (from Method#parameters) that name a keyword argument
30
+ ROUTE_PARAMS_KEYWORD_TYPES = %i[key keyreq].freeze
31
+
21
32
  protected
22
33
 
23
34
  # Invoke Logic class with constrained signature
@@ -36,8 +47,13 @@ class Otto
36
47
  # Get locale
37
48
  locale = env['otto.locale'] || 'en'
38
49
 
39
- # Instantiate Logic class
40
- logic = target_class.new(strategy_result, logic_params, locale)
50
+ # Instantiate Logic class. Path captures travel separately so a Logic
51
+ # class can read the router's value by name, whatever the body sent.
52
+ logic = if accepts_route_params?
53
+ target_class.new(strategy_result, logic_params, locale, route_params: route_params)
54
+ else
55
+ target_class.new(strategy_result, logic_params, locale)
56
+ end
41
57
 
42
58
  # Execute standard Logic class lifecycle
43
59
  logic.raise_concerns if logic.respond_to?(:raise_concerns)
@@ -57,50 +73,86 @@ class Otto
57
73
  [result, context]
58
74
  end
59
75
 
76
+ # Path captures matched by the router, keyed by the route's placeholder
77
+ # names. Always present in +params+ as well (where they take precedence);
78
+ # this copy is for Logic classes that must not confuse a path value with
79
+ # one the caller put in the query string or body.
80
+ #
81
+ # @return [Hash] Indifferent hash of route captures; empty for literal routes
82
+ def route_params
83
+ Otto::Static.indifferent_params((@extra_params || {}).dup)
84
+ end
85
+
86
+ # Whether the Logic class constructor declares a +route_params:+ keyword
87
+ # (or accepts arbitrary keywords). Logic classes opt in by declaring it;
88
+ # the three-positional signature keeps working unchanged.
89
+ #
90
+ # @return [Boolean]
91
+ def accepts_route_params?
92
+ return @accepts_route_params if defined?(@accepts_route_params)
93
+
94
+ @accepts_route_params = target_class.instance_method(:initialize).parameters.any? do |type, name|
95
+ type == :keyrest || (ROUTE_PARAMS_KEYWORD_TYPES.include?(type) && name == :route_params)
96
+ end
97
+ end
98
+
60
99
  # Extract logic parameters including JSON body parsing
100
+ #
101
+ # Starts from req.params, which already carries Rack's own precedence
102
+ # (form body over query string) with the path captures merged on top by
103
+ # setup_request_response. A JSON body sits below all of those: a JSON
104
+ # key can never replace a path capture, a query parameter, or a form
105
+ # field. JSON bodies are only read for methods that carry a body (never
106
+ # GET or HEAD).
107
+ #
61
108
  # @param req [Rack::Request] Request object
62
109
  # @param env [Hash] Rack environment
63
110
  # @return [Hash] Parameters for Logic class
64
111
  def extract_logic_params(req, env)
65
- # req.params already has extra_params merged and indifferent_params applied
66
- # by setup_request_response in BaseHandler
67
112
  logic_params = req.params.dup
113
+ return logic_params unless json_body?(req)
68
114
 
69
- # Handle JSON request bodies
70
- if req.content_type&.include?('application/json') && req.body.size.positive?
71
- logic_params = parse_json_body(req, env, logic_params)
72
- end
115
+ json_params = parse_json_body(req, env)
116
+ return logic_params if json_params.empty?
117
+
118
+ Otto::Static.indifferent_params(json_params.merge(logic_params))
119
+ end
73
120
 
74
- logic_params
121
+ # Whether the request carries a JSON body worth parsing
122
+ # @param req [Rack::Request] Request object
123
+ # @return [Boolean]
124
+ def json_body?(req)
125
+ return false if req.get? || req.head?
126
+ return false unless req.content_type&.include?('application/json')
127
+
128
+ req.body&.size&.positive? || false
75
129
  end
76
130
 
77
131
  # Parse JSON request body with error handling
78
132
  # @param req [Rack::Request] Request object
79
133
  # @param env [Hash] Rack environment
80
- # @param logic_params [Hash] Current parameters
81
- # @return [Hash] Parameters with JSON merged (or original if parsing fails)
82
- def parse_json_body(req, env, logic_params)
83
- begin
84
- req.body.rewind
85
- json_data = JSON.parse(req.body.read)
86
- logic_params = logic_params.merge(json_data) if json_data.is_a?(Hash)
87
- rescue JSON::ParserError => e
88
- # Base context pattern: create once, reuse for correlation
89
- log_context = Otto::LoggingHelpers.request_context(env)
90
-
91
- Otto.structured_log(:error, 'JSON parsing error',
92
- log_context.merge(
93
- handler: handler_name,
94
- error: e.message,
95
- error_class: e.class.name,
96
- duration: Otto::Utils.now_in_μs - @start_time
97
- ))
98
-
99
- Otto::LoggingHelpers.log_backtrace(e,
100
- log_context.merge(handler: handler_name))
101
- end
102
-
103
- logic_params
134
+ # @return [Hash] Parsed JSON object, or an empty hash when the body is
135
+ # not a JSON object or fails to parse
136
+ def parse_json_body(req, env)
137
+ req.body.rewind
138
+ json_data = JSON.parse(req.body.read)
139
+ json_data.is_a?(Hash) ? json_data : {}
140
+ rescue JSON::ParserError => e
141
+ # Base context pattern: create once, reuse for correlation
142
+ log_context = Otto::LoggingHelpers.request_context(env)
143
+
144
+ Otto.structured_log(:error, 'JSON parsing error',
145
+ log_context.merge(
146
+ handler: handler_name,
147
+ error: e.message,
148
+ error_class: e.class.name,
149
+ duration: Otto::Utils.now_in_μs - @start_time
150
+ ))
151
+
152
+ Otto::LoggingHelpers.log_backtrace(e,
153
+ log_context.merge(handler: handler_name))
154
+
155
+ {}
104
156
  end
105
157
 
106
158
  # Format handler name for Logic routes