otto 2.0.0.pre7 → 2.0.0.pre8

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69a46d80f1fcc3f2472c44554fd7102645226e264befdd23af9871ad4f5fafaf
4
- data.tar.gz: 54d8b433d49b549e17d3406aa011b6f4f80c50eefc5a6fa51dde1772fde8b8f2
3
+ metadata.gz: b094c2fc179b84631bf53b1e4b7f3cbcd661f83e1f8a88ab6c1f22d3e8b11cbd
4
+ data.tar.gz: 70a7588c86b8b3f31968b577a298f05c09cad5ef5d9fcf028f0feece2963e8fb
5
5
  SHA512:
6
- metadata.gz: b518140043ad7ab983fb99e85ac9643ca4527385930d77a4b01e2b241b7212854fbc5e8875bc58b17e3d17c66fcfc6455b85587d023ceec13006876bec79a0d9
7
- data.tar.gz: f64b842b58acc746ad39d58eac0fb57c245f1db29755f89d259d64bd127db3043034a9e8ada0ee19c04d969ec1bb34b515f5dfc1fcf36c80ceaeb9123ac2de6e
6
+ metadata.gz: c9e8f2f46cf51bc0799dd6030e52a48c1665b9978efb8bc686977be722e61b3c553f17db91d9fd795447b5b4882ac95af404a71e14f9b65d3935a992ecb768f1
7
+ data.tar.gz: d0c6f98edf2f468297dcb19bb9743b1a2858f2497cf40d2fc4332550a883542f2b7e1a1de8ac4b9f9d352b40c53e974d6c99aecee2679abc6b38868c02ae5bd6
@@ -27,15 +27,12 @@ jobs:
27
27
  uses: ruby/setup-ruby@v1
28
28
  with:
29
29
  ruby-version: 3.4
30
- bundler-cache: true
31
30
 
32
- - name: Configure Bundler for secure gem installation
31
+ - name: Install dependencies with optional groups
33
32
  run: |
34
33
  bundle config set --local path 'vendor/bundle'
35
- bundle config set --local deployment 'false'
36
-
37
- - name: Install dependencies
38
- run: bundle install --jobs 4 --retry 3
34
+ bundle config set --local with 'development test'
35
+ bundle install --jobs 4 --retry 3
39
36
 
40
37
  - name: Run Reek analysis
41
38
  run: |
data/CHANGELOG.rst CHANGED
@@ -7,10 +7,76 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.0.0.pre8:
11
+
12
+ 2.0.0.pre8 — 2025-11-27
13
+ =======================
14
+
15
+ Fixed
16
+ -----
17
+
18
+ - Routes declaring ``response=json`` now return 401 JSON errors instead of 302 redirects when authentication fails, regardless of Accept header. The route's explicit configuration takes precedence over content negotiation.
19
+
20
+ .. _changelog-2.0.0.pre7:
21
+
22
+ 2.0.0.pre7 — 2025-11-24
23
+ =======================
24
+
25
+ Added
26
+ -----
27
+
28
+ - Error handler registration system for expected business logic errors. Register handlers with ``otto.register_error_handler(ErrorClass, status: 404, log_level: :info)`` to return proper HTTP status codes and avoid logging expected errors as 500s with backtraces. Supports custom response handlers via blocks for complete control over error responses.
29
+
30
+ Changed
31
+ -------
32
+
33
+ - Backtrace logging now always logs at ERROR level (was DEBUG) with sanitized file paths for security. Backtraces for unhandled 500 errors are always logged regardless of ``OTTO_DEBUG`` setting, with paths sanitized to prevent exposing system information (project files show relative paths, gems show ``[GEM] name-version/path``, Ruby stdlib shows ``[RUBY] filename``).
34
+ - Increased backtrace limit from 10 to 20 lines for critical errors to provide better debugging context.
35
+
36
+ AI Assistance
37
+ -------------
38
+
39
+ - Implemented error handler registration architecture with comprehensive test coverage (17 test cases) using sequential thinking to work through security implications and design decisions. AI assisted with path sanitization strategy, error classification patterns, and ensuring backward compatibility with existing error handling.
40
+
41
+ Improved backtrace sanitization security and readability
42
+ --------------------------------------------------------
43
+
44
+ **Security Enhancements:**
45
+
46
+ - Fixed bundler gem path detection to correctly sanitize git-based gems
47
+ - Now properly handles nested gem paths like ``/gems/3.4.0/bundler/gems/otto-abc123/``
48
+ - Strips git hash suffixes from bundler gems (``otto-abc123def456`` → ``otto``)
49
+ - Removes version numbers from regular gems (``rack-3.2.4`` → ``rack``)
50
+ - Prevents exposure of absolute paths, usernames, and project names in logs
51
+
52
+ **Improvements:**
53
+
54
+ - Bundler gems now show as ``[GEM] otto/lib/otto/route.rb:142`` instead of ``[GEM] 3.4.0/bundler/gems/...``
55
+ - Regular gems show cleaner output: ``[GEM] rack/lib/rack.rb:20`` instead of ``[GEM] rack-3.2.4/lib/rack.rb:20``
56
+ - Multi-hyphenated gem names handled correctly (``active-record-import-1.5.0`` → ``active-record-import``)
57
+ - Better handling of version-only directory names in gem paths
58
+
59
+ **Documentation:**
60
+
61
+ - Added comprehensive backtrace sanitization section to CLAUDE.md
62
+ - Documented security guarantees and sanitization rules
63
+ - Added examples showing before/after path transformations
64
+ - Created comprehensive test suite for backtrace sanitization
65
+
66
+ **Rationale:**
67
+
68
+ Raw backtraces expose sensitive information:
69
+ - Usernames (``/Users/alice/``, ``/home/admin/``)
70
+ - Project structure and internal organization
71
+ - Gem installation paths and Ruby versions
72
+ - System architecture details
73
+
74
+ This improvement ensures all backtraces are sanitized automatically, preventing accidental leakage of sensitive system information while maintaining readability for debugging.
75
+
10
76
  .. _changelog-2.0.0.pre6:
11
77
 
