actionpack 6.0.6.1 → 6.1.0.rc1

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of actionpack might be problematic. Click here for more details.

Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +232 -354
  3. data/MIT-LICENSE +1 -1
  4. data/README.rdoc +1 -1
  5. data/lib/abstract_controller/base.rb +35 -2
  6. data/lib/abstract_controller/callbacks.rb +2 -2
  7. data/lib/abstract_controller/helpers.rb +105 -90
  8. data/lib/abstract_controller/rendering.rb +9 -9
  9. data/lib/abstract_controller/translation.rb +8 -2
  10. data/lib/abstract_controller.rb +1 -0
  11. data/lib/action_controller/api.rb +2 -2
  12. data/lib/action_controller/base.rb +4 -2
  13. data/lib/action_controller/caching.rb +0 -1
  14. data/lib/action_controller/log_subscriber.rb +3 -3
  15. data/lib/action_controller/metal/conditional_get.rb +10 -2
  16. data/lib/action_controller/metal/content_security_policy.rb +1 -1
  17. data/lib/action_controller/metal/data_streaming.rb +1 -1
  18. data/lib/action_controller/metal/etag_with_template_digest.rb +2 -4
  19. data/lib/action_controller/metal/exceptions.rb +33 -0
  20. data/lib/action_controller/metal/feature_policy.rb +46 -0
  21. data/lib/action_controller/metal/head.rb +7 -4
  22. data/lib/action_controller/metal/helpers.rb +11 -1
  23. data/lib/action_controller/metal/http_authentication.rb +5 -3
  24. data/lib/action_controller/metal/implicit_render.rb +1 -1
  25. data/lib/action_controller/metal/instrumentation.rb +11 -9
  26. data/lib/action_controller/metal/live.rb +1 -1
  27. data/lib/action_controller/metal/logging.rb +20 -0
  28. data/lib/action_controller/metal/mime_responds.rb +6 -2
  29. data/lib/action_controller/metal/parameter_encoding.rb +35 -4
  30. data/lib/action_controller/metal/params_wrapper.rb +16 -11
  31. data/lib/action_controller/metal/redirecting.rb +1 -1
  32. data/lib/action_controller/metal/rendering.rb +6 -0
  33. data/lib/action_controller/metal/request_forgery_protection.rb +1 -1
  34. data/lib/action_controller/metal/rescue.rb +1 -1
  35. data/lib/action_controller/metal/strong_parameters.rb +103 -15
  36. data/lib/action_controller/metal.rb +2 -2
  37. data/lib/action_controller/renderer.rb +23 -13
  38. data/lib/action_controller/test_case.rb +62 -56
  39. data/lib/action_controller.rb +2 -3
  40. data/lib/action_dispatch/http/cache.rb +12 -10
  41. data/lib/action_dispatch/http/content_security_policy.rb +11 -0
  42. data/lib/action_dispatch/http/feature_policy.rb +168 -0
  43. data/lib/action_dispatch/http/filter_parameters.rb +1 -1
  44. data/lib/action_dispatch/http/filter_redirect.rb +1 -1
  45. data/lib/action_dispatch/http/headers.rb +3 -2
  46. data/lib/action_dispatch/http/mime_negotiation.rb +14 -8
  47. data/lib/action_dispatch/http/mime_type.rb +29 -16
  48. data/lib/action_dispatch/http/parameters.rb +1 -19
  49. data/lib/action_dispatch/http/request.rb +24 -8
  50. data/lib/action_dispatch/http/response.rb +17 -16
  51. data/lib/action_dispatch/http/url.rb +3 -2
  52. data/lib/action_dispatch/journey/formatter.rb +53 -28
  53. data/lib/action_dispatch/journey/gtg/builder.rb +22 -36
  54. data/lib/action_dispatch/journey/gtg/simulator.rb +8 -7
  55. data/lib/action_dispatch/journey/gtg/transition_table.rb +6 -4
  56. data/lib/action_dispatch/journey/nfa/dot.rb +0 -11
  57. data/lib/action_dispatch/journey/nodes/node.rb +4 -3
  58. data/lib/action_dispatch/journey/parser.rb +13 -13
  59. data/lib/action_dispatch/journey/parser.y +1 -1
  60. data/lib/action_dispatch/journey/path/pattern.rb +13 -18
  61. data/lib/action_dispatch/journey/route.rb +7 -18
  62. data/lib/action_dispatch/journey/router/utils.rb +6 -4
  63. data/lib/action_dispatch/journey/router.rb +26 -30
  64. data/lib/action_dispatch/journey.rb +0 -2
  65. data/lib/action_dispatch/middleware/actionable_exceptions.rb +1 -1
  66. data/lib/action_dispatch/middleware/cookies.rb +67 -32
  67. data/lib/action_dispatch/middleware/debug_exceptions.rb +8 -15
  68. data/lib/action_dispatch/middleware/debug_view.rb +1 -1
  69. data/lib/action_dispatch/middleware/exception_wrapper.rb +28 -16
  70. data/lib/action_dispatch/middleware/executor.rb +1 -1
  71. data/lib/action_dispatch/middleware/host_authorization.rb +35 -35
  72. data/lib/action_dispatch/middleware/remote_ip.rb +5 -4
  73. data/lib/action_dispatch/middleware/request_id.rb +4 -5
  74. data/lib/action_dispatch/middleware/session/abstract_store.rb +2 -2
  75. data/lib/action_dispatch/middleware/session/cookie_store.rb +2 -2
  76. data/lib/action_dispatch/middleware/ssl.rb +9 -6
  77. data/lib/action_dispatch/middleware/stack.rb +18 -0
  78. data/lib/action_dispatch/middleware/static.rb +154 -93
  79. data/lib/action_dispatch/middleware/templates/rescues/_message_and_suggestions.html.erb +18 -0
  80. data/lib/action_dispatch/middleware/templates/rescues/blocked_host.html.erb +1 -1
  81. data/lib/action_dispatch/middleware/templates/rescues/blocked_host.text.erb +1 -1
  82. data/lib/action_dispatch/middleware/templates/rescues/diagnostics.html.erb +2 -5
  83. data/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb +2 -2
  84. data/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb +2 -3
  85. data/lib/action_dispatch/middleware/templates/rescues/layout.erb +88 -8
  86. data/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb +1 -1
  87. data/lib/action_dispatch/middleware/templates/routes/_table.html.erb +12 -1
  88. data/lib/action_dispatch/railtie.rb +3 -2
  89. data/lib/action_dispatch/request/session.rb +2 -8
  90. data/lib/action_dispatch/request/utils.rb +26 -2
  91. data/lib/action_dispatch/routing/inspector.rb +8 -7
  92. data/lib/action_dispatch/routing/mapper.rb +102 -71
  93. data/lib/action_dispatch/routing/polymorphic_routes.rb +16 -19
  94. data/lib/action_dispatch/routing/redirection.rb +3 -3
  95. data/lib/action_dispatch/routing/route_set.rb +49 -41
  96. data/lib/action_dispatch/system_test_case.rb +29 -24
  97. data/lib/action_dispatch/system_testing/browser.rb +33 -27
  98. data/lib/action_dispatch/system_testing/driver.rb +6 -7
  99. data/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb +47 -6
  100. data/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb +4 -7
  101. data/lib/action_dispatch/testing/assertions/response.rb +2 -4
  102. data/lib/action_dispatch/testing/assertions/routing.rb +5 -5
  103. data/lib/action_dispatch/testing/assertions.rb +1 -1
  104. data/lib/action_dispatch/testing/integration.rb +38 -27
  105. data/lib/action_dispatch/testing/test_process.rb +29 -4
  106. data/lib/action_dispatch/testing/test_request.rb +3 -3
  107. data/lib/action_dispatch.rb +3 -2
  108. data/lib/action_pack/gem_version.rb +3 -3
  109. data/lib/action_pack.rb +1 -1
  110. metadata +23 -25
  111. data/lib/action_controller/metal/force_ssl.rb +0 -58
  112. data/lib/action_dispatch/http/parameter_filter.rb +0 -12
  113. data/lib/action_dispatch/journey/nfa/builder.rb +0 -78
  114. data/lib/action_dispatch/journey/nfa/simulator.rb +0 -47
  115. data/lib/action_dispatch/journey/nfa/transition_table.rb +0 -119
