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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/.rubocop_todo.yml +2 -2
- data/CHANGELOG.rst +100 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +8 -8
- data/README.md +6 -0
- data/docs/guides/forwarded-authority.md +5 -1
- data/docs/guides/privacy.md +5 -0
- data/docs/guides/routing.md +68 -5
- data/docs/guides/testing-guide.md +115 -2
- data/lib/otto/caddy_tls/localhost_guard.rb +6 -6
- data/lib/otto/core/configuration.rb +5 -3
- data/lib/otto/core/router.rb +16 -20
- data/lib/otto/env_keys.rb +2 -1
- data/lib/otto/privacy/config.rb +19 -13
- data/lib/otto/response.rb +5 -2
- data/lib/otto/route.rb +1 -1
- data/lib/otto/route_handlers/base.rb +1 -1
- data/lib/otto/route_handlers/logic_class.rb +86 -34
- data/lib/otto/security/config.rb +349 -303
- data/lib/otto/security/configurator.rb +82 -45
- data/lib/otto/security/core.rb +4 -2
- data/lib/otto/security/csp/report_middleware.rb +17 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +3 -5
- data/lib/otto/security/rate_limiter.rb +8 -5
- data/lib/otto/security/trusted_proxy_config.rb +396 -0
- data/lib/otto/static.rb +7 -6
- data/lib/otto/testing.rb +148 -0
- data/lib/otto/utils.rb +63 -14
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +7 -2
- metadata +4 -2
data/lib/otto/core/router.rb
CHANGED
|
@@ -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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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.
|
|
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 +
|
|
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
|
data/lib/otto/privacy/config.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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]
|
|
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]
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
# @
|
|
81
|
-
#
|
|
82
|
-
def parse_json_body(req, env
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|