12
- 2.0.0.pre6 — TBD
13
- ================
78
+ 2.0.0.pre6
79
+ ==========
14
80
 
15
81
  Changed
16
82
  -------
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.0.0.pre7)
4
+ otto (2.0.0.pre8)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  facets (~> 3.1)
7
7
  ipaddr (~> 1, < 2.0)
@@ -10,7 +10,14 @@ require_relative 'base'
10
10
  class Otto
11
11
  module RouteHandlers
12
12
  # Handler for class methods (existing Otto pattern)
13
- # Maintains backward compatibility for Controller.action patterns
13
+ # Route syntax: Controller.action
14
+ #
15
+ # Class methods receive full Rack request/response access:
16
+ # - Method signature: def self.action(request, response)
17
+ # - Direct access to sessions, cookies, headers, and the raw env
18
+ #
19
+ # Use this handler for endpoints requiring request-level control (logout,
20
+ # session management, cookie manipulation, custom header handling).
14
21
  class ClassMethodHandler < BaseHandler
15
22
  def call(env, extra_params = {})
16
23
  start_time = Otto::Utils.now_in_μs
@@ -8,7 +8,14 @@ require_relative 'base'
8
8
  class Otto
9
9
  module RouteHandlers
10
10
  # Handler for instance methods (existing Otto pattern)
11
- # Maintains backward compatibility for Controller#action patterns
11
+ # Route syntax: Controller#action
12
+ #
13
+ # Controller instances receive full Rack request/response access:
14
+ # - initialize(request, response) with Rack::Request and Rack::Response
15
+ # - Direct access to sessions, cookies, headers, and the raw env
16
+ #
17
+ # Use this handler for endpoints requiring request-level control (logout,
18
+ # session management, cookie manipulation, custom header handling).
12
19
  class InstanceMethodHandler < BaseHandler
13
20
  def call(env, extra_params = {})
14
21
  start_time = Otto::Utils.now_in_μs
@@ -9,8 +9,16 @@ require_relative 'base'
9
9
  class Otto
10
10
  module RouteHandlers
11
11
  # Handler for Logic classes (new in Otto Framework Enhancement)
12
- # Handles Logic class routes with the modern RequestContext pattern
13
- # Logic classes use signature: initialize(context, params, locale)
12
+ #
13
+ # Logic classes use a constrained signature: initialize(context, params, locale)
14
+ # - context: The authentication strategy result (user info, session data)
15
+ # - params: Merged request parameters (URL params + body + extra_params)
16
+ # - locale: The locale string from env['otto.locale']
17
+ #
18
+ # IMPORTANT: Logic classes do NOT receive the Rack request or env hash.
19
+ # This is intentional - Logic classes work with clean, authenticated contexts.
20
+ # For endpoints requiring direct request access (sessions, cookies, headers,
21
+ # or logout flows), use controller handlers (Controller#action or Controller.action).
14
22
  class LogicClassHandler < BaseHandler
15
23
  def call(env, extra_params = {})
