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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby-lint.yml +1 -1
  3. data/.github/workflows/yardoc.yml +1 -1
  4. data/CHANGELOG.rst +135 -0
  5. data/Gemfile +1 -1
  6. data/Gemfile.lock +5 -5
  7. data/README.md +54 -0
  8. data/examples/advanced_routes/README.md +49 -0
  9. data/examples/advanced_routes/config.rb +15 -2
  10. data/examples/advanced_routes/routes +12 -0
  11. data/examples/lambda_handlers/README.md +128 -0
  12. data/examples/lambda_handlers/config.ru +26 -0
  13. data/examples/lambda_handlers/handlers.rb +75 -0
  14. data/examples/lambda_handlers/routes +28 -0
  15. data/lib/otto/core/configuration.rb +103 -1
  16. data/lib/otto/core/middleware_stack.rb +1 -0
  17. data/lib/otto/core/router.rb +67 -10
  18. data/lib/otto/core/uri_generator.rb +36 -2
  19. data/lib/otto/env_keys.rb +21 -0
  20. data/lib/otto/errors.rb +7 -0
  21. data/lib/otto/mcp/route_parser.rb +15 -4
  22. data/lib/otto/privacy/config.rb +37 -1
  23. data/lib/otto/privacy/core.rb +18 -1
  24. data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
  25. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  26. data/lib/otto/privacy.rb +1 -0
  27. data/lib/otto/request.rb +41 -0
  28. data/lib/otto/response.rb +80 -43
  29. data/lib/otto/route.rb +103 -41
  30. data/lib/otto/route_definition.rb +56 -6
  31. data/lib/otto/route_handlers/base.rb +4 -0
  32. data/lib/otto/route_handlers/factory.rb +15 -0
  33. data/lib/otto/route_handlers/lambda.rb +47 -32
  34. data/lib/otto/security/config.rb +137 -84
  35. data/lib/otto/security/configurator.rb +16 -0
  36. data/lib/otto/security/core.rb +32 -0
  37. data/lib/otto/security/csp/emit_middleware.rb +91 -0
  38. data/lib/otto/security/csp/nonce.rb +78 -0
  39. data/lib/otto/security/csp/policy.rb +273 -0
  40. data/lib/otto/security/csp/writer.rb +197 -0
  41. data/lib/otto/security/csp.rb +20 -8
  42. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  43. data/lib/otto/security/csrf_validation.rb +75 -0
  44. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  45. data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -6
  46. data/lib/otto/security.rb +1 -0
  47. data/lib/otto/version.rb +1 -1
  48. data/lib/otto.rb +26 -2
  49. metadata +13 -2
@@ -7,10 +7,18 @@ require_relative '../config'
7
7
  class Otto
8
8
  module Security
9
9
  module Middleware
10
- # Middleware that provides Cross-Site Request Forgery (CSRF) protection
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 = Otto::Request.new(env)
23
-
24
- # Skip CSRF protection for safe methods
25
- if safe_method?(request.request_method)
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
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.4.0'
6
+ VERSION = '2.6.0'
7
7
  end
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, :option,
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
- @routes_static = { GET: {} }
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.0
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-01 00:00:00.000000000 Z
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