@@ -6,21 +6,21 @@ require "rack/utils"
6
6
  module ActionDispatch
7
7
  class ExceptionWrapper
8
8
  cattr_accessor :rescue_responses, default: Hash.new(:internal_server_error).merge!(
9
- "ActionController::RoutingError" => :not_found,
10
- "AbstractController::ActionNotFound" => :not_found,
11
- "ActionController::MethodNotAllowed" => :method_not_allowed,
12
- "ActionController::UnknownHttpMethod" => :method_not_allowed,
13
- "ActionController::NotImplemented" => :not_implemented,
14
- "ActionController::UnknownFormat" => :not_acceptable,
9
+ "ActionController::RoutingError" => :not_found,
10
+ "AbstractController::ActionNotFound" => :not_found,
11
+ "ActionController::MethodNotAllowed" => :method_not_allowed,
12
+ "ActionController::UnknownHttpMethod" => :method_not_allowed,
13
+ "ActionController::NotImplemented" => :not_implemented,
14
+ "ActionController::UnknownFormat" => :not_acceptable,
15
15
  "ActionDispatch::Http::MimeNegotiation::InvalidType" => :not_acceptable,
16
- "ActionController::MissingExactTemplate" => :not_acceptable,
17
- "ActionController::InvalidAuthenticityToken" => :unprocessable_entity,
18
- "ActionController::InvalidCrossOriginRequest" => :unprocessable_entity,
19
- "ActionDispatch::Http::Parameters::ParseError" => :bad_request,
20
- "ActionController::BadRequest" => :bad_request,
21
- "ActionController::ParameterMissing" => :bad_request,
22
- "Rack::QueryParser::ParameterTypeError" => :bad_request,
23
- "Rack::QueryParser::InvalidParameterError" => :bad_request
16
+ "ActionController::MissingExactTemplate" => :not_acceptable,
17
+ "ActionController::InvalidAuthenticityToken" => :unprocessable_entity,
18
+ "ActionController::InvalidCrossOriginRequest" => :unprocessable_entity,
19
+ "ActionDispatch::Http::Parameters::ParseError" => :bad_request,
20
+ "ActionController::BadRequest" => :bad_request,
21
+ "ActionController::ParameterMissing" => :bad_request,
22
+ "Rack::QueryParser::ParameterTypeError" => :bad_request,
23
+ "Rack::QueryParser::InvalidParameterError" => :bad_request
24
24
  )