16
24
  start_time = Otto::Utils.now_in_μs
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Otto
4
+ module Security
5
+ module Authentication
6
+ module RouteAuthWrapperComponents
7
+ # Builds HTTP error responses for authentication/authorization failures
8
+ #
9
+ # Handles content negotiation (JSON vs HTML) and applies security headers.
10
+ # Route's declared response_type takes precedence over Accept header.
11
+ #
12
+ # @example
13
+ # builder = ResponseBuilder.new(route_definition, auth_config, security_config)
14
+ # response = builder.unauthorized(env, "Invalid token")
15
+ # response = builder.forbidden(env, "Admin role required")
16
+ # response = builder.auth_failure(env, auth_failure_result)
17
+ #
18
+ class ResponseBuilder
19
+ # @param route_definition [RouteDefinition] Route with response_type info
20
+ # @param auth_config [Hash] Auth config with :login_path for HTML redirects
21
+ # @param security_config [SecurityConfig, nil] Optional security config for headers
22
+ def initialize(route_definition, auth_config, security_config = nil)
23
+ @route_definition = route_definition
24
+ @auth_config = auth_config
25
+ @security_config = security_config
26
+ end
27
+
28
+ # Generate response for authentication failure
29
+ #
30
+ # @param env [Hash] Rack environment
31
+ # @param result [AuthFailure] Failure result from strategy
32
+ # @return [Array] Rack response array
33
+ def auth_failure(env, result)
34
+ wants_json?(env) ? json_auth_error(result) : html_auth_error(result)
35
+ end
36
+
37
+ # Generate 401 Unauthorized response
38
+ #
39
+ # @param env [Hash] Rack environment
40
+ # @param message [String] Error message
41
+ # @return [Array] Rack response array
42
+ def unauthorized(env, message)
43
+ if wants_json?(env)
44
+ json_response(401, error: message)
45
+ else
46
+ text_response(401, message)
47
+ end
48
+ end
49
+
50
+ # Generate 403 Forbidden response
51
+ #
52
+ # @param env [Hash] Rack environment
53
+ # @param message [String] Error message
54
+ # @return [Array] Rack response array
55
+ def forbidden(env, message)
56
+ if wants_json?(env)
57
+ json_response(403, error: 'Forbidden', message: message)
58
+ else
59
+ text_response(403, message)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ # Determine if response should be JSON based on route config and Accept header
66
+ #
67
+ # Route's declared response type takes precedence over Accept header.
68
+ # This ensures API routes (response=json) always get JSON errors.
69
+ #
70
+ # @param env [Hash] Rack environment
71
+ # @return [Boolean] true if response should be JSON
72
+ def wants_json?(env)
73
+ return true if @route_definition.response_type == 'json'
74
+
75
+ accept_header = env['HTTP_ACCEPT'] || ''
76
+ accept_header.include?('application/json')
77
+ end
78
+
79
+ # Generate JSON 401 response for auth failure
80
+ def json_auth_error(result)
81
+ json_response(401,
82
+ error: 'Authentication Required',
83
+ message: result.failure_reason || 'Not authenticated',
84
+ timestamp: Time.now.to_i)
85
+ end
86
+
87
+ # Generate HTML 401 response (redirect to login)
88
+ def html_auth_error(_result)
89
+ login_path = @auth_config[:login_path] || '/signin'
90
+ headers = { 'location' => login_path }
91
+ merge_security_headers!(headers)
92
+ [302, headers, ["Redirecting to #{login_path}"]]
93
+ end
94
+
95
+ # Build a JSON response with security headers
96
+ def json_response(status, body_hash)
97
+ body = body_hash.to_json
98
+ headers = {
99
+ 'content-type' => 'application/json',
100
+ 'content-length' => body.bytesize.to_s,
101
+ }
102
+ merge_security_headers!(headers)
103
+ [status, headers, [body]]
104
+ end
105
+
106
+ # Build a plain text response with security headers
107
+ def text_response(status, message)
108
+ headers = { 'content-type' => 'text/plain' }
109
+ merge_security_headers!(headers)
110
+ [status, headers, [message]]
111
+ end
112
+
113
+ # Merge security headers into response headers
114
+ def merge_security_headers!(headers)
115
+ return unless @security_config
116
+
117
+ headers.merge!(@security_config.security_headers)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Otto
4
+ module Security
5
+ module Authentication
6
+ module RouteAuthWrapperComponents
7
+ # Handles Layer 1 (route-level) role-based authorization
8
+ #
9
+ # Extracts user roles from authentication results and checks against
10
+ # route requirements using OR logic (user needs ANY of the required roles).
11
+ #
12
+ # @example
13
+ # authorizer = RoleAuthorization.new(route_definition)
14
+ # authorizer.check!(strategy_result, env) # raises or returns true
15
+ #
16
+ # @note This is Layer 1 authorization only. Layer 2 (resource-level)
17
+ # authorization should be handled in Logic classes via raise_concerns.
18
+ #
19
+ class RoleAuthorization
20
+ # @param route_definition [RouteDefinition] Route with role requirements
21
+ def initialize(route_definition)
22
+ @route_definition = route_definition
23
+ end
24
+
25
+ # Check if authentication result satisfies role requirements
26
+ #
27
+ # @param result [StrategyResult] Authentication result
28
+ # @param env [Hash] Rack environment (for logging)
29
+ # @return [true] if authorized
30
+ # @return [Hash] failure info if not authorized: { authorized: false, required: [...], actual: [...] }
31
+ def check(result, env)
32
+ role_requirements = @route_definition.role_requirements
33
+ return true if role_requirements.empty?
34
+
35
+ user_roles = extract_roles(result)
36
+
37
+ # OR logic: user needs ANY of the required roles
38
+ if (user_roles & role_requirements).any?
39
+ log_success(env, role_requirements, user_roles)
40
+ true
41
+ else
42
+ log_failure(env, role_requirements, user_roles, result)
43
+ {
44
+ authorized: false,
45
+ required: role_requirements,
46
+ actual: user_roles,
47
+ }
48
+ end
49
+ end
50
+
51
+ # Check authorization, returning boolean
52
+ #
53
+ # @param result [StrategyResult] Authentication result
54
+ # @return [Boolean] true if authorized
55
+ def authorized?(result)
56
+ role_requirements = @route_definition.role_requirements
57
+ return true if role_requirements.empty?
58
+
59
+ user_roles = extract_roles(result)
60
+ (user_roles & role_requirements).any?
61
+ end
62
+
63
+ # Get the role requirements for error messages
64
+ #
65
+ # @return [Array<String>] Required roles
66
+ def requirements
67
+ @route_definition.role_requirements
68
+ end
69
+
70
+ private
71
+
72
+ # Extract user roles from authentication result
73
+ #
74
+ # Supports multiple role sources in order of precedence:
75
+ # 1. result.user_roles (Array)
76
+ # 2. result.user[:roles] (Array)
77
+ # 3. result.user['roles'] (Array)
78
+ # 4. result.metadata[:user_roles] (Array)
79
+ #
80
+ # @param result [StrategyResult] Authentication result
81
+ # @return [Array<String>] Array of role strings
82
+ def extract_roles(result)
83
+ # Try direct user_roles accessor (e.g., from RoleStrategy)
84
+ return Array(result.user_roles) if result.respond_to?(:user_roles) && result.user_roles
85
+
86
+ # Try user hash/object with roles
87
+ if result.user
88
+ roles = result.user[:roles] || result.user['roles']
89
+ return Array(roles) if roles
90
+ end
91
+
92
+ # Try metadata
93
+ return Array(result.metadata[:user_roles]) if result.metadata && result.metadata[:user_roles]
94
+
95
+ # No roles found
96
+ []
97
+ end
98
+
99
+ def log_success(env, required_roles, user_roles)
100
+ Otto.structured_log(:debug, 'Role authorization succeeded',
101
+ Otto::LoggingHelpers.request_context(env).merge(
102
+ required_roles: required_roles,
103
+ user_roles: user_roles,
104
+ matched_roles: user_roles & required_roles
105
+ ))
106
+ end
107
+
108
+ def log_failure(env, required_roles, user_roles, result)
109
+ Otto.structured_log(:warn, 'Role authorization failed',
110
+ Otto::LoggingHelpers.request_context(env).merge(
111
+ required_roles: required_roles,
112
+ user_roles: user_roles,
113
+ user_id: result.user_id
114
+ ))
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Otto
4
+ module Security
5
+ module Authentication
6
+ module RouteAuthWrapperComponents
7
+ # Resolves authentication strategy names to strategy instances
8
+ #
9
+ # Handles strategy lookup with caching and pattern matching:
10
+ # - Exact match: 'authenticated' → looks up auth_config[:auth_strategies]['authenticated']
11
+ # - Prefix match: 'custom:value' → looks up 'custom' strategy
12
+ #
13
+ # Results are cached to avoid repeated lookups for the same requirement.
14
+ #
15
+ # @example
16
+ # resolver = StrategyResolver.new(auth_config)
17
+ # strategy, name = resolver.resolve('session')
18
+ # strategy, name = resolver.resolve('oauth:google') # prefix match
19
+ #
20
+ class StrategyResolver
21
+ # @param auth_config [Hash] Auth configuration with :auth_strategies key
22
+ def initialize(auth_config)
23
+ @auth_config = auth_config
24
+ @cache = {}
25
+ end
26
+
27
+ # Resolve a requirement string to a strategy instance
28
+ #
29
+ # @param requirement [String] Auth requirement from route (e.g., 'session', 'oauth:google')
30
+ # @return [Array<AuthStrategy, String>, Array<nil, nil>] Tuple of [strategy, name] or [nil, nil]
31
+ def resolve(requirement)
32
+ return [nil, nil] unless @auth_config && @auth_config[:auth_strategies]
33
+
34
+ # Check cache first
35
+ return @cache[requirement] if @cache.key?(requirement)
36
+
37
+ result = find_strategy(requirement)
38
+ @cache[requirement] = result
39
+ result
40
+ end
41
+
42
+ # Clear the strategy cache
43
+ def clear_cache
44
+ @cache.clear
45
+ end
46
+
47
+ private
48
+
49
+ def find_strategy(requirement)
50
+ strategies = @auth_config[:auth_strategies]
51
+
52
+ # Try exact match first - highest priority
53
+ strategy = strategies[requirement]
54
+ return [strategy, requirement] if strategy
55
+
56
+ # For colon-separated requirements like "custom:value", try prefix match
57
+ if requirement.include?(':')
58
+ prefix = requirement.split(':', 2).first
59
+ prefix_strategy = strategies[prefix]
60
+ return [prefix_strategy, prefix] if prefix_strategy
61
+ end
62
+
63
+ [nil, nil]
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,28 +1,27 @@
1
- # lib/otto/security/authentication/route_auth_wrapper.rb
2
- #
3
1
  # frozen_string_literal: true
