otto 2.4.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 +135 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +5 -5
- data/README.md +54 -0
- 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/middleware_stack.rb +1 -0
- data/lib/otto/core/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +21 -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 +41 -0
- data/lib/otto/response.rb +80 -43
- 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 +137 -84
- data/lib/otto/security/configurator.rb +16 -0
- data/lib/otto/security/core.rb +32 -0
- data/lib/otto/security/csp/emit_middleware.rb +91 -0
- data/lib/otto/security/csp/nonce.rb +78 -0
- data/lib/otto/security/csp/policy.rb +273 -0
- data/lib/otto/security/csp/writer.rb +197 -0
- data/lib/otto/security/csp.rb +20 -8
- 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 +13 -2
|
@@ -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
|
|
@@ -294,10 +299,16 @@ files:
|
|
|
294
299
|
- lib/otto/security/constant_resolver.rb
|
|
295
300
|
- lib/otto/security/core.rb
|
|
296
301
|
- lib/otto/security/csp.rb
|
|
302
|
+
- lib/otto/security/csp/emit_middleware.rb
|
|
303
|
+
- lib/otto/security/csp/nonce.rb
|
|
297
304
|
- lib/otto/security/csp/parser.rb
|
|
305
|
+
- lib/otto/security/csp/policy.rb
|
|
298
306
|
- lib/otto/security/csp/report.rb
|
|
299
307
|
- lib/otto/security/csp/report_middleware.rb
|
|
308
|
+
- lib/otto/security/csp/writer.rb
|
|
300
309
|
- lib/otto/security/csrf.rb
|
|
310
|
+
- lib/otto/security/csrf_enforcement_wrapper.rb
|
|
311
|
+
- lib/otto/security/csrf_validation.rb
|
|
301
312
|
- lib/otto/security/middleware/csrf_middleware.rb
|
|
302
313
|
- lib/otto/security/middleware/ip_privacy_middleware.rb
|
|
303
314
|
- lib/otto/security/middleware/rate_limit_middleware.rb
|