25
25
 
26
26
  cattr_accessor :rescue_templates, default: Hash.new("diagnostics").merge!(
@@ -36,18 +36,24 @@ module ActionDispatch
36
36
  "ActionView::Template::Error"
37
37
  ]
38
38
 
39
+ cattr_accessor :silent_exceptions, default: [
40
+ "ActionController::RoutingError",
41
+ "ActionDispatch::Http::MimeNegotiation::InvalidType"
42
+ ]
43
+
39
44
  attr_reader :backtrace_cleaner, :exception, :wrapped_causes, :line_number, :file
40
45
 
41
46
  def initialize(backtrace_cleaner, exception)
42
47
  @backtrace_cleaner = backtrace_cleaner
43
48
  @exception = exception
49
+ @exception_class_name = @exception.class.name
44
50
  @wrapped_causes = wrapped_causes_for(exception, backtrace_cleaner)
45
51
 
46
52
  expand_backtrace if exception.is_a?(SyntaxError) || exception.cause.is_a?(SyntaxError)
47
53
  end
48
54
 
49
55
  def unwrapped_exception
50
- if wrapper_exceptions.include?(exception.class.to_s)
56
+ if wrapper_exceptions.include?(@exception_class_name)
51
57
  exception.cause
52
58
  else
53
59
  exception
@@ -55,13 +61,19 @@ module ActionDispatch
55
61
  end
56
62
 
57
63
  def rescue_template
58
- @@rescue_templates[@exception.class.name]
64
+ @@rescue_templates[@exception_class_name]
59
65
  end
60
66
 
61
67
  def status_code
62
68
  self.class.status_code_for_exception(unwrapped_exception.class.name)
63
69
  end
64
70
 
71
+ def exception_trace
72
+ trace = application_trace
73
+ trace = framework_trace if trace.empty? && !silent_exceptions.include?(@exception_class_name)
74
+ trace
75
+ end
76
+
65
77
  def application_trace
66
78
  clean_backtrace(:silent)
67
79
  end
@@ -9,7 +9,7 @@ module ActionDispatch
9
9
  end
10
10
 
11
11
  def call(env)
12
- state = @executor.run!(reset: true)
12
+ state = @executor.run!
13
13
  begin
14
14
  response = @app.call(env)
15
15
  returned = response << ::Rack::BodyProxy.new(response.pop) { state.complete! }
@@ -4,23 +4,17 @@ require "action_dispatch/http/request"
4
4
 
5
5
  module ActionDispatch
6
6
  # This middleware guards from DNS rebinding attacks by explicitly permitting
7
- # the hosts a request can be sent to.
7
+ # the hosts a request can be sent to, and is passed the options set in
8
+ # +config.host_authorization+.
9
+ #
10
+ # Requests can opt-out of Host Authorization with +exclude+:
11
+ #
12
+ # config.host_authorization = { exclude: ->(request) { request.path =~ /healthcheck/ } }
8
13
  #
9
14
  # When a request comes to an unauthorized host, the +response_app+
10
15
  # application will be executed and rendered. If no +response_app+ is given, a
11
16
  # default one will run, which responds with +403 Forbidden+.
12
17
  class HostAuthorization