4
2
 
3
+ require_relative 'route_auth_wrapper/strategy_resolver'
4
+ require_relative 'route_auth_wrapper/response_builder'
5
+ require_relative 'route_auth_wrapper/role_authorization'
6
+
5
7
  class Otto
6
8
  module Security
7
9
  module Authentication
8
- # Wraps route handlers to enforce authentication requirements
10
+ # Wraps route handlers with authentication and authorization
9
11
  #
10
- # This wrapper executes authentication strategies AFTER routing but BEFORE
11
- # route handler execution. This solves the architectural issue where
12
- # middleware-based authentication runs before routing (so can't access route info).
12
+ # This is the main orchestrator that:
13
+ # - Sets anonymous StrategyResult for unauthenticated routes
14
+ # - Enforces authentication for protected routes
15
+ # - Supports multi-strategy with OR logic (first success wins)
16
+ # - Performs Layer 1 (route-level) role authorization
13
17
  #
14
- # Flow:
15
- # 1. Route matched (route_definition available)
16
- # 2. RouteAuthWrapper#call invoked
17
- # 3. Execute auth strategy based on route's auth_requirement
18
- # 4. Set env['otto.strategy_result']
19
- # 5. If auth fails, return 401 or redirect
20
- # 6. If auth succeeds, call wrapped handler
18
+ # @example Basic usage
19
+ # wrapper = RouteAuthWrapper.new(handler, route_def, auth_config)
20
+ # response = wrapper.call(env)
21
21
  #
22
- # @example
23
- # handler = InstanceMethodHandler.new(route_def, otto)
24
- # wrapped = RouteAuthWrapper.new(handler, route_def, auth_config)
25
- # wrapped.call(env, extra_params)
22
+ # @see RouteAuthWrapper::StrategyResolver for strategy lookup
23
+ # @see RouteAuthWrapper::ResponseBuilder for error responses
24
+ # @see RouteAuthWrapper::RoleAuthorization for role checking
26
25
  #
27
26
  class RouteAuthWrapper
28
27
  attr_reader :wrapped_handler, :route_definition, :auth_config, :security_config
@@ -30,17 +29,17 @@ class Otto
30
29
  def initialize(wrapped_handler, route_definition, auth_config, security_config = nil)
31
30
  @wrapped_handler = wrapped_handler
32
31
  @route_definition = route_definition
33
- @auth_config = auth_config # Hash: { auth_strategies: {}, default_auth_strategy: 'publicly' }
32
+ @auth_config = auth_config
34
33
  @security_config = security_config
35
- @strategy_cache = {} # Cache resolved strategies to avoid repeated lookups
34
+
35
+ # Initialize extracted components
36
+ @strategy_resolver = RouteAuthWrapperComponents::StrategyResolver.new(auth_config)
37
+ @response_builder = RouteAuthWrapperComponents::ResponseBuilder.new(route_definition, auth_config, security_config)
38
+ @role_authorizer = RouteAuthWrapperComponents::RoleAuthorization.new(route_definition)
36
39
  end
37
40
 
38
41
  # Execute authentication then call wrapped handler
39
42
  #
40
- # For routes WITHOUT auth requirement: Sets anonymous StrategyResult
41
- # For routes WITH auth requirement: Enforces authentication
42
- # Supports multi-strategy with OR logic: auth=session,apikey,oauth
43
- #
44
43
  # @param env [Hash] Rack environment
45
44
  # @param extra_params [Hash] Additional parameters
46
45
  # @return [Array] Rack response array
@@ -48,363 +47,202 @@ class Otto
48
47
  auth_requirements = route_definition.auth_requirements
49
48
 
50
49
  # Routes without auth requirement get anonymous StrategyResult
51
- if auth_requirements.empty?
52
- # Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
53
- metadata = { ip: env['REMOTE_ADDR'] }
54
- metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
55
-
56
- result = StrategyResult.anonymous(metadata: metadata, strategy_name: 'anonymous')
57
- env['otto.strategy_result'] = result
58
- return wrapped_handler.call(env, extra_params)
59
- end
60
-
61
- # Routes WITH auth requirements: Try each strategy in order (first success wins)
50
+ return handle_anonymous_route(env, extra_params) if auth_requirements.empty?
62
51
 
63
52
  # Validate all strategies exist before executing any (fail-fast)
