Class: Otto

Inherits:
Object
  • Object
show all
Extended by:
ClassMethods
Defined in:
lib/otto.rb,
lib/otto/route.rb,
lib/otto/static.rb,
lib/otto/version.rb,
lib/otto/design_system.rb,
lib/otto/security/csrf.rb,
lib/otto/helpers/request.rb,
lib/otto/security/config.rb,
lib/otto/helpers/response.rb,
lib/otto/security/validator.rb

Overview

lib/otto/helpers/response.rb

Defined Under Namespace

Modules: ClassMethods, DesignSystem, RequestHelpers, ResponseHelpers, Security, Static Classes: Route

Constant Summary collapse

LIB_HOME =
__dir__
VERSION =
'1.3.0'.freeze

Class Attribute Summary collapse

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from ClassMethods

default, env?, path

Constructor Details

#initialize(path = nil, opts = {}) ⇒ Otto

Returns a new instance of Otto.



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/otto.rb', line 49

def initialize(path = nil, opts = {})
  @routes_static     = { GET: {} }
  @routes            = { GET: [] }
  @routes_literal    = { GET: {} }
  @route_definitions = {}
  @option            = {
    public: nil,
    locale: 'en',
  }.merge(opts)
  @security_config   = Otto::Security::Config.new
  @middleware_stack  = []

  # Configure security based on options
  configure_security(opts)

  Otto.logger.debug "new Otto: #{opts}" if Otto.debug
  load(path) unless path.nil?
  super()
end

Class Attribute Details

.debugObject

Returns the value of attribute debug.



470
471
472
# File 'lib/otto.rb', line 470

def debug
  @debug
end

.loggerObject

Returns the value of attribute logger.



470
471
472
# File 'lib/otto.rb', line 470

def logger
  @logger
end

Instance Attribute Details

#middleware_stackObject

Returns the value of attribute middleware_stack.



47
48
49
# File 'lib/otto.rb', line 47

def middleware_stack
  @middleware_stack
end

#not_foundObject

Returns the value of attribute not_found.



47
48
49
# File 'lib/otto.rb', line 47

def not_found
  @not_found
end

#optionObject (readonly) Also known as: options

Returns the value of attribute option.



46
47
48
# File 'lib/otto.rb', line 46

def option
  @option
end

#route_definitionsObject (readonly)

Returns the value of attribute route_definitions.



46
47
48
# File 'lib/otto.rb', line 46

def route_definitions
  @route_definitions
end

#routesObject (readonly)

Returns the value of attribute routes.



46
47
48
# File 'lib/otto.rb', line 46

def routes
  @routes
end

#routes_literalObject (readonly)

Returns the value of attribute routes_literal.



46
47
48
# File 'lib/otto.rb', line 46

def routes_literal
  @routes_literal
end

#routes_staticObject (readonly)

Returns the value of attribute routes_static.



46
47
48
# File 'lib/otto.rb', line 46

def routes_static
  @routes_static
end

#security_configObject (readonly)

Returns the value of attribute security_config.



46
47
48
# File 'lib/otto.rb', line 46

def security_config
  @security_config
end

#server_errorObject

Returns the value of attribute server_error.



47
48
49
# File 'lib/otto.rb', line 47

def server_error
  @server_error
end

#static_routeObject (readonly)

Returns the value of attribute static_route.



46
47
48
# File 'lib/otto.rb', line 46

def static_route
  @static_route
end

Instance Method Details

#add_static_path(path) ⇒ Object



132
133
134
135
136
137
138
139
140
141
# File 'lib/otto.rb', line 132

def add_static_path(path)
  return unless safe_file?(path)

  base_path                      = File.split(path).first
  # Files in the root directory can refer to themselves
  base_path                      = path if base_path == '/'
  File.join(option[:public], base_path)
  Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
  routes_static[:GET][base_path] = base_path
end

#add_trusted_proxy(proxy) ⇒ Object

Add a trusted proxy server for accurate client IP detection. Only requests from trusted proxies will have their forwarded headers honored.

Examples:

otto.add_trusted_proxy('10.0.0.0/8')
otto.add_trusted_proxy(/^172\.16\./)

Parameters:

  • proxy (String, Regexp)

    IP address, CIDR range, or regex pattern



325
326
327
# File 'lib/otto.rb', line 325

def add_trusted_proxy(proxy)
  @security_config.add_trusted_proxy(proxy)
end

#call(env) ⇒ Object



143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/otto.rb', line 143

def call(env)
  # Apply middleware stack
  app = ->(e) { handle_request(e) }
  @middleware_stack.reverse_each do |middleware|
    app = middleware.new(app, @security_config)
  end

  begin
    app.call(env)
  rescue StandardError => ex
    handle_error(ex, env)
  end
end

#determine_locale(env) ⇒ Object



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/otto.rb', line 266