13
- ALLOWED_HOSTS_IN_DEVELOPMENT = [".localhost", IPAddr.new("0.0.0.0/0"), IPAddr.new("::/0")]
14
- PORT_REGEX = /(?::\d+)/ # :nodoc:
15
- IPV4_HOSTNAME = /(?<host>\d+\.\d+\.\d+\.\d+)#{PORT_REGEX}?/ # :nodoc:
16
- IPV6_HOSTNAME = /(?<host>[a-f0-9]*:[a-f0-9.:]+)/i # :nodoc:
17
- IPV6_HOSTNAME_WITH_PORT = /\[#{IPV6_HOSTNAME}\]#{PORT_REGEX}/i # :nodoc:
18
- VALID_IP_HOSTNAME = Regexp.union( # :nodoc:
19
- /\A#{IPV4_HOSTNAME}\z/,
20
- /\A#{IPV6_HOSTNAME}\z/,
21
- /\A#{IPV6_HOSTNAME_WITH_PORT}\z/,
22
- )
23
-
24
18
  class Permissions # :nodoc:
25
19
  def initialize(hosts)
26
20
  @hosts = sanitize_hosts(hosts)
@@ -32,17 +26,11 @@ module ActionDispatch
32
26
 
33
27
  def allows?(host)
34
28
  @hosts.any? do |allowed|
35
- if allowed.is_a?(IPAddr)
36
- begin
37
- allowed === extract_hostname(host)
38
- rescue
39
- # IPAddr#=== raises an error if you give it a hostname instead of
40
- # IP. Treat similar errors as blocked access.
41
- false
42
- end
43
- else
44
- allowed === host
45
- end
29
+ allowed === host
30
+ rescue
31
+ # IPAddr#=== raises an error if you give it a hostname instead of
32
+ # IP. Treat similar errors as blocked access.
33
+ false
46
34
  end
47
35
  end
48
36
 
@@ -58,20 +46,16 @@ module ActionDispatch
58
46
  end
59
47
 
60
48
  def sanitize_regexp(host)
61
- /\A#{host}#{PORT_REGEX}?\z/
49
+ /\A#{host}\z/
62
50
  end
63
51
 
64
52
  def sanitize_string(host)
65
53
  if host.start_with?(".")
66
- /\A([a-z0-9-]+\.)?#{Regexp.escape(host[1..-1])}#{PORT_REGEX}?\z/i
54
+ /\A(.+\.)?#{Regexp.escape(host[1..-1])}\z/
67
55
  else
68
- /\A#{Regexp.escape host}#{PORT_REGEX}?\z/i
56
+ host
69
57
  end
70
58
  end
71
-
72
- def extract_hostname(host)
73
- host.slice(VALID_IP_HOSTNAME, "host") || host
74
- end
75
59
  end
76
60
 
77
61
  DEFAULT_RESPONSE_APP = -> env do
@@ -87,9 +71,20 @@ module ActionDispatch
87
71
  }, [body]]
88
72
  end
89
73
 
90
- def initialize(app, hosts, response_app = nil)
74
+ def initialize(app, hosts, deprecated_response_app = nil, exclude: nil, response_app: nil)
91
75
  @app = app
92
76
  @permissions = Permissions.new(hosts)
77
+ @exclude = exclude
78
+
79
+ unless deprecated_response_app.nil?
80
+ ActiveSupport::Deprecation.warn(<<-MSG.squish)
81
+ `action_dispatch.hosts_response_app` is deprecated and will be ignored in Rails 6.2.
82
+ Use the Host Authorization `response_app` setting instead.
83
+ MSG
84
+
85
+ response_app ||= deprecated_response_app
86
+ end
87
+
93
88
  @response_app = response_app || DEFAULT_RESPONSE_APP
94
89
  end
95
90
 
@@ -98,7 +93,7 @@ module ActionDispatch
98
93
 
99
94
  request = Request.new(env)
100
95
 
101
- if authorized?(request)
96
+ if authorized?(request) || excluded?(request)
102
97
  mark_as_authorized(request)
103
98
  @app.call(env)
104
99
  else
@@ -108,10 +103,15 @@ module ActionDispatch
108
103
 
109
104
  private
110
105
  def authorized?(request)
111
- origin_host = request.get_header("HTTP_HOST")
112
- forwarded_host = request.x_forwarded_host&.split(/,\s?/)&.last
106
+ origin_host = request.get_header("HTTP_HOST").to_s.sub(/:\d+\z/, "")
107
+ forwarded_host = request.x_forwarded_host.to_s.split(/,\s?/).last.to_s.sub(/:\d+\z/, "")
108
+
109
+ @permissions.allows?(origin_host) &&
110
+ (forwarded_host.blank? || @permissions.allows?(forwarded_host))
111
+ end
113
112
 
114
- @permissions.allows?(origin_host) && (forwarded_host.blank? || @permissions.allows?(forwarded_host))
113
+ def excluded?(request)
114
+ @exclude && @exclude.call(request)
115
115
  end