53
+ validation_error = validate_strategies(auth_requirements, env)
54
+ return validation_error if validation_error
55
+
56
+ # Try each strategy in order (first success wins)
57
+ authenticate_and_authorize(env, extra_params, auth_requirements)
58
+ end
59
+
60
+ private
61
+
62
+ # Handle routes without authentication requirements
63
+ def handle_anonymous_route(env, extra_params)
64
+ metadata = build_anonymous_metadata(env)
65
+ result = StrategyResult.anonymous(metadata: metadata, strategy_name: 'anonymous')
66
+ env['otto.strategy_result'] = result
67
+ wrapped_handler.call(env, extra_params)
68
+ end
69
+
70
+ # Validate all strategies exist before executing
71
+ #
72
+ # @return [Array, nil] Error response if validation fails, nil otherwise
73
+ def validate_strategies(auth_requirements, env)
64
74
  auth_requirements.each do |requirement|
65
- strategy, _strategy_name = get_strategy(requirement)
66
- unless strategy
67
- error_msg = "Authentication strategy not configured: '#{requirement}'"
68
- Otto.logger.error "[RouteAuthWrapper] #{error_msg}"
69
- return unauthorized_response(env, error_msg)
70
- end
75
+ strategy, _name = @strategy_resolver.resolve(requirement)
76
+ next if strategy
77
+
78
+ error_msg = "Authentication strategy not configured: '#{requirement}'"
79
+ Otto.logger.error "[RouteAuthWrapper] #{error_msg}"
80
+ return @response_builder.unauthorized(env, error_msg)
71
81
  end
82
+ nil
83
+ end
72
84
 
73
- last_failure = nil
85
+ # Main authentication and authorization flow
86
+ def authenticate_and_authorize(env, extra_params, auth_requirements)
74
87
  failed_strategies = []
75
88
  total_start_time = Otto::Utils.now_in_μs
76
89
 
77
90
  auth_requirements.each do |requirement|
78
- strategy, strategy_name = get_strategy(requirement)
79
-
80
- # Log strategy execution start
81
- Otto.structured_log(:debug, "Auth strategy executing",
82
- Otto::LoggingHelpers.request_context(env).merge(
83
- strategy: strategy_name,
84
- requirement: requirement,
85
- strategy_position: auth_requirements.index(requirement) + 1,
86
- total_strategies: auth_requirements.size
87
- )
88
- )
91
+ strategy, strategy_name = @strategy_resolver.resolve(requirement)
92
+
93
+ log_strategy_start(env, strategy_name, requirement, auth_requirements)
89
94
 
90
95
  # Execute the strategy
91
96
  start_time = Otto::Utils.now_in_μs
92
97
  result = strategy.authenticate(env, requirement)
93
98
  duration = Otto::Utils.now_in_μs - start_time
94
99
 
95
- # Inject strategy_name into result (Data.define objects are immutable, use #with for updates)
96
- if result.is_a?(StrategyResult)
97
- result = result.with(strategy_name: strategy_name)
98
- end
100
+ # Inject strategy_name into result
101
+ result = result.with(strategy_name: strategy_name) if result.is_a?(StrategyResult)
99
102
 
100
- # Handle authentication success (both authenticated and anonymous results) - return immediately
103
+ # Handle authentication success
101
104
  if result.is_a?(StrategyResult) && (result.authenticated? || result.anonymous?)
102
- total_duration = Otto::Utils.now_in_μs - total_start_time
103
-
104
- # Log authentication success
105
- Otto.structured_log(:info, "Auth strategy result",
106
- Otto::LoggingHelpers.request_context(env).merge(
107
- strategy: strategy_name,
108
- success: true,
109
- user_id: result.user_id,
110
- duration: duration,
111
- total_duration: total_duration,
112
- strategies_attempted: failed_strategies.size + 1
113
- )
114
- )
115
-
116
- # Set environment variables for controllers/logic on success
117
- env['otto.strategy_result'] = result
118
-
119
- # SESSION PERSISTENCE: This assignment is INTENTIONAL, not a merge operation.
120
- # We must ensure env['rack.session'] and strategy_result.session reference
121
- # the SAME object so that:
122
- # 1. Logic classes write to strategy_result.session
123
- # 2. Rack's session middleware persists env['rack.session']
124
- # 3. Changes from (1) are included in (2)
125
- #
126
- # Using merge! instead would break this - the objects must be identical.
127
- env['rack.session'] = result.session if result.is_a?(StrategyResult) && result.session
128
-
129
- # Layer 1 Authorization: Check role requirements (route-level)
130
- role_requirements = route_definition.role_requirements
131
- unless role_requirements.empty?
132
- user_roles = extract_user_roles(result)
133
-
134
- # OR logic: user needs ANY of the required roles
135
- unless (user_roles & role_requirements).any?
136
- Otto.structured_log(:warn, "Role authorization failed",
137
- Otto::LoggingHelpers.request_context(env).merge(
138
- required_roles: role_requirements,
139
- user_roles: user_roles,
140
- user_id: result.user_id
141
- )
142
- )
143
-
144
- return forbidden_response(env,
145
- "Access denied: requires one of roles: #{role_requirements.join(', ')}")
146
- end
147
-
148
- Otto.structured_log(:debug, "Role authorization succeeded",
149
- Otto::LoggingHelpers.request_context(env).merge(
150
- required_roles: role_requirements,
151
- user_roles: user_roles,
152
- matched_roles: user_roles & role_requirements
153
- )
154
- )
155
- end
156
-
157
- # Authentication and authorization succeeded - call wrapped handler
158
- return wrapped_handler.call(env, extra_params)
105
+ return handle_auth_success(env, extra_params, result, strategy_name,
106
+ duration, total_start_time, failed_strategies)
159
107
  end
160
108
 
161
109
  # Handle authentication failure - continue to next strategy
