otto 2.5.0 → 2.6.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/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/CHANGELOG.rst +65 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +5 -5
- data/examples/advanced_routes/README.md +49 -0
- data/examples/advanced_routes/config.rb +15 -2
- data/examples/advanced_routes/routes +12 -0
- data/examples/lambda_handlers/README.md +128 -0
- data/examples/lambda_handlers/config.ru +26 -0
- data/examples/lambda_handlers/handlers.rb +75 -0
- data/examples/lambda_handlers/routes +28 -0
- data/lib/otto/core/configuration.rb +103 -1
- data/lib/otto/core/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +11 -0
- data/lib/otto/errors.rb +7 -0
- data/lib/otto/mcp/route_parser.rb +15 -4
- data/lib/otto/privacy/config.rb +37 -1
- data/lib/otto/privacy/core.rb +18 -1
- data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
- data/lib/otto/privacy/user_agent_privacy.rb +64 -0
- data/lib/otto/privacy.rb +1 -0
- data/lib/otto/request.rb +27 -0
- data/lib/otto/route.rb +103 -41
- data/lib/otto/route_definition.rb +56 -6
- data/lib/otto/route_handlers/base.rb +4 -0
- data/lib/otto/route_handlers/factory.rb +15 -0
- data/lib/otto/route_handlers/lambda.rb +47 -32
- data/lib/otto/security/config.rb +100 -5
- data/lib/otto/security/csp/policy.rb +135 -3
- data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
- data/lib/otto/security/csrf_validation.rb +75 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -6
- data/lib/otto/security.rb +1 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +26 -2
- metadata +9 -2
|
@@ -43,9 +43,14 @@ class Otto
|
|
|
43
43
|
# @param report_to_url [String, nil] absolute URL configured for the
|
|
44
44
|
# modern Reporting API; its presence (not its value) toggles the
|
|
45
45
|
# `report-to <group>` directive (omitted when nil/empty)
|
|
46
|
+
# @param directive_overrides [Hash, nil] per-directive overrides merged
|
|
47
|
+
# into the base set before reporting directives are appended. See
|
|
48
|
+
# {.merge_directives} for the accepted shape (replace a directive's
|
|
49
|
+
# sources, add a new directive, or remove one with a nil/false value).
|
|
46
50
|
# @return [String] complete CSP policy string
|
|
47
|
-
def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil)
|
|
51
|
+
def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil, directive_overrides: nil)
|
|
48
52
|
directives = development_mode ? development_directives(nonce) : production_directives(nonce)
|
|
53
|
+
directives = merge_directives(directives, directive_overrides)
|
|
49
54
|
uri_directive = report_uri_directive(report_uri)
|
|
50
55
|
to_directive = report_to_directive(report_to_url)
|
|
51
56
|
directives += ["#{uri_directive};"] if uri_directive
|
|
@@ -88,6 +93,133 @@ class Otto
|
|
|
88
93
|
"report-to #{REPORTING_GROUP}"
|
|
89
94
|
end
|
|
90
95
|
|
|
96
|
+
# Merge per-directive overrides into a base directive set.
|
|
97
|
+
#
|
|
98
|
+
# This is the customization seam the hardcoded directive sets previously
|
|
99
|
+
# lacked: a consuming app can adjust ANY directive (e.g. re-allow
|
|
100
|
+
# `data:` workers via `worker-src 'self' data: blob:`) without
|
|
101
|
+
# vendoring the gem. Order is
|
|
102
|
+
# preserved — an override that matches an existing directive replaces it
|
|
103
|
+
# in place; an override for a directive not in the base set is appended
|
|
104
|
+
# after the base directives (before any reporting directives).
|
|
105
|
+
#
|
|
106
|
+
# Override values:
|
|
107
|
+
# - a String → the directive's source list verbatim, e.g.
|
|
108
|
+
# `'worker-src' => "'self' blob:"` yields `worker-src 'self' blob:;`
|
|
109
|
+
# - an Array → sources joined with a single space, e.g.
|
|
110
|
+
# `%w['self' blob:]`
|
|
111
|
+
# - `nil`/`false` → REMOVE the directive from the emitted policy
|
|
112
|
+
#
|
|
113
|
+
# Directive names are matched case-insensitively (CSP directive names are
|
|
114
|
+
# case-insensitive) and may be given as Strings or Symbols.
|
|
115
|
+
#
|
|
116
|
+
# @note The per-request nonce is embedded in `script-src` (production)
|
|
117
|
+
# and cannot be reproduced in a static override, so replacing (or
|
|
118
|
+
# removing) `script-src` strips the nonce from the emitted header and
|
|
119
|
+
# DEFEATS nonce protection: the browser then accepts any inline script
|
|
120
|
+
# the page carries the nonce attribute on. Overriding `script-src`
|
|
121
|
+
# while nonce mode is enabled therefore disables nonce enforcement;
|
|
122
|
+
# {Otto::Security::Config} logs a warning when such an override is
|
|
123
|
+
# configured. Override other directives freely.
|
|
124
|
+
#
|
|
125
|
+
# @param directives [Array<String>] base directive strings, each `;`-terminated
|
|
126
|
+
# @param overrides [Hash, nil] directive name => source list / nil
|
|
127
|
+
# @return [Array<String>] merged directive strings, each `;`-terminated
|
|
128
|
+
# @raise [ArgumentError] if an override name or source token contains a
|
|
129
|
+
# `;`, newline, or carriage return (see {.build_directive})
|
|
130
|
+
def merge_directives(directives, overrides)
|
|
131
|
+
return directives if overrides.nil? || overrides.empty?
|
|
132
|
+
|
|
133
|
+
normalized = normalize_overrides(overrides)
|
|
134
|
+
consumed = {}
|
|
135
|
+
|
|
136
|
+
merged = directives.filter_map do |directive|
|
|
137
|
+
name = directive_name(directive)
|
|
138
|
+
next directive unless normalized.key?(name)
|
|
139
|
+
|
|
140
|
+
consumed[name] = true
|
|
141
|
+
build_directive(name, normalized[name])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
normalized.each do |name, value|
|
|
145
|
+
next if consumed[name]
|
|
146
|
+
|
|
147
|
+
appended = build_directive(name, value)
|
|
148
|
+
merged << appended if appended
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
merged
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Normalize an overrides hash to lowercased, hyphenated String keys so
|
|
155
|
+
# lookups are case-insensitive and Symbol/String keys are
|
|
156
|
+
# interchangeable. Underscores map to hyphens (no CSP directive contains
|
|
157
|
+
# an underscore) so a Symbol key like `:worker_src` addresses the
|
|
158
|
+
# `worker-src` directive. Blank keys are dropped.
|
|
159
|
+
#
|
|
160
|
+
# @param overrides [Hash]
|
|
161
|
+
# @return [Hash{String=>Object}]
|
|
162
|
+
def normalize_overrides(overrides)
|
|
163
|
+
overrides.each_with_object({}) do |(key, value), acc|
|
|
164
|
+
name = key.to_s.strip.downcase.tr('_', '-')
|
|
165
|
+
acc[name] = value unless name.empty?
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The directive name (first token) of a `;`-terminated directive string,
|
|
170
|
+
# lowercased for case-insensitive matching.
|
|
171
|
+
#
|
|
172
|
+
# @param directive [String]
|
|
173
|
+
# @return [String]
|
|
174
|
+
def directive_name(directive)
|
|
175
|
+
directive.to_s.strip.delete_suffix(';').split(/\s+/, 2).first.to_s.downcase
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Build a single `;`-terminated directive string from a name and an
|
|
179
|
+
# override value, or nil when the value signals removal (nil/false).
|
|
180
|
+
#
|
|
181
|
+
# The directive name and each source token are validated against CSP's
|
|
182
|
+
# separator characters: a name or token containing `;` (which separates
|
|
183
|
+
# directives), a newline, or a carriage return raises {ArgumentError}
|
|
184
|
+
# rather than silently injecting extra directives — a real footgun when
|
|
185
|
+
# overrides come from env/config files. (The `false` removal sentinel is
|
|
186
|
+
# checked before {Array} so a bare `false` never becomes a `[false]`
|
|
187
|
+
# source list.)
|
|
188
|
+
#
|
|
189
|
+
# @param name [String] directive name
|
|
190
|
+
# @param value [String, Array, nil, false] source list, or nil/false to remove
|
|
191
|
+
# @return [String, nil]
|
|
192
|
+
# @raise [ArgumentError] if the name or a source token contains a `;`,
|
|
193
|
+
# newline, or carriage return
|
|
194
|
+
def build_directive(name, value)
|
|
195
|
+
return nil if value.nil? || value == false
|
|
196
|
+
|
|
197
|
+
reject_injection!('directive name', name)
|
|
198
|
+
sources = Array(value).filter_map do |token|
|
|
199
|
+
str = token.to_s.strip
|
|
200
|
+
next if str.empty?
|
|
201
|
+
|
|
202
|
+
reject_injection!("source for #{name}", str)
|
|
203
|
+
str
|
|
204
|
+
end.join(' ')
|
|
205
|
+
sources.empty? ? "#{name};" : "#{name} #{sources};"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Raise {ArgumentError} when +text+ carries a CSP directive/token
|
|
209
|
+
# separator (`;`, newline, or carriage return) that would let an
|
|
210
|
+
# override break out of its directive and inject another.
|
|
211
|
+
#
|
|
212
|
+
# @param label [String] what is being validated (for the error message)
|
|
213
|
+
# @param text [String]
|
|
214
|
+
# @return [void]
|
|
215
|
+
# @raise [ArgumentError] if +text+ contains `;`, `\n`, or `\r`
|
|
216
|
+
def reject_injection!(label, text)
|
|
217
|
+
return unless text.match?(/[;\r\n]/)
|
|
218
|
+
|
|
219
|
+
raise ArgumentError,
|
|
220
|
+
"invalid CSP #{label}: #{text.inspect} contains a ';', newline, or carriage return"
|
|
221
|
+
end
|
|
222
|
+
|
|
91
223
|
# CSP directives for the development environment.
|
|
92
224
|
#
|
|
93
225
|
# Development mode allows inline scripts/styles and hot reloading
|
|
@@ -108,7 +240,7 @@ class Otto
|
|
|
108
240
|
"form-action 'self';",
|
|
109
241
|
"frame-ancestors 'none';",
|
|
110
242
|
"manifest-src 'self';",
|
|
111
|
-
"worker-src 'self'
|
|
243
|
+
"worker-src 'self' blob:;",
|
|
112
244
|
]
|
|
113
245
|
end
|
|
114
246
|
|
|
@@ -132,7 +264,7 @@ class Otto
|
|
|
132
264
|
"form-action 'self';", # Restrict form submissions to same origin
|
|
133
265
|
"frame-ancestors 'none';", # Prevent site from being embedded in frames
|
|
134
266
|
"manifest-src 'self';", # Allow web app manifests from same origin
|
|
135
|
-
"worker-src 'self'
|
|
267
|
+
"worker-src 'self' blob:;", # Allow Workers from same origin and blob: URLs
|
|
136
268
|
]
|
|
137
269
|
end
|
|
138
270
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# lib/otto/security/csrf_enforcement_wrapper.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative 'csrf_validation'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Security
|
|
9
|
+
# Per-route CSRF enforcement, applied at the handler layer.
|
|
10
|
+
#
|
|
11
|
+
# CSRF enforcement lives here rather than in the global +CSRFMiddleware+
|
|
12
|
+
# because +csrf=exempt+ is a per-route option: it is only known once a
|
|
13
|
+
# route has been matched (the middleware runs *ahead* of route matching and
|
|
14
|
+
# never sees route options, so a global block could not honor exemption —
|
|
15
|
+
# issue #186). This wrapper runs after matching, alongside +RouteAuthWrapper+,
|
|
16
|
+
# where +route_definition.csrf_exempt?+ is directly available. It is composed
|
|
17
|
+
# by +HandlerFactory+ only when CSRF protection is enabled, and wraps outside
|
|
18
|
+
# +RouteAuthWrapper+ so a forged unsafe request is rejected before any
|
|
19
|
+
# authentication work runs.
|
|
20
|
+
#
|
|
21
|
+
# The global +CSRFMiddleware+ retains only token *injection* into HTML
|
|
22
|
+
# responses (a response-shaping concern that is method/content-type based,
|
|
23
|
+
# not route based, so it stays global).
|
|
24
|
+
class CSRFEnforcementWrapper
|
|
25
|
+
include CSRFValidation
|
|
26
|
+
|
|
27
|
+
attr_reader :wrapped_handler, :route_definition, :config
|
|
28
|
+
|
|
29
|
+
# @param wrapped_handler [#call] the handler to guard
|
|
30
|
+
# @param route_definition [Otto::RouteDefinition] the matched route
|
|
31
|
+
# @param config [Otto::Security::Config] security config exposing CSRF settings
|
|
32
|
+
def initialize(wrapped_handler, route_definition, config)
|
|
33
|
+
@wrapped_handler = wrapped_handler
|
|
34
|
+
@route_definition = route_definition
|
|
35
|
+
@config = config
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param env [Hash] Rack environment
|
|
39
|
+
# @param extra_params [Hash] Additional parameters passed through to the handler
|
|
40
|
+
# @return [Array] Rack response tuple
|
|
41
|
+
def call(env, extra_params = {})
|
|
42
|
+
return wrapped_handler.call(env, extra_params) unless enforce?(env)
|
|
43
|
+
|
|
44
|
+
request = Otto::Request.new(env)
|
|
45
|
+
return wrapped_handler.call(env, extra_params) if valid_csrf_token?(request)
|
|
46
|
+
|
|
47
|
+
Otto.structured_log(:warn, 'CSRF validation failed',
|
|
48
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
49
|
+
handler: route_definition.definition,
|
|
50
|
+
referrer: request.referrer
|
|
51
|
+
))
|
|
52
|
+
csrf_error_response
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Whether this request must present a valid CSRF token. Only unsafe
|
|
58
|
+
# methods on a non-exempt route are enforced when protection is enabled.
|
|
59
|
+
def enforce?(env)
|
|
60
|
+
return false unless config&.csrf_enabled?
|
|
61
|
+
return false if safe_method?(env['REQUEST_METHOD'])
|
|
62
|
+
return false if route_definition.csrf_exempt?
|
|
63
|
+
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# lib/otto/security/csrf_validation.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Security
|
|
9
|
+
# Shared CSRF token mechanics.
|
|
10
|
+
#
|
|
11
|
+
# Both the global +CSRFMiddleware+ (which injects tokens into HTML
|
|
12
|
+
# responses) and the per-route +CSRFEnforcementWrapper+ (which enforces
|
|
13
|
+
# tokens on unsafe requests, honoring +csrf=exempt+) mix this in so the
|
|
14
|
+
# two cannot drift on what counts as a safe method, where a token may be
|
|
15
|
+
# carried, or how a rejection is shaped. The including object must expose a
|
|
16
|
+
# +@config+ (an +Otto::Security::Config+).
|
|
17
|
+
module CSRFValidation
|
|
18
|
+
# HTTP methods that never mutate state and so are exempt from token
|
|
19
|
+
# validation (RFC 7231 safe methods plus TRACE).
|
|
20
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
|
21
|
+
|
|
22
|
+
# Static 403 body. Frozen once so a rejected request does not re-serialize
|
|
23
|
+
# the same JSON on every call.
|
|
24
|
+
CSRF_ERROR_BODY = {
|
|
25
|
+
error: 'CSRF token validation failed',
|
|
26
|
+
message: 'The request could not be authenticated. Please refresh the page and try again.',
|
|
27
|
+
}.to_json.freeze
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def safe_method?(method)
|
|
32
|
+
SAFE_METHODS.include?(method.to_s.upcase)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def valid_csrf_token?(request)
|
|
36
|
+
token = extract_csrf_token(request)
|
|
37
|
+
# Reject nil / blank / whitespace-only tokens up front, before creating
|
|
38
|
+
# a session or running HMAC verification — obviously-malformed input
|
|
39
|
+
# should not cause session churn (#186 review).
|
|
40
|
+
return false if token.nil? || token.strip.empty?
|
|
41
|
+
|
|
42
|
+
session_id = extract_session_id(request)
|
|
43
|
+
@config.verify_csrf_token(token, session_id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def extract_csrf_token(request)
|
|
47
|
+
# Try form parameter first
|
|
48
|
+
token = request.params[@config.csrf_token_key]
|
|
49
|
+
|
|
50
|
+
# Try header if not in params
|
|
51
|
+
token ||= request.env[@config.csrf_header_key]
|
|
52
|
+
|
|
53
|
+
# Try alternative header format
|
|
54
|
+
token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
|
|
55
|
+
|
|
56
|
+
token
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def extract_session_id(request)
|
|
60
|
+
@config.get_or_create_session_id(request)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def csrf_error_response
|
|
64
|
+
[
|
|
65
|
+
403,
|
|
66
|
+
{
|
|
67
|
+
'content-type' => 'application/json',
|
|
68
|
+
'content-length' => CSRF_ERROR_BODY.bytesize.to_s,
|
|
69
|
+
},
|
|
70
|
+
[CSRF_ERROR_BODY],
|
|
71
|
+
]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -7,10 +7,18 @@ require_relative '../config'
|
|
|
7
7
|
class Otto
|
|
8
8
|
module Security
|
|
9
9
|
module Middleware
|
|
10
|
-
#
|
|
10
|
+
# Global middleware that injects CSRF tokens into HTML responses.
|
|
11
|
+
#
|
|
12
|
+
# Token *enforcement* deliberately does NOT live here. This middleware
|
|
13
|
+
# runs ahead of route matching, so it cannot see per-route options like
|
|
14
|
+
# +csrf=exempt+ (issue #186); enforcing globally would block routes an
|
|
15
|
+
# operator explicitly exempted. Enforcement is applied after matching by
|
|
16
|
+
# +Otto::Security::CSRFEnforcementWrapper+ at the handler layer, where the
|
|
17
|
+
# route definition is available. This middleware keeps only the
|
|
18
|
+
# response-shaping half — injecting a fresh token into HTML responses so
|
|
19
|
+
# forms and meta tags can carry it — which is method/content-type based
|
|
20
|
+
# and correctly stays global.
|
|
11
21
|
class CSRFMiddleware
|
|
12
|
-
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
|
13
|
-
|
|
14
22
|
def initialize(app, config = nil)
|
|
15
23
|
@app = app
|
|
16
24
|
@config = config || Otto::Security::Config.new
|
|
@@ -19,60 +27,14 @@ class Otto
|
|
|
19
27
|
def call(env)
|
|
20
28
|
return @app.call(env) unless @config.csrf_enabled?
|
|
21
29
|
|
|
22
|
-
request
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
response = @app.call(env)
|
|
27
|
-
response = inject_csrf_token(request, response) if html_response?(response)
|
|
28
|
-
return response
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Validate CSRF token for unsafe methods
|
|
32
|
-
unless valid_csrf_token?(request)
|
|
33
|
-
# Log CSRF validation failure
|
|
34
|
-
Otto.structured_log(:warn, "CSRF validation failed",
|
|
35
|
-
Otto::LoggingHelpers.request_context(env).merge(
|
|
36
|
-
referrer: request.referrer
|
|
37
|
-
)
|
|
38
|
-
)
|
|
39
|
-
return csrf_error_response
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
@app.call(env)
|
|
30
|
+
request = Otto::Request.new(env)
|
|
31
|
+
response = @app.call(env)
|
|
32
|
+
response = inject_csrf_token(request, response) if html_response?(response)
|
|
33
|
+
response
|
|
43
34
|
end
|
|
44
35
|
|
|
45
36
|
private
|
|
46
37
|
|
|
47
|
-
def safe_method?(method)
|
|
48
|
-
SAFE_METHODS.include?(method.upcase)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def valid_csrf_token?(request)
|
|
52
|
-
token = extract_csrf_token(request)
|
|
53
|
-
return false if token.nil? || token.empty?
|
|
54
|
-
|
|
55
|
-
session_id = @config.get_or_create_session_id(request)
|
|
56
|
-
@config.verify_csrf_token(token, session_id)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def extract_csrf_token(request)
|
|
60
|
-
# Try form parameter first
|
|
61
|
-
token = request.params[@config.csrf_token_key]
|
|
62
|
-
|
|
63
|
-
# Try header if not in params
|
|
64
|
-
token ||= request.env[@config.csrf_header_key]
|
|
65
|
-
|
|
66
|
-
# Try alternative header format
|
|
67
|
-
token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
|
|
68
|
-
|
|
69
|
-
token
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def extract_session_id(request)
|
|
73
|
-
@config.get_or_create_session_id(request)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
38
|
def inject_csrf_token(request, response)
|
|
77
39
|
return response unless response.is_a?(Array) && response.length >= 3
|
|
78
40
|
|
|
@@ -138,24 +100,6 @@ class Otto
|
|
|
138
100
|
content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
|
|
139
101
|
content_type&.include?('text/html')
|
|
140
102
|
end
|
|
141
|
-
|
|
142
|
-
def csrf_error_response
|
|
143
|
-
[
|
|
144
|
-
403,
|
|
145
|
-
{
|
|
146
|
-
'content-type' => 'application/json',
|
|
147
|
-
'content-length' => csrf_error_body.bytesize.to_s,
|
|
148
|
-
},
|
|
149
|
-
[csrf_error_body],
|
|
150
|
-
]
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def csrf_error_body
|
|
154
|
-
{
|
|
155
|
-
error: 'CSRF token validation failed',
|
|
156
|
-
message: 'The request could not be authenticated. Please refresh the page and try again.',
|
|
157
|
-
}.to_json
|
|
158
|
-
end
|
|
159
103
|
end
|
|
160
104
|
end
|
|
161
105
|
end
|
|
@@ -69,12 +69,6 @@ class Otto
|
|
|
69
69
|
|
|
70
70
|
private
|
|
71
71
|
|
|
72
|
-
# Apply privacy settings to environment
|
|
73
|
-
#
|
|
74
|
-
# @param env [Hash] Rack environment
|
|
75
|
-
# Apply privacy settings to environment
|
|
76
|
-
#
|
|
77
|
-
# @param env [Hash] Rack environment
|
|
78
72
|
# Apply privacy settings to environment
|
|
79
73
|
#
|
|
80
74
|
# @param env [Hash] Rack environment
|
|
@@ -116,6 +110,15 @@ class Otto
|
|
|
116
110
|
# Canonical client IP downstream reads (exempt: not masked)
|
|
117
111
|
env['otto.client_ip'] = client_ip
|
|
118
112
|
# Don't mask forwarded headers for private IPs
|
|
113
|
+
#
|
|
114
|
+
# This early return also means NONE of the privacy fingerprint
|
|
115
|
+
# values are produced for exempt IPs — no otto.privacy.fingerprint,
|
|
116
|
+
# masked_ip, hashed_ip, geo_country, or correlation_hash. That is
|
|
117
|
+
# intentional and consistent: the correlation hash targets public
|
|
118
|
+
# audit-trail traffic, so req.ip_correlation_hash is nil for
|
|
119
|
+
# localhost / RFC-1918 addresses (the default dev path) even when a
|
|
120
|
+
# correlation_secret is configured. Set mask_private_ips to treat
|
|
121
|
+
# private IPs as public and run them through the full path below.
|
|
119
122
|
Otto.logger.debug "[IPPrivacyMiddleware] Private/localhost IP exempted: #{client_ip}" if Otto.debug
|
|
120
123
|
return
|
|
121
124
|
end
|
|
@@ -134,6 +137,14 @@ class Otto
|
|
|
134
137
|
env['otto.privacy.hashed_ip'] = fingerprint.hashed_ip
|
|
135
138
|
env['otto.privacy.geo_country'] = fingerprint.country
|
|
136
139
|
|
|
140
|
+
# Fingerprint the FULL client IP here — while client_ip is still the
|
|
141
|
+
# real address, before REMOTE_ADDR is masked below — so it identifies
|
|
142
|
+
# the visitor, not just their /24. Uses the caller's stable secret
|
|
143
|
+
# (unlike hashed_ip's daily key), so the same IP matches across days.
|
|
144
|
+
# nil when no secret is set. The real IP is never written to env; only
|
|
145
|
+
# this hash leaves the middleware.
|
|
146
|
+
env['otto.privacy.correlation_hash'] = correlation_hash(client_ip)
|
|
147
|
+
|
|
137
148
|
# CRITICAL: Replace REMOTE_ADDR and forwarded headers with masked values
|
|
138
149
|
# This ensures downstream code (rate limiting, auth, logging, Rack's request.ip)
|
|
139
150
|
# automatically uses the masked values without modification
|
|
@@ -158,6 +169,23 @@ class Otto
|
|
|
158
169
|
# or env['otto.original_referer']. This prevents accidental leakage of the real values.
|
|
159
170
|
end
|
|
160
171
|
|
|
172
|
+
# Fingerprint of the full client IP, keyed with the caller's stable
|
|
173
|
+
# correlation secret (not hashed_ip's daily-rotating key). The same IP
|
|
174
|
+
# and secret always produce the same value, so it can match a visitor
|
|
175
|
+
# across days — which the daily hash can't.
|
|
176
|
+
#
|
|
177
|
+
# Returns nil when no secret is configured. An empty key is never used
|
|
178
|
+
# to hash (that would let anyone reverse it); we return nil rather than
|
|
179
|
+
# raise, since "no secret" just means the feature is off.
|
|
180
|
+
#
|
|
181
|
+
# @param client_ip [String] Resolved full client IP (pre-masking)
|
|
182
|
+
# @return [String, nil] Hex HMAC-SHA256 digest, or nil when unconfigured
|
|
183
|
+
def correlation_hash(client_ip)
|
|
184
|
+
secret = @config.correlation_secret
|
|
185
|
+
return nil if secret.nil? || secret.empty?
|
|
186
|
+
|
|
187
|
+
Otto::Privacy::IPPrivacy.hash_ip(client_ip, secret)
|
|
188
|
+
end
|
|
161
189
|
|
|
162
190
|
# Set or clear a Rack env header in a SPEC-compliant way.
|
|
163
191
|
#
|
data/lib/otto/security.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative 'security/authorization_error'
|
|
|
9
9
|
require_relative 'security/config'
|
|
10
10
|
require_relative 'security/configurator'
|
|
11
11
|
require_relative 'security/middleware/csrf_middleware'
|
|
12
|
+
require_relative 'security/csrf_enforcement_wrapper'
|
|
12
13
|
require_relative 'security/middleware/validation_middleware'
|
|
13
14
|
require_relative 'security/middleware/rate_limit_middleware'
|
|
14
15
|
require_relative 'security/middleware/ip_privacy_middleware'
|
data/lib/otto/version.rb
CHANGED
data/lib/otto.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
+
require 'concurrent'
|
|
5
6
|
require 'json'
|
|
6
7
|
require 'logger'
|
|
7
8
|
require 'securerandom'
|
|
@@ -75,7 +76,8 @@ class Otto
|
|
|
75
76
|
end
|
|
76
77
|
@logger = Logger.new($stdout, Logger::INFO)
|
|
77
78
|
|
|
78
|
-
attr_reader :routes, :routes_literal, :routes_static, :route_definitions,
|
|
79
|
+
attr_reader :routes, :routes_literal, :routes_static, :route_definitions,
|
|
80
|
+
:routes_by_definition, :option,
|
|
79
81
|
:static_route, :security_config, :locale_config, :auth_config,
|
|
80
82
|
:route_handler_factory, :mcp_server, :caddy_tls_server, :security, :middleware,
|
|
81
83
|
:error_handlers, :request_class, :response_class
|
|
@@ -107,6 +109,10 @@ class Otto
|
|
|
107
109
|
end
|
|
108
110
|
alias options option
|
|
109
111
|
|
|
112
|
+
# Read-only view of assembled instance options. LambdaHandler resolves its
|
|
113
|
+
# registry via otto_instance.config[:lambda_handlers].
|
|
114
|
+
alias config option
|
|
115
|
+
|
|
110
116
|
# Main Rack application interface
|
|
111
117
|
def call(env)
|
|
112
118
|
# Freeze configuration on first request (thread-safe).
|
|
@@ -166,10 +172,24 @@ class Otto
|
|
|
166
172
|
private
|
|
167
173
|
|
|
168
174
|
def initialize_core_state
|
|
169
|
-
|
|
175
|
+
# The GET cache is a Concurrent::Map, not a plain Hash: lazy static-file
|
|
176
|
+
# discovery (Core::Router#handle_request, Core::FileSafety#add_static_path)
|
|
177
|
+
# writes into it at request time, after freeze_configuration! has already
|
|
178
|
+
# deep-frozen the rest of the routing state. Deep-freezing this cache too
|
|
179
|
+
# would turn every as-yet-uncached static file request into a 500
|
|
180
|
+
# (FrozenError) in production (issue #185), so it is intentionally excluded
|
|
181
|
+
# from deep_freeze_value in Configuration#freeze_configuration! and kept as
|
|
182
|
+
# a structure that is both mutable post-freeze and safe under concurrent
|
|
183
|
+
# request threads.
|
|
184
|
+
@routes_static = { GET: Concurrent::Map.new }
|
|
170
185
|
@routes = { GET: [] }
|
|
171
186
|
@routes_literal = { GET: {} }
|
|
172
187
|
@route_definitions = {}
|
|
188
|
+
# All routes per definition string, in load order. A definition string is
|
|
189
|
+
# not unique — the same handler can be mounted at several verb/path pairs —
|
|
190
|
+
# so reverse lookups (Otto#uri) consult this index instead of the
|
|
191
|
+
# single-route @route_definitions entry (issue #190).
|
|
192
|
+
@routes_by_definition = {}
|
|
173
193
|
@security_config = Otto::Security::Config.new
|
|
174
194
|
@middleware = Otto::Core::MiddlewareStack.new
|
|
175
195
|
# Initialize @auth_config first so it can be shared with the configurator
|
|
@@ -234,6 +254,10 @@ class Otto
|
|
|
234
254
|
|
|
235
255
|
# Initialize MCP server
|
|
236
256
|
configure_mcp(opts)
|
|
257
|
+
|
|
258
|
+
# Validate and freeze the lambda handler registry (issue #41).
|
|
259
|
+
# Runs last so misconfiguration fails fast at construction.
|
|
260
|
+
configure_lambda_handlers(opts)
|
|
237
261
|
end
|
|
238
262
|
|
|
239
263
|
class << self
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: otto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -203,6 +203,10 @@ files:
|
|
|
203
203
|
- examples/caddy_tls_demo/routes
|
|
204
204
|
- examples/caddy_tls_demo/standalone.ru
|
|
205
205
|
- examples/error_handler_registration.rb
|
|
206
|
+
- examples/lambda_handlers/README.md
|
|
207
|
+
- examples/lambda_handlers/config.ru
|
|
208
|
+
- examples/lambda_handlers/handlers.rb
|
|
209
|
+
- examples/lambda_handlers/routes
|
|
206
210
|
- examples/logging_improvements.rb
|
|
207
211
|
- examples/mcp_demo/README.md
|
|
208
212
|
- examples/mcp_demo/app.rb
|
|
@@ -254,6 +258,7 @@ files:
|
|
|
254
258
|
- lib/otto/privacy/geo_resolver.rb
|
|
255
259
|
- lib/otto/privacy/ip_privacy.rb
|
|
256
260
|
- lib/otto/privacy/redacted_fingerprint.rb
|
|
261
|
+
- lib/otto/privacy/user_agent_privacy.rb
|
|
257
262
|
- lib/otto/request.rb
|
|
258
263
|
- lib/otto/response.rb
|
|
259
264
|
- lib/otto/response_handlers.rb
|
|
@@ -302,6 +307,8 @@ files:
|
|
|
302
307
|
- lib/otto/security/csp/report_middleware.rb
|
|
303
308
|
- lib/otto/security/csp/writer.rb
|
|
304
309
|
- lib/otto/security/csrf.rb
|
|
310
|
+
- lib/otto/security/csrf_enforcement_wrapper.rb
|
|
311
|
+
- lib/otto/security/csrf_validation.rb
|
|
305
312
|
- lib/otto/security/middleware/csrf_middleware.rb
|
|
306
313
|
- lib/otto/security/middleware/ip_privacy_middleware.rb
|
|
307
314
|
- lib/otto/security/middleware/rate_limit_middleware.rb
|