116
116
 
117
117
  def mark_as_authorized(request)
@@ -33,7 +33,7 @@ module ActionDispatch
33
33
  # not be the ultimate client IP in production, and so are discarded. See
34
34
  # https://en.wikipedia.org/wiki/Private_network for details.
35
35
  TRUSTED_PROXIES = [
36
- "127.0.0.1", # localhost IPv4
36
+ "127.0.0.0/8", # localhost IPv4 range, per RFC-3330
37
37
  "::1", # localhost IPv6
38
38
  "fc00::/7", # private IPv6 range fc00::/7
39
39
  "10.0.0.0/8", # private IPv4 range 10.x.x.x
@@ -143,10 +143,11 @@ module ActionDispatch
143
143
  # - X-Forwarded-For will be a list of IPs, one per proxy, or blank
144
144
  # - Client-Ip is propagated from the outermost proxy, or is blank
145
145
  # - REMOTE_ADDR will be the IP that made the request to Rack
146
- ips = [forwarded_ips, client_ips, remote_addr].flatten.compact
146
+ ips = [forwarded_ips, client_ips].flatten.compact
147
147
 
148
- # If every single IP option is in the trusted list, just return REMOTE_ADDR
149
- filter_proxies(ips).first || remote_addr
148
+ # If every single IP option is in the trusted list, return the IP
149
+ # that's furthest away
150
+ filter_proxies(ips + [remote_addr]).first || ips.last || remote_addr
150
151
  end
151
152
 
152
153
  # Memoizes the value returned by #calculate_ip and returns it for
@@ -15,16 +15,15 @@ module ActionDispatch
15
15
  # The unique request id can be used to trace a request end-to-end and would typically end up being part of log files
16
16
  # from multiple pieces of the stack.
17
17
  class RequestId
18
- X_REQUEST_ID = "X-Request-Id" #:nodoc:
19
-
20
- def initialize(app)
18
+ def initialize(app, header:)
21
19
  @app = app
20
+ @header = header
22
21
  end
23
22
 
24
23
  def call(env)
25
24
  req = ActionDispatch::Request.new env
26
- req.request_id = make_request_id(req.x_request_id)
27
- @app.call(env).tap { |_status, headers, _body| headers[X_REQUEST_ID] = req.request_id }
25
+ req.request_id = make_request_id(req.headers[@header])
26
+ @app.call(env).tap { |_status, headers, _body| headers[@header] = req.request_id }
28
27
  end
29
28
 
30
29
  private
@@ -82,7 +82,7 @@ module ActionDispatch
82
82
  include SessionObject
83
83
 
84
84
  private
85
- def set_cookie(request, session_id, cookie)
85
+ def set_cookie(request, response, cookie)
86
86
  request.cookie_jar[key] = cookie
87
87
  end
88
88
  end
@@ -97,7 +97,7 @@ module ActionDispatch
97
97
  end
98
98
 
99
99
  private
100
- def set_cookie(request, session_id, cookie)
100
+ def set_cookie(request, response, cookie)
101
101
  request.cookie_jar[key] = cookie
102
102
  end
103
103
  end
@@ -10,8 +10,8 @@ module ActionDispatch
10
10
  # dramatically faster than the alternatives.
11
11
  #
12
12
  # Sessions typically contain at most a user_id and flash message; both fit
13
- # within the 4K cookie size limit. A CookieOverflow exception is raised if
14
- # you attempt to store more than 4K of data.
13
+ # within the 4096 bytes cookie size limit. A CookieOverflow exception is raised if
14
+ # you attempt to store more than 4096 bytes of data.
15
15
  #
16
16
  # The cookie jar used for storage is automatically configured to be the
17
17
  # best possible option given your application's configuration.
@@ -13,7 +13,7 @@ module ActionDispatch
13
13
  #
14
14
  # Requests can opt-out of redirection with +exclude+:
15
15
  #
16
- # config.ssl_options = { redirect: { exclude: -> request { request.path =~ /healthcheck/ } } }
16
+ # config.ssl_options = { redirect: { exclude: -> request { /healthcheck/.match?(request.path) } } }
17
17
  #
18
18
  # Cookies will not be flagged as secure for excluded requests.
19
19
  #
@@ -29,7 +29,7 @@ module ActionDispatch
29
29
  #
30
30
  # * +expires+: How long, in seconds, these settings will stick. The minimum
31
31
  # required to qualify for browser preload lists is 1 year. Defaults to
32
- # 1 year (recommended).
32
+ # 2 years (recommended).
33
33
  #
34
34
  # * +subdomains+: Set to +true+ to tell the browser to apply these settings
35
35
  # to all subdomains. This protects your cookies from interception by a
@@ -49,14 +49,14 @@ module ActionDispatch
49
49
  class SSL
50
50
  # :stopdoc:
51
51
 
52
- # Default to 1 year, the minimum for browser preload lists.
53
- HSTS_EXPIRES_IN = 31536000
52
+ # Default to 2 years as recommended on hstspreload.org.
53
+ HSTS_EXPIRES_IN = 63072000
54
54
 
55
55
  def self.default_hsts_options
56
56
  { expires: HSTS_EXPIRES_IN, subdomains: true, preload: false }
57
57
  end
58
58
 
59
- def initialize(app, redirect: {}, hsts: {}, secure_cookies: true)
59
+ def initialize(app, redirect: {}, hsts: {}, secure_cookies: true, ssl_default_redirect_status: nil)
60
60
  @app = app
61
61
 
62
62
  @redirect = redirect
@@ -65,6 +65,7 @@ module ActionDispatch
65
65
  @secure_cookies = secure_cookies
66
66
 
67
67
  @hsts_header = build_hsts_header(normalize_hsts_options(hsts))
68
+ @ssl_default_redirect_status = ssl_default_redirect_status
68
69
  end
69
70
 
70
71
  def call(env)
@@ -126,12 +127,14 @@ module ActionDispatch
126
127
  [ @redirect.fetch(:status, redirection_status(request)),
127
128
  { "Content-Type" => "text/html",
128
129
  "Location" => https_location_for(request) },
129
- @redirect.fetch(:body, []) ]
130
+ (@redirect[:body] || []) ]
130
131
  end
