Add allow_browser to set minimum versions for your application (#50505)

* Add allow_browser to set minimum versions for your application
This commit is contained in:
David Heinemeier Hansson 2023-12-31 19:19:16 +01:00 committed by GitHub
parent 813afbdd74
commit e3da4fc53d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 258 additions and 3 deletions

@ -97,6 +97,7 @@ else
end
gem "kredis", ">= 1.7.0", require: false
gem "useragent", require: false
# Active Job
group :job do

@ -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)
@ -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)
@ -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)
@ -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

@ -42,6 +42,7 @@
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

@ -27,6 +27,7 @@ module ActionController
end
autoload_under "metal" do
autoload :AllowBrowser
autoload :ConditionalGet
autoload :ContentSecurityPolicy
autoload :Cookies

@ -215,6 +215,7 @@ def self.without_modules(*modules)
ContentSecurityPolicy,
PermissionsPolicy,
RateLimiting,
AllowBrowser,
Streaming,
DataStreaming,
HttpAuthentication::Basic::ControllerMethods,

@ -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

@ -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

@ -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"

@ -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

@ -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>

@ -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

@ -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