162
- if result.is_a?(AuthFailure)
163
- # Log authentication failure
164
- Otto.structured_log(:info, "Auth strategy result",
165
- Otto::LoggingHelpers.request_context(env).merge(
166
- strategy: strategy_name,
167
- success: false,
168
- failure_reason: result.failure_reason,
169
- duration: duration,
170
- remaining_strategies: auth_requirements.size - auth_requirements.index(requirement) - 1
171
- )
172
- )
173
-
174
- failed_strategies << { strategy: strategy_name, reason: result.failure_reason }
175
- last_failure = result
176
- end
177
- end
178
-
179
- # All strategies failed - return 401
180
- total_duration = Otto::Utils.now_in_μs - total_start_time
181
-
182
- # Log comprehensive failure
183
- Otto.structured_log(:warn, "All auth strategies failed",
184
- Otto::LoggingHelpers.request_context(env).merge(
185
- strategies_attempted: failed_strategies.map { |f| f[:strategy] },
186
- total_duration: total_duration,
187
- failure_count: failed_strategies.size
188
- )
189
- )
190
-
191
- # Create anonymous result with comprehensive failure info
192
- # Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
193
- metadata = {
194
- ip: env['REMOTE_ADDR'],
195
- auth_failure: "All authentication strategies failed",
196
- attempted_strategies: failed_strategies.map { |f| f[:strategy] },
197
- failure_reasons: failed_strategies.map { |f| f[:reason] }
198
- }
199
- metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
110
+ next unless result.is_a?(AuthFailure)
200
111
 
201
- # Use 'multi-strategy-failure' only for actual multi-strategy failures
202
- # For single-strategy failures, use the actual strategy name
203
- failure_strategy_name = if auth_requirements.size > 1
204
- 'multi-strategy-failure'
205
- elsif failed_strategies.any?
206
- failed_strategies.first[:strategy]
207
- else
208
- auth_requirements.first
112
+ log_strategy_failure(env, strategy_name, result, duration, auth_requirements, requirement)
113
+ failed_strategies << { strategy: strategy_name, reason: result.failure_reason }
209
114
  end
210
115
 
211
- env['otto.strategy_result'] = StrategyResult.anonymous(
212
- metadata: metadata,
213
- strategy_name: failure_strategy_name
214
- )
215
-
216
- auth_failure_response(env, last_failure || AuthFailure.new(
217
- failure_reason: "Authentication required",
218
- auth_method: failure_strategy_name
219
- ))
116
+ # All strategies failed
117
+ handle_all_strategies_failed(env, auth_requirements, failed_strategies, total_start_time)
220
118
  end
221
119
 
222
- private
120
+ # Handle successful authentication
121
+ def handle_auth_success(env, extra_params, result, strategy_name, duration, total_start_time, failed_strategies)
122
+ total_duration = Otto::Utils.now_in_μs - total_start_time
223
123
 
224
- # Get strategy from auth_config hash with pattern matching
225
- #
226
- # Supports:
227
- # - Exact match: 'authenticated' → looks up auth_config[:auth_strategies]['authenticated']
228
- # - Prefix match: 'custom:value' → looks up 'custom' strategy
229
- #
230
- # Results are cached to avoid repeated lookups for the same requirement.
231
- #
232
- # NOTE: Role-based authorization should use route option `role=admin` instead of `auth=role:admin`
233
- # to properly separate authentication from authorization concerns.
234
- #
235
- # @param requirement [String] Auth requirement from route
236
- # @return [Array<AuthStrategy, String>, Array<nil, nil>] Tuple of [strategy, name] or [nil, nil]
237
- def get_strategy(requirement)
238
- return [nil, nil] unless auth_config && auth_config[:auth_strategies]
239
-
240
- # Check cache first (cache stores [strategy, name] tuples)
241
- return @strategy_cache[requirement] if @strategy_cache.key?(requirement)
242
-
243
- # Try exact match first - this has highest priority
244
- strategy = auth_config[:auth_strategies][requirement]
245
- if strategy
246
- result = [strategy, requirement]
247
- @strategy_cache[requirement] = result
248
- return result
249
- end
124
+ log_auth_success(env, strategy_name, result, duration, total_duration, failed_strategies)
250
125
 
251
- # For colon-separated requirements like "custom:value", try prefix match
252
- if requirement.include?(':')
253
- prefix = requirement.split(':', 2).first
126
+ # Set environment variables for controllers/logic
127
+ env['otto.strategy_result'] = result
254
128
 
255
- # Check if we have a strategy registered for the prefix
256
- prefix_strategy = auth_config[:auth_strategies][prefix]
257
- if prefix_strategy
258
- result = [prefix_strategy, prefix]
259
- @strategy_cache[requirement] = result
260
- return result
261
- end
129
+ # SESSION PERSISTENCE: Ensure env['rack.session'] and strategy_result.session
130
+ # reference the SAME object for proper session persistence
131
+ env['rack.session'] = result.session if result.is_a?(StrategyResult) && result.session
132
+
133
+ # Layer 1 Authorization: Check role requirements
134
+ auth_check = @role_authorizer.check(result, env)
135
+ unless auth_check == true
136
+ return @response_builder.forbidden(env,
137
+ "Access denied: requires one of roles: #{auth_check[:required].join(', ')}")
262
138
  end
263
139
 
264
- # Cache nil results too to avoid repeated failed lookups
265
- @strategy_cache[requirement] = [nil, nil]
266
- [nil, nil]
140
+ # Authentication and authorization succeeded
141
+ wrapped_handler.call(env, extra_params)
267
142
  end
268
143
 
269
- # Generate 401 response for authentication failure
270
- #
271
- # @param env [Hash] Rack environment
272
- # @param result [AuthFailure] Failure result from strategy
273
- # @return [Array] Rack response array
274
- def auth_failure_response(env, result)
275
- # Check if request wants JSON
276
- accept_header = env['HTTP_ACCEPT'] || ''
277
- wants_json = accept_header.include?('application/json')
144
+ # Handle case when all authentication strategies fail
145
+ def handle_all_strategies_failed(env, auth_requirements, failed_strategies, total_start_time)
146
+ total_duration = Otto::Utils.now_in_μs - total_start_time
278
147
 
279
- if wants_json
280
- json_auth_error(result)
281
- else
282
- html_auth_error(result)
283
- end
284
- end
148
+ log_all_failed(env, failed_strategies, total_duration)
285
149
 