131
132
 
132
133
  def redirection_status(request)
133
134
  if request.get? || request.head?
134
135
  301 # Issue a permanent redirect via a GET request.
136
+ elsif @ssl_default_redirect_status
137
+ @ssl_default_redirect_status
135
138
  else
136
139
  307 # Issue a fresh request redirect to preserve the HTTP method.
137
140
  end
@@ -122,6 +122,24 @@ module ActionDispatch
122
122
  middlewares.delete_if { |m| m.klass == target }
123
123
  end
124
124
 
125
+ def move(target, source)
126
+ source_index = assert_index(source, :before)
127
+ source_middleware = middlewares.delete_at(source_index)
128
+
129
+ target_index = assert_index(target, :before)
130
+ middlewares.insert(target_index, source_middleware)
131
+ end
132
+
133
+ alias_method :move_before, :move
134
+
135
+ def move_after(target, source)
136
+ source_index = assert_index(source, :after)
137
+ source_middleware = middlewares.delete_at(source_index)
138
+
139
+ target_index = assert_index(target, :after)
140
+ middlewares.insert(target_index + 1, source_middleware)
141
+ end
142
+
125
143
  def use(klass, *args, &block)
126
144
  middlewares.push(build_middleware(klass, args, block))
127
145
  end
@@ -4,126 +4,187 @@ require "rack/utils"
4
4
  require "active_support/core_ext/uri"
5
5
 
6
6
  module ActionDispatch
7
- # This middleware returns a file's contents from disk in the body response.
8
- # When initialized, it can accept optional HTTP headers, which will be set
9
- # when a response containing a file's contents is delivered.
7
+ # This middleware serves static files from disk, if available.
8
+ # If no file is found, it hands off to the main app.
10
9
  #
11
- # This middleware will render the file specified in <tt>env["PATH_INFO"]</tt>
12
- # where the base path is in the +root+ directory. For example, if the +root+
13
- # is set to +public/+, then a request with <tt>env["PATH_INFO"]</tt> of
14
- # +assets/application.js+ will return a response with the contents of a file
15
- # located at +public/assets/application.js+ if the file exists. If the file
16
- # does not exist, a 404 "File not Found" response will be returned.
17
- class FileHandler
18
- def initialize(root, index: "index", headers: {})
19
- @root = root.chomp("/").b
20
- @file_server = ::Rack::File.new(@root, headers)
21
- @index = index
10
+ # In Rails apps, this middleware is configured to serve assets from
11
+ # the +public/+ directory.
12
+ #
13
+ # Only GET and HEAD requests are served. POST and other HTTP methods
14
+ # are handed off to the main app.
15
+ #
16
+ # Only files in the root directory are served; path traversal is denied.
17
+ class Static
18
+ def initialize(app, path, index: "index", headers: {})
19
+ @app = app
20
+ @file_handler = FileHandler.new(path, index: index, headers: headers)
22
21
  end
23
22
 
