otto 2.11.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.
@@ -105,22 +105,16 @@ class Otto
105
105
  # symlink-free root (issue #257). A missing root leaves @static_route
106
106
  # nil and the request falls through to normal routing (404), rather
107
107
  # than raising at construction.
108
- @static_route ||= build_static_route
109
- path_info = Rack::Utils.unescape(env['PATH_INFO'])
110
- path_info = '/' if path_info.to_s.empty?
111
-
112
- begin
113
- # Shared with Otto::CaddyTLS::LocalhostGuard so the guard and the
114
- # router cannot normalize a path differently (which would be a guard
115
- # bypass). See Otto::Utils.normalize_path.
116
- path_info_clean = Otto::Utils.normalize_path(env['PATH_INFO'])
117
- rescue ArgumentError => e
118
- # Log the error but don't expose details
119
- Otto.logger.error '[Otto.handle_request] Path encoding error'
120
- Otto.logger.debug "[Otto.handle_request] Error details: #{e.message}" if Otto.debug
121
- # Set a default value or use the original path_info
122
- path_info_clean = path_info
123
- end
108
+ @static_route ||= build_static_route
109
+
110
+ # The one path every dispatch stage matches. It comes from the public
111
+ # Otto::Utils.routing_path so that guards running before the router
112
+ # (Otto::CaddyTLS::LocalhostGuard, an application's own middleware)
113
+ # judge exactly this value rather than re-deriving it; a guard that
114
+ # normalized differently would be a bypass. It never raises: a
115
+ # malformed escape (%zz) is kept as written and routed like any other
116
+ # path.
117
+ path_info_clean = Otto::Utils.routing_path(env)
124
118
 
125
119
  http_verb = env['REQUEST_METHOD'].upcase.to_sym
126
120
  literal_routes = routes_literal[http_verb] || {}
@@ -220,7 +214,9 @@ class Otto
220
214
  # Rack::Files unescapes PATH_INFO, so escape the canonical path to
221
215
  # survive the round trip (escape_path preserves '/').
222
216
  static_env['PATH_INFO'] = "/#{Rack::Utils.escape_path(static_file.relative)}"
223
- files.call(static_env)
217
+ status, headers, body = files.call(static_env)
218
+ headers['referrer-policy'] ||= @security_config.referrer_policy
219
+ [status, headers, body]
224
220
  end
225
221
 
226
222
  # Rack::Files rooted at the root +static_file+ was validated against.
@@ -249,9 +245,9 @@ class Otto
249
245
  end
250
246
 
251
247
  # +dispatch_path+ is the normalized path from #handle_request (see the
252
- # +dispatch_path+ comment there): +Otto::Utils.normalize_path+ output with
248
+ # +dispatch_path+ comment there): +Otto::Utils.routing_path+ output with
253
249
  # root's empty string mapped back to '/' so the anchored route regexes can
254
- # match. It is deliberately NOT the raw +normalize_path+ value.
250
+ # match. It is deliberately NOT the raw +routing_path+ value.
255
251
  def match_dynamic_route(env, dispatch_path, http_verb, literal_routes)
256
252
  extra_params = {}
257
253
  found_route = nil
@@ -319,7 +315,7 @@ class Otto
319
315
  # @return [Array] a fresh Rack triple
320
316
  def not_found_response(env)
321
317
  fallback = @not_found
322
- return Otto::Static.not_found if fallback.nil?
318
+ return Otto::Static.not_found(@security_config) if fallback.nil?
323
319
 
324
320
  resolve_fallback_response(:not_found, fallback, env)
325
321
  end
data/lib/otto/env_keys.rb CHANGED
@@ -217,7 +217,8 @@ class Otto
217
217
  # Note: setting CLIENT_IP yourself is out of contract — it trips the
218
218
  # middleware's idempotency guard, so the unmasked address is never
219
219
  # captured and this capability degrades to a logged fail-closed
220
- # check that denies every range.
220
+ # check that denies every range. Test harnesses build both keys
221
+ # with Otto::Testing.env_for (require 'otto/testing').
221
222
  IP_MATCH = 'otto.ip_match'
222
223
 
223
224
  # Privacy-safe masked IP address
@@ -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