286
- # Generate JSON 401 response
287
- #
288
- # @param result [AuthFailure] Failure result
289
- # @return [Array] Rack response array
290
- def json_auth_error(result)
291
- body = {
292
- error: 'Authentication Required',
293
- message: result.failure_reason || 'Not authenticated',
294
- timestamp: Time.now.to_i
295
- }.to_json
296
-
297
- headers = {
298
- 'content-type' => 'application/json',
299
- 'content-length' => body.bytesize.to_s
300
- }
150
+ # Create anonymous result with failure info
151
+ metadata = build_failure_metadata(env, failed_strategies)
152
+ failure_strategy_name = determine_failure_strategy_name(auth_requirements, failed_strategies)
301
153
 
302
- # Add security headers if available
303
- merge_security_headers!(headers)
154
+ env['otto.strategy_result'] = StrategyResult.anonymous(
155
+ metadata: metadata,
156
+ strategy_name: failure_strategy_name
157
+ )
304
158
 
305
- [401, headers, [body]]
159
+ last_failure = if failed_strategies.any?
160
+ AuthFailure.new(
161
+ failure_reason: failed_strategies.last[:reason],
162
+ auth_method: failed_strategies.last[:strategy]
163
+ )
164
+ else
165
+ AuthFailure.new(
166
+ failure_reason: 'Authentication required',
167
+ auth_method: auth_requirements.first
168
+ )
169
+ end
170
+
171
+ @response_builder.auth_failure(env, last_failure)
306
172
  end
307
173
 
308
- # Generate HTML 401 response or redirect
309
- #
310
- # @param result [AuthFailure] Failure result
311
- # @return [Array] Rack response array
312
- def html_auth_error(result)
313
- # For HTML requests, redirect to login
314
- login_path = auth_config[:login_path] || '/signin'
315
-
316
- headers = { 'location' => login_path }
317
-
318
- # Add security headers if available
319
- merge_security_headers!(headers)
320
-
321
- [302, headers, ["Redirecting to #{login_path}"]]
174
+ # Build metadata for anonymous routes
175
+ def build_anonymous_metadata(env)
176
+ metadata = { ip: env['REMOTE_ADDR'] }
177
+ metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
178
+ metadata
322
179
  end
323
180
 
324
- # Generate generic unauthorized response
325
- #
326
- # @param env [Hash] Rack environment
327
- # @param message [String] Error message
328
- # @return [Array] Rack response array
329
- def unauthorized_response(env, message)
330
- accept_header = env['HTTP_ACCEPT'] || ''
331
- wants_json = accept_header.include?('application/json')
332
-
333
- if wants_json
334
- body = { error: message }.to_json
335
- headers = {
336
- 'content-type' => 'application/json',
337
- 'content-length' => body.bytesize.to_s
338
- }
339
- merge_security_headers!(headers)
340
- [401, headers, [body]]
341
- else
342
- headers = { 'content-type' => 'text/plain' }
343
- merge_security_headers!(headers)
344
- [401, headers, [message]]
345
- end
181
+ # Build metadata for failed authentication
182
+ def build_failure_metadata(env, failed_strategies)
183
+ metadata = {
184
+ ip: env['REMOTE_ADDR'],
185
+ auth_failure: 'All authentication strategies failed',
186
+ attempted_strategies: failed_strategies.map { |f| f[:strategy] },
187
+ failure_reasons: failed_strategies.map { |f| f[:reason] },
188
+ }
189
+ metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
190
+ metadata
346
191
  end
347
192
 
348
- # Generate 403 Forbidden response for role authorization failure
349
- #
350
- # @param env [Hash] Rack environment
351
- # @param message [String] Error message
352
- # @return [Array] Rack response array
353
- def forbidden_response(env, message)
354
- accept_header = env['HTTP_ACCEPT'] || ''
355
- wants_json = accept_header.include?('application/json')
356
-
357
- if wants_json
358
- body = { error: 'Forbidden', message: message }.to_json
359
- headers = {
360
- 'content-type' => 'application/json',
361
- 'content-length' => body.bytesize.to_s
362
- }
363
- merge_security_headers!(headers)
364
- [403, headers, [body]]
193
+ # Determine strategy name for failure response
194
+ def determine_failure_strategy_name(auth_requirements, failed_strategies)
195
+ if auth_requirements.size > 1
196
+ 'multi-strategy-failure'
197
+ elsif failed_strategies.any?
198
+ failed_strategies.first[:strategy]
365
199
  else
366
- headers = { 'content-type' => 'text/plain' }
367
- merge_security_headers!(headers)
368
- [403, headers, [message]]
200
+ auth_requirements.first
369
201
  end
370
202
  end
371
203
 
372
- # Extract user roles from authentication result
373
- #
374
- # Supports multiple role sources in order of precedence:
375
- # 1. result.user_roles (Array)
376
- # 2. result.user[:roles] (Array)
377
- # 3. result.user['roles'] (Array)
378
- # 4. result.metadata[:user_roles] (Array)
379
- #
380
- # @param result [StrategyResult] Authentication result
381
- # @return [Array<String>] Array of role strings
382
- def extract_user_roles(result)
383
- # Try direct user_roles accessor (e.g., from RoleStrategy)
384
- return Array(result.user_roles) if result.respond_to?(:user_roles) && result.user_roles
385
-
386
- # Try user hash/object with roles
387
- if result.user
388
- roles = result.user[:roles] || result.user['roles']
389
- return Array(roles) if roles
390
- end
204
+ # Logging helpers
391
205
 
392
- # Try metadata
393
- if result.metadata && result.metadata[:user_roles]
394
- return Array(result.metadata[:user_roles])
395
- end
206
+ def log_strategy_start(env, strategy_name, requirement, auth_requirements)
207
+ Otto.structured_log(:debug, 'Auth strategy executing',
208
+ Otto::LoggingHelpers.request_context(env).merge(
209
+ strategy: strategy_name,
210
+ requirement: requirement,
211
+ strategy_position: auth_requirements.index(requirement) + 1,
212
+ total_strategies: auth_requirements.size
213
+ ))
214
+ end
396
215
 