def determine_locale(env)
  accept_langs = env['HTTP_ACCEPT_LANGUAGE']
  accept_langs = option[:locale] if accept_langs.to_s.empty?
  locales      = []
  unless accept_langs.empty?
    locales = accept_langs.split(',').map do |l|
      l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
      l.split(';q=')
    end.sort_by do |_locale, qvalue|
      qvalue.to_f
    end.collect do |locale, _qvalue|
      locale
    end.reverse
  end
  Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
  locales.empty? ? nil : locales
end

#enable_csp!(policy = "default-src 'self'") ⇒ Object

Enable Content Security Policy (CSP) header to prevent XSS attacks. The default policy only allows resources from the same origin.

Examples:

otto.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")

Parameters:

  • policy (String) (defaults to: "default-src 'self'")

    CSP policy string (default: “default-src ‘self’”)



360
361
362
# File 'lib/otto.rb', line 360

def enable_csp!(policy = "default-src 'self'")
  @security_config.enable_csp!(policy)
end

#enable_csrf_protection!Object

Enable CSRF protection for POST, PUT, DELETE, and PATCH requests. This will automatically add CSRF tokens to HTML forms and validate them on unsafe HTTP methods.

Examples:

otto.enable_csrf_protection!


299
300
301
302
303
304
# File 'lib/otto.rb', line 299

def enable_csrf_protection!
  return if middleware_enabled?(Otto::Security::CSRFMiddleware)

  @security_config.enable_csrf_protection!
  use Otto::Security::CSRFMiddleware
end

#enable_frame_protection!(option = 'SAMEORIGIN') ⇒ Object

Enable X-Frame-Options header to prevent clickjacking attacks.

Examples:

otto.enable_frame_protection!('DENY')

Parameters:

  • option (String) (defaults to: 'SAMEORIGIN')

    Frame options: ‘DENY’, ‘SAMEORIGIN’, or ‘ALLOW-FROM uri’



369
370
371
# File 'lib/otto.rb', line 369

def enable_frame_protection!(option = 'SAMEORIGIN')
  @security_config.enable_frame_protection!(option)
end

#enable_hsts!(max_age: 31_536_000, include_subdomains: true) ⇒ Object

Enable HTTP Strict Transport Security (HSTS) header. WARNING: This can make your domain inaccessible if HTTPS is not properly configured. Only enable this when you’re certain HTTPS is working correctly.

Examples:

otto.enable_hsts!(max_age: 86400, include_subdomains: false)

Parameters:

  • max_age (Integer) (defaults to: 31_536_000)

    Maximum age in seconds (default: 1 year)

  • include_subdomains (Boolean) (defaults to: true)

    Apply to all subdomains (default: true)



350
351
352
# File 'lib/otto.rb', line 350

def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
  @security_config.enable_hsts!(max_age: max_age, include_subdomains: include_subdomains)
end

#enable_request_validation!Object

Enable request validation including input sanitization, size limits, and protection against XSS and SQL injection attacks.

Examples:

otto.enable_request_validation!


311
312
313
314
315
316
# File 'lib/otto.rb', line 311

def enable_request_validation!
  return if middleware_enabled?(Otto::Security::ValidationMiddleware)

  @security_config.input_validation = true
  use Otto::Security::ValidationMiddleware
end

#handle_request(env) ⇒ Object



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/otto.rb', line 157

def handle_request(env)
  locale             = determine_locale env
  env['rack.locale'] = locale
  @static_route    ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
  path_info          = Rack::Utils.unescape(env['PATH_INFO'])
  path_info          = '/' if path_info.to_s.empty?

  begin
    path_info_clean = path_info
      .encode(
        'UTF-8', # Target encoding
        invalid: :replace, # Replace invalid byte sequences
        undef: :replace,   # Replace characters undefined in UTF-8
        replace: '',        # Use empty string for replacement
      )
      .gsub(%r{/$}, '') # Remove trailing slash, if present
  rescue ArgumentError => ex
    # Log the error but don't expose details
    Otto.logger.error '[Otto.handle_request] Path encoding error'
    Otto.logger.debug "[Otto.handle_request] Error details: #{ex.message}" if Otto.debug
    # Set a default value or use the original path_info
    path_info_clean = path_info
  end

  base_path      = File.split(path_info).first
  # Files in the root directory can refer to themselves
  base_path      = path_info if base_path == '/'
  http_verb      = env['REQUEST_METHOD'].upcase.to_sym
  literal_routes = routes_literal[http_verb] || {}
  literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
  if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
    # Otto.logger.debug " request: #{path_info} (static)"
    static_route.call(env)
  elsif literal_routes.has_key?(path_info_clean)
    route = literal_routes[path_info_clean]
    # Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
    route.call(env)
  elsif static_route && http_verb == :GET && safe_file?(path_info)
    Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
    routes_static[:GET][base_path] = base_path
    static_route.call(env)
  else
    extra_params  = {}
    found_route   = nil
    valid_routes  = routes[http_verb] || []
    valid_routes.push(*routes[:GET]) if http_verb == :HEAD
    valid_routes.each do |route|
      # Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
      next unless (match = route.pattern.match(path_info))

      values       = match.captures.to_a
      # The first capture returned is the entire matched string b/c
      # we wrapped the entire regex in parens. We don't need it to
      # the full match.
      values.shift
      extra_params =
        if route.keys.any?
          route.keys.zip(values).each_with_object({}) do |(k, v), hash|
            if k == 'splat'
              (hash[k] ||= []) << v
            else
              hash[k] = v
            end
          end
        elsif values.any?
          { 'captures' => values }
        else
          {}
        end
      found_route  = route
      break
    end
    found_route ||= literal_routes['/404']
    if found_route
      found_route.call env, extra_params
    else
      @not_found || Otto::Static.not_found
    end
  end
