Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add allow_browser to set minimum versions for your application #50505

Merged
merged 5 commits into from Dec 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Expand Up @@ -94,6 +94,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
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 @@ -545,6 +544,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 @@ -648,6 +648,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
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
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
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
@@ -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
@@ -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
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
@@ -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
@@ -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
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
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