397
- # No roles found
398
- []
216
+ def log_auth_success(env, strategy_name, result, duration, total_duration, failed_strategies)
217
+ Otto.structured_log(:info, 'Auth strategy result',
218
+ Otto::LoggingHelpers.request_context(env).merge(
219
+ strategy: strategy_name,
220
+ success: true,
221
+ user_id: result.user_id,
222
+ duration: duration,
223
+ total_duration: total_duration,
224
+ strategies_attempted: failed_strategies.size + 1
225
+ ))
399
226
  end
400
227
 
401
- # Merge security headers into response headers
402
- #
403
- # @param headers [Hash] Response headers hash to merge into
404
- def merge_security_headers!(headers)
405
- return unless security_config
228
+ def log_strategy_failure(env, strategy_name, result, duration, auth_requirements, requirement)
229
+ Otto.structured_log(:info, 'Auth strategy result',
230
+ Otto::LoggingHelpers.request_context(env).merge(
231
+ strategy: strategy_name,
232
+ success: false,
233
+ failure_reason: result.failure_reason,
234
+ duration: duration,
235
+ remaining_strategies: auth_requirements.size - auth_requirements.index(requirement) - 1
236
+ ))
237
+ end
406
238
 
407
- headers.merge!(security_config.security_headers)
239
+ def log_all_failed(env, failed_strategies, total_duration)
240
+ Otto.structured_log(:warn, 'All auth strategies failed',
241
+ Otto::LoggingHelpers.request_context(env).merge(
242
+ strategies_attempted: failed_strategies.map { |f| f[:strategy] },
243
+ total_duration: total_duration,
244
+ failure_count: failed_strategies.size
245
+ ))
408
246
  end
409
247
  end
410
248
  end
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.0.0.pre7'
6
+ VERSION = '2.0.0.pre8'
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.pre7
4
+ version: 2.0.0.pre8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -169,8 +169,6 @@ files:
169
169
  - LICENSE.txt
170
170
  - README.md
171
171
  - bin/rspec
172
- - changelog.d/20251103_235431_delano_86_improve_error_logging.rst
173
- - changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst
174
172
  - changelog.d/README.md
175
173
  - changelog.d/scriv.ini
176
174
  - docs/.gitignore
@@ -289,6 +287,9 @@ files:
289
287
  - lib/otto/security/authentication/auth_failure.rb
290
288
  - lib/otto/security/authentication/auth_strategy.rb
291
289
  - lib/otto/security/authentication/route_auth_wrapper.rb
290
+ - lib/otto/security/authentication/route_auth_wrapper/response_builder.rb
291
+ - lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb
292
+ - lib/otto/security/authentication/route_auth_wrapper/strategy_resolver.rb
292
293
  - lib/otto/security/authentication/strategies/api_key_strategy.rb
293
294
  - lib/otto/security/authentication/strategies/noauth_strategy.rb
294
295
  - lib/otto/security/authentication/strategies/permission_strategy.rb
@@ -1,15 +0,0 @@
1
- Added
2
- -----
3
-
4
- - Error handler registration system for expected business logic errors. Register handlers with ``otto.register_error_handler(ErrorClass, status: 404, log_level: :info)`` to return proper HTTP status codes and avoid logging expected errors as 500s with backtraces. Supports custom response handlers via blocks for complete control over error responses.
5
-
6
- Changed
7
- -------
8
-
9
- - Backtrace logging now always logs at ERROR level (was DEBUG) with sanitized file paths for security. Backtraces for unhandled 500 errors are always logged regardless of ``OTTO_DEBUG`` setting, with paths sanitized to prevent exposing system information (project files show relative paths, gems show ``[GEM] name-version/path``, Ruby stdlib shows ``[RUBY] filename``).
10
- - Increased backtrace limit from 10 to 20 lines for critical errors to provide better debugging context.
11
-
12
- AI Assistance
13
- -------------
14
-
15
- - Implemented error handler registration architecture with comprehensive test coverage (17 test cases) using sequential thinking to work through security implications and design decisions. AI assisted with path sanitization strategy, error classification patterns, and ensuring backward compatibility with existing error handling.
@@ -1,37 +0,0 @@
1
- .. Changed in: otto
2
- .. Fixes issue:
3
-
4
- Improved backtrace sanitization security and readability
5
- ---------------------------------------------------------
6
-
7
- **Security Enhancements:**
8
-
9
- - Fixed bundler gem path detection to correctly sanitize git-based gems
10
- - Now properly handles nested gem paths like ``/gems/3.4.0/bundler/gems/otto-abc123/``
11
- - Strips git hash suffixes from bundler gems (``otto-abc123def456`` → ``otto``)
12
- - Removes version numbers from regular gems (``rack-3.2.4`` → ``rack``)
13
- - Prevents exposure of absolute paths, usernames, and project names in logs
14
-
15
- **Improvements:**
16
-
17
- - Bundler gems now show as ``[GEM] otto/lib/otto/route.rb:142`` instead of ``[GEM] 3.4.0/bundler/gems/...``
18
- - Regular gems show cleaner output: ``[GEM] rack/lib/rack.rb:20`` instead of ``[GEM] rack-3.2.4/lib/rack.rb:20``
19
- - Multi-hyphenated gem names handled correctly (``active-record-import-1.5.0`` → ``active-record-import``)
20
- - Better handling of version-only directory names in gem paths
21
-
22
- **Documentation:**
23
-
24
- - Added comprehensive backtrace sanitization section to CLAUDE.md
25
- - Documented security guarantees and sanitization rules
26
- - Added examples showing before/after path transformations
27
- - Created comprehensive test suite for backtrace sanitization
28
-
29
- **Rationale:**
30
-
31
- Raw backtraces expose sensitive information:
32
- - Usernames (``/Users/alice/``, ``/home/admin/``)
33
- - Project structure and internal organization
34
- - Gem installation paths and Ruby versions
35
- - System architecture details
36
-
37
- This improvement ensures all backtraces are sanitized automatically, preventing accidental leakage of sensitive system information while maintaining readability for debugging.