end

#load(path) ⇒ Object

Raises:

  • (ArgumentError)


70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/otto.rb', line 70

def load(path)
  path = File.expand_path(path)
  raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)

  raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip.split(/\s+/) }
  raw.each do |entry|
    verb, path, definition                  = *entry
    route                                   = Otto::Route.new verb, path, definition
    route.otto                              = self
    path_clean                              = path.gsub(%r{/$}, '')
    @route_definitions[route.definition]    = route
    Otto.logger.debug "route: #{route.pattern}" if Otto.debug
    @routes[route.verb]                   ||= []
    @routes[route.verb] << route
    @routes_literal[route.verb]           ||= {}
    @routes_literal[route.verb][path_clean] = route
  rescue StandardError
    Otto.logger.error "Bad route in #{path}: #{entry}"
  end
  self
end

#safe_dir?(path) ⇒ Boolean

Returns:

  • (Boolean)


117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/otto.rb', line 117

def safe_dir?(path)
  return false if path.nil? || path.empty?

  # Clean and expand the path
  clean_path = path.delete("\0").strip
  return false if clean_path.empty?

  expanded_path = File.expand_path(clean_path)

  # Check directory exists, is readable, and has proper ownership
  File.directory?(expanded_path) &&
    File.readable?(expanded_path) &&
    (File.owned?(expanded_path) || File.grpowned?(expanded_path))
end

#safe_file?(path) ⇒ Boolean

Returns:

  • (Boolean)


92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/otto.rb', line 92

def safe_file?(path)
  return false if option[:public].nil? || option[:public].empty?
  return false if path.nil? || path.empty?

  # Normalize and resolve the public directory path
  public_dir = File.expand_path(option[:public])
  return false unless File.directory?(public_dir)

  # Clean the requested path - remove null bytes and normalize
  clean_path = path.delete("\0").strip
  return false if clean_path.empty?

  # Join and expand to get the full resolved path
  requested_path = File.expand_path(File.join(public_dir, clean_path))

  # Ensure the resolved path is within the public directory (prevents path traversal)
  return false unless requested_path.start_with?(public_dir + File::SEPARATOR)

  # Check file exists, is readable, and is not a directory
  File.exist?(requested_path) &&
    File.readable?(requested_path) &&
    !File.directory?(requested_path) &&
    (File.owned?(requested_path) || File.grpowned?(requested_path))
end

#set_security_headers(headers) ⇒ Object

Set custom security headers that will be added to all responses. These merge with the default security headers.

Examples:

otto.set_security_headers({
  'content-security-policy' => "default-src 'self'",
  'strict-transport-security' => 'max-age=31536000'
})

Parameters:

  • headers (Hash)

    Hash of header name => value pairs



338
339
340
# File 'lib/otto.rb', line 338

def set_security_headers(headers)
  @security_config.security_headers.merge!(headers)
end

#uri(route_definition, params = {}) ⇒ Object

Return the URI path for the given route_definition e.g.

Otto.default.path 'YourClass.somemethod'  #=> /some/path


243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/otto.rb', line 243

def uri(route_definition, params = {})
  # raise RuntimeError, "Not working"
  route = @route_definitions[route_definition]
  return if route.nil?

  local_params = params.clone
  local_path   = route.path.clone

  local_params.each_pair do |k, v|
    next unless local_path.match(":#{k}")

    local_path.gsub!(":#{k}", v.to_s)
    local_params.delete(k)
  end

  uri = URI::HTTP.new(nil, nil, nil, nil, nil, local_path, nil, nil, nil)
  unless local_params.empty?
    query_string = local_params.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }.join('&')
    uri.query    = query_string
  end
  uri.to_s
end

#use(middleware) ⇒ Object

Add middleware to the stack

Parameters:

  • middleware (Class)

    The middleware class to add

  • args (Array)

    Additional arguments for the middleware

  • block (Proc)

    Optional block for middleware configuration



289
290
291
# File 'lib/otto.rb', line 289

def use(middleware, *, &)
  @middleware_stack << middleware
end