Skip to content

Commit

Permalink
Add allow_browser to set minimum versions for your application (#50505)
Browse files Browse the repository at this point in the history
* Add allow_browser to set minimum versions for your application
  • Loading branch information
dhh committed Dec 31, 2023
1 parent 813afbd commit e3da4fc
Show file tree
Hide file tree
Showing 12 changed files with 258 additions and 3 deletions.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ else
end

gem "kredis", ">= 1.7.0", require: false
gem "useragent", require: false

# Active Job
group :job do
Expand Down
5 changes: 3 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ PATH
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
activerecord (= 7.2.0.alpha)
Expand Down Expand Up @@ -191,8 +192,6 @@ GEM
railties (>= 6.0.0)
date (3.3.3)
debug (1.7.1)
irb (>= 1.5.0)
reline (>= 0.3.1)
declarative (0.0.20)
delayed_job (4.1.11)
activesupport (>= 3.0, < 8.0)
Expand Down Expand Up @@ -550,6 +549,7 @@ GEM
concurrent-ruby (~> 1.0)
uber (0.1.0)
unicode-display_width (2.5.0)
useragent (0.16.10)
w3c_validators (1.3.7)
json (>= 1.8)
nokogiri (~> 1.6)
Expand Down Expand Up @@ -654,6 +654,7 @@ DEPENDENCIES
trilogy (>= 2.5.0)
turbo-rails
tzinfo-data
useragent
w3c_validators (~> 1.3.6)
wdm (>= 0.1.0)
web-console
Expand Down
1 change: 1 addition & 0 deletions actionpack/actionpack.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Gem::Specification.new do |s|
s.add_dependency "rack-test", ">= 0.6.3"
s.add_dependency "rails-html-sanitizer", "~> 1.6"
s.add_dependency "rails-dom-testing", "~> 2.2"
s.add_dependency "useragent", "~> 0.16"
s.add_dependency "actionview", version

s.add_development_dependency "activemodel", version
Expand Down
1 change: 1 addition & 0 deletions actionpack/lib/action_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ module ActionController
end

autoload_under "metal" do
autoload :AllowBrowser
autoload :ConditionalGet
autoload :ContentSecurityPolicy
autoload :Cookies
Expand Down
1 change: 1 addition & 0 deletions actionpack/lib/action_controller/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ def self.without_modules(*modules)
ContentSecurityPolicy,
PermissionsPolicy,
RateLimiting,
AllowBrowser,
Streaming,
DataStreaming,
HttpAuthentication::Basic::ControllerMethods,
Expand Down
111 changes: 111 additions & 0 deletions actionpack/lib/action_controller/metal/allow_browser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# frozen_string_literal: true

module ActionController # :nodoc:
module AllowBrowser
extend ActiveSupport::Concern

module ClassMethods
# Specify the browser versions that will be allowed to access all actions (or some, as limited by <tt>only:</tt> or <tt>except:</tt>).
# Only browsers matched in the hash or named set passed to <tt>versions:</tt> will be blocked if they're below the versions specified.
# This means that all other browsers, as well as agents that aren't reporting a user-agent header, will be allowed access.
#
# A browser that's blocked will by default be served the file in public/426.html with a HTTP status code of "426 Upgrade Required".
#
# In addition to specifically named browser versions, you can also pass <tt>:modern</tt> as the set to restrict support to browsers
# natively supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
# This includes Safari 17.2+, Chrome 119+, Firefox 121+, Opera 104+.
#
# You can use https://caniuse.com to check for browser versions supporting the features you use.
#
# You can use +ActiveSupport::Notifications+ to subscribe to events of browsers being blocked using the +browser_block.action_controller+
# event name.
#
# Examples:
#
# class ApplicationController < ActionController::Base
# # Allow only browsers natively supporting webp images, web push, badges, import maps, CSS nesting + :has
# allow_browser versions: :modern
# end
#
# class ApplicationController < ActionController::Base
# # All versions of Chrome and Opera will be allowed, but no versions of "internet explorer" (ie). Safari needs to be 16.4+ and Firefox 121+.
# allow_browser versions: { safari: 16.4, firefox: 121, ie: false }
# end
#
# class MessagesController < ApplicationController
# # In addition to the browsers blocked by ApplicationController, also block Opera below 104 and Chrome below 119 for the show action.
# allow_browser versions: { opera: 104, chrome: 119 }, only: :show
# end
def allow_browser(versions:, block: -> { render file: Rails.root.join("public/426.html"), layout: false, status: :upgrade_required }, **options)
before_action -> { allow_browser(versions: versions, block: block) }, **options
end
end

private
def allow_browser(versions:, block:)
require "useragent"

if BrowserBlocker.new(request, versions: versions).blocked?
ActiveSupport::Notifications.instrument("browser_block.action_controller", request: request, versions: versions) do
instance_exec(&block)
end
end
end

class BrowserBlocker
SETS = {
modern: { safari: 17.2, chrome: 119, firefox: 121, opera: 104, ie: false }
}

attr_reader :request, :versions

def initialize(request, versions:)
@request, @versions = request, versions
end

def blocked?
user_agent_version_reported? && unsupported_browser?
end

private
def parsed_user_agent
@parsed_user_agent ||= UserAgent.parse(request.user_agent)
end

def user_agent_version_reported?
request.user_agent.present? && parsed_user_agent.version.to_s.present?
end

def unsupported_browser?
version_guarded_browser? && version_below_minimum_required?
end

def version_guarded_browser?
minimum_browser_version_for_browser != nil
end

def version_below_minimum_required?
if minimum_browser_version_for_browser
parsed_user_agent.version < UserAgent::Version.new(minimum_browser_version_for_browser.to_s)
else
true
end
end

def minimum_browser_version_for_browser
expanded_versions[normalized_browser_name]
end

def expanded_versions
@expanded_versions ||= (SETS[versions] || versions).with_indifferent_access
end

def normalized_browser_name
case name = parsed_user_agent.browser.downcase
when "internet explorer" then "ie"
else name
end
end
end
end
end
67 changes: 67 additions & 0 deletions actionpack/test/controller/allow_browser_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# frozen_string_literal: true

require "abstract_unit"

class AllowBrowserController < ActionController::Base
allow_browser versions: { safari: "16.4", chrome: "119", firefox: "123", opera: "104", ie: false }, block: -> { head :upgrade_required }, only: :hello
def hello
head :ok
end

allow_browser versions: :modern, block: -> { head :upgrade_required }, only: :modern
def modern
head :ok
end
end

class AllowBrowserTest < ActionController::TestCase
tests AllowBrowserController

CHROME_118 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118 Safari/537.36"
CHROME_120 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36"
SAFARI_17_2_0 = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.0 Safari/605.1.15"
FIREFOX_114 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/114.0"
IE_11 = "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko"
OPERA_104 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36 OPR/104.0.4638.54"

test "blocked browser below version limit" do
get_with_agent :hello, FIREFOX_114
assert_response :upgrade_required
end

test "blocked browser by name" do
get_with_agent :hello, IE_11
assert_response :upgrade_required
end

test "allowed browsers above specific version limit" do
get_with_agent :hello, SAFARI_17_2_0
assert_response :ok

get_with_agent :hello, CHROME_120
assert_response :ok

get_with_agent :hello, OPERA_104
assert_response :ok
end

test "browsers against modern limit" do
get_with_agent :modern, SAFARI_17_2_0
assert_response :ok

get_with_agent :modern, CHROME_118
assert_response :upgrade_required

get_with_agent :modern, CHROME_120
assert_response :ok

get_with_agent :modern, OPERA_104
assert_response :ok
end

private
def get_with_agent(action, agent)
@request.headers["User-Agent"] = agent
get action
end
end
1 change: 1 addition & 0 deletions railties/lib/rails/generators/rails/app/app_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,7 @@ def delete_public_files_if_api_option
if options[:api]
remove_file "public/404.html"
remove_file "public/422.html"
remove_file "public/426.html"
remove_file "public/500.html"
remove_file "public/apple-touch-icon-precomposed.png"
remove_file "public/apple-touch-icon.png"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
class ApplicationController < ActionController::<%= options.api? ? "API" : "Base" %>
<%- unless options.api? -%>
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
<% end -%>
end
66 changes: 66 additions & 0 deletions railties/lib/rails/generators/rails/app/templates/public/426.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html>
<head>
<title>Your browser is not supported (426)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
.rails-default-error-page {
background-color: #EFEFEF;
color: #2E2F30;
text-align: center;
font-family: arial, sans-serif;
margin: 0;
}

.rails-default-error-page div.dialog {
width: 95%;
max-width: 33em;
margin: 4em auto 0;
}

.rails-default-error-page div.dialog > div {
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #BBB;
border-top: #B00100 solid 4px;
border-top-left-radius: 9px;
border-top-right-radius: 9px;
background-color: white;
padding: 7px 12% 0;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}

.rails-default-error-page h1 {
font-size: 100%;
color: #730E15;
line-height: 1.5em;
}

.rails-default-error-page div.dialog > p {
margin: 0 0 1em;
padding: 1em;
background-color: #F7F7F7;
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #999;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-top-color: #DADADA;
color: #666;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
</style>
</head>

<body class="rails-default-error-page">
<!-- This file lives in public/426.html -->
<div class="dialog">
<div>
<h1>Your browser is not supported.</h1>
<p>Please upgrade your browser to continue.</p>
</div>
</div>
</body>
</html>
2 changes: 1 addition & 1 deletion railties/test/application/rake_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ class Hello
end

def test_code_statistics
assert_match "Code LOC: 62 Test LOC: 5 Code to Test Ratio: 1:0.1",
assert_match "Code LOC: 63 Test LOC: 5 Code to Test Ratio: 1:0.1",
rails("stats")
end

Expand Down
1 change: 1 addition & 0 deletions railties/test/generators/api_app_generator_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ def skipped_files
tmp/cache/assets
public/404.html
public/422.html
public/426.html
public/500.html
public/apple-touch-icon-precomposed.png
public/apple-touch-icon.png
Expand Down

0 comments on commit e3da4fc

Please sign in to comment.