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.
- 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 +3 -2
- data/AGENTS.md +18 -0
- data/CHANGELOG.rst +132 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +8 -8
- data/README.md +6 -0
- data/docs/guides/configuration_freezing.md +17 -6
- data/docs/guides/forwarded-authority.md +5 -1
- data/docs/guides/privacy.md +5 -0
- data/docs/guides/routing.md +180 -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 +9 -4
- data/lib/otto/core/error_handler.rb +40 -2
- data/lib/otto/core/file_safety.rb +24 -8
- data/lib/otto/core/router.rb +53 -24
- data/lib/otto/core/static_mounts.rb +172 -0
- data/lib/otto/core.rb +1 -0
- 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 +45 -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 +126 -2
- metadata +5 -2
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
|