24
- # Takes a path to a file. If the file is found, has valid encoding, and has
25
- # correct read permissions, the return value is a URI-escaped string
26
- # representing the filename. Otherwise, false is returned.
27
- #
28
- # Used by the +Static+ class to check the existence of a valid file
29
- # in the server's +public/+ directory (see Static#call).
30
- def match?(path)
31
- path = ::Rack::Utils.unescape_path path
32
- return false unless ::Rack::Utils.valid_path? path
33
- path = ::Rack::Utils.clean_path_info path
34
-
35
- paths = [path, "#{path}#{ext}", "#{path}/#{@index}#{ext}"]
36
-
37
- if match = paths.detect { |p|
38
- path = File.join(@root, p.b)
39
- begin
40
- File.file?(path) && File.readable?(path)
41
- rescue SystemCallError
42
- false
43
- end
44
- }
45
- ::Rack::Utils.escape_path(match).b
46
- end
23
+ def call(env)
24
+ @file_handler.attempt(env) || @app.call(env)
25
+ end
26
+ end
27
+
28
+ # This endpoint serves static files from disk using Rack::File.
29
+ #
30
+ # URL paths are matched with static files according to expected
31
+ # conventions: +path+, +path+.html, +path+/index.html.
32
+ #
33
+ # Precompressed versions of these files are checked first. Brotli (.br)
34
+ # and gzip (.gz) files are supported. If +path+.br exists, this
35
+ # endpoint returns that file with a +Content-Encoding: br+ header.
36
+ #
37
+ # If no matching file is found, this endpoint responds 404 Not Found.
38
+ #
39
+ # Pass the +root+ directory to search for matching files, an optional
40
+ # +index: "index"+ to change the default +path+/index.html, and optional
41
+ # additional response headers.
42
+ class FileHandler
43
+ # Accept-Encoding value -> file extension
44
+ PRECOMPRESSED = {
45
+ "br" => ".br",
46
+ "gzip" => ".gz",
47
+ "identity" => nil
48
+ }
49
+
50
+ def initialize(root, index: "index", headers: {}, precompressed: %i[ br gzip ], compressible_content_types: /\A(?:text\/|application\/javascript)/)
51
+ @root = root.chomp("/").b
52
+ @index = index
53
+
54
+ @precompressed = Array(precompressed).map(&:to_s) | %w[ identity ]
55
+ @compressible_content_types = compressible_content_types
56
+
57
+ @file_server = ::Rack::File.new(@root, headers)
47
58
  end
48
59
 
49
60
  def call(env)
50
- serve(Rack::Request.new(env))
61
+ attempt(env) || @file_server.call(env)
51
62
  end
52
63
 
53
- def serve(request)
54
- path = request.path_info
55
- gzip_path = gzip_file_path(path)
64
+ def attempt(env)
65
+ request = Rack::Request.new env
56
66
 
57
- if gzip_path && gzip_encoding_accepted?(request)
58
- request.path_info = gzip_path
59
- status, headers, body = @file_server.call(request.env)
60
- if status == 304
61
- return [status, headers, body]
67
+ if request.get? || request.head?
68
+ if found = find_file(request.path_info, accept_encoding: request.accept_encoding)
69
+ serve request, *found
62
70
  end
63
- headers["Content-Encoding"] = "gzip"
64
- headers["Content-Type"] = content_type(path)
65
- else
66
- status, headers, body = @file_server.call(request.env)
67
71
  end
68
-
69
- headers["Vary"] = "Accept-Encoding" if gzip_path
70
-
71
- [status, headers, body]
72
- ensure
73
- request.path_info = path
74
72
  end
75
73
 
76
74
  private
77
- def ext
78
- ::ActionController::Base.default_static_extension
75
+ def serve(request, filepath, content_headers)
76
+ original, request.path_info =
77
+ request.path_info, ::Rack::Utils.escape_path(filepath).b
78
+
79
+ @file_server.call(request.env).tap do |status, headers, body|
80
+ # Omit Content-Encoding/Type/etc headers for 304 Not Modified
81
+ if status != 304
82
+ headers.update(content_headers)
83
+ end
84
+ end
85
+ ensure
86
+ request.path_info = original
79
87
  end
80
88
 
81
- def content_type(path)
82
- ::Rack::Mime.mime_type(::File.extname(path), "text/plain")
89
+ # Match a URI path to a static file to be served.
90
+ #
91
+ # Used by the +Static+ class to negotiate a servable file in the
92
+ # +public/+ directory (see Static#call).
93
+ #
94
+ # Checks for +path+, +path+.html, and +path+/index.html files,
95
+ # in that order, including .br and .gzip compressed extensions.
96
+ #
97
+ # If a matching file is found, the path and necessary response headers
98
+ # (Content-Type, Content-Encoding) are returned.
99
+ def find_file(path_info, accept_encoding:)
100
+ each_candidate_filepath(path_info) do |filepath, content_type|
101
+ if response = try_files(filepath, content_type, accept_encoding: accept_encoding)
102
+ return response
103
+ end
104
+ end
83
105
  end
84
106
 
85
- def gzip_encoding_accepted?(request)
86
- request.accept_encoding.any? { |enc, quality| enc =~ /\bgzip\b/i }
107
+ def try_files(filepath, content_type, accept_encoding:)
108
+ headers = { "Content-Type" => content_type }
109
+
110
+ if compressible? content_type
111
+ try_precompressed_files filepath, headers, accept_encoding: accept_encoding
112
+ elsif file_readable? filepath
113
+ [ filepath, headers ]
114
+ end
87
115
  end
88
116
 
89
- def gzip_file_path(path)
90
- can_gzip_mime = content_type(path) =~ /\A(?:text\/|application\/javascript)/
91
- gzip_path = "#{path}.gz"
92
- if can_gzip_mime && File.exist?(File.join(@root, ::Rack::Utils.unescape_path(gzip_path)))
93
- gzip_path
94
- else
95
- false
117
+ def try_precompressed_files(filepath, headers, accept_encoding:)
118
+ each_precompressed_filepath(filepath) do |content_encoding, precompressed_filepath|
119
+ if file_readable? precompressed_filepath
120
+ # Identity encoding is default, so we skip Accept-Encoding
121
+ # negotiation and needn't set Content-Encoding.
122
+ #
123
+ # Vary header is expected when we've found other available
124
+ # encodings that Accept-Encoding ruled out.
125
+ if content_encoding == "identity"
126
+ return precompressed_filepath, headers
127
+ else
128
+ headers["Vary"] = "Accept-Encoding"
129
+
130
+ if accept_encoding.any? { |enc, _| /\b#{content_encoding}\b/i.match?(enc) }
131
+ headers["Content-Encoding"] = content_encoding
132
+ return precompressed_filepath, headers
133
+ end
134
+ end
135
+ end
96
136
  end
97
137
  end
98
- end
99
138
 
100
- # This middleware will attempt to return the contents of a file's body from
101
- # disk in the response. If a file is not found on disk, the request will be
102
- # delegated to the application stack. This middleware is commonly initialized
103
- # to serve assets from a server's +public/+ directory.
104
- #
105
- # This middleware verifies the path to ensure that only files
106
- # living in the root directory can be rendered. A request cannot
107
- # produce a directory traversal using this middleware. Only 'GET' and 'HEAD'
108
- # requests will result in a file being returned.
109
- class Static
110
- def initialize(app, path, index: "index", headers: {})
111
- @app = app
112
- @file_handler = FileHandler.new(path, index: index, headers: headers)
113
- end
139
+ def file_readable?(path)
140
+ file_stat = File.stat(File.join(@root, path.b))
141
+ rescue SystemCallError
142
+ false
143
+ else
144
+ file_stat.file? && file_stat.readable?
145
+ end
114
146
 
115
- def call(env)
116
- req = Rack::Request.new env
147
+ def compressible?(content_type)
148
+ @compressible_content_types.match?(content_type)
149
+ end
117
150
 
118
- if req.get? || req.head?
119
- path = req.path_info.chomp("/")
120
- if match = @file_handler.match?(path)
121
- req.path_info = match
122
- return @file_handler.serve(req)
151
+ def each_precompressed_filepath(filepath)
152
+ @precompressed.each do |content_encoding|
153
+ precompressed_ext = PRECOMPRESSED.fetch(content_encoding)
154
+ yield content_encoding, "#{filepath}#{precompressed_ext}"
123
155
  end
156
+
157
+ nil
124
158
  end
125
159
 
126
- @app.call(req.env)
127
- end
160
+ def each_candidate_filepath(path_info)
161
+ return unless path = clean_path(path_info)
162
+
163
+ ext = ::File.extname(path)
164
+ content_type = ::Rack::Mime.mime_type(ext, nil)
165
+ yield path, content_type || "text/plain"
166
+
167
+ # Tack on .html and /index.html only for paths that don't have
168
+ # an explicit, resolvable file extension. No need to check
169
+ # for foo.js.html and foo.js/index.html.
170
+ unless content_type
171
+ default_ext = ::ActionController::Base.default_static_extension
172
+ if ext != default_ext
173
+ default_content_type = ::Rack::Mime.mime_type(default_ext, "text/plain")
174
+
175
+ yield "#{path}#{default_ext}", default_content_type
176
+ yield "#{path}/#{@index}#{default_ext}", default_content_type
177
+ end
178
+ end
179
+
180
+ nil
181
+ end
182
+
183
+ def clean_path(path_info)
184
+ path = ::Rack::Utils.unescape_path path_info.chomp("/")
185
+ if ::Rack::Utils.valid_path? path
186
+ ::Rack::Utils.clean_path_info path
187
+ end
188
+ end
128
189
  end
129
190
  end