diff --git a/Gemfile b/Gemfile index a972ebfaf643a..c8244535cc02a 100644 --- a/Gemfile +++ b/Gemfile @@ -94,6 +94,7 @@ else end gem "kredis", ">= 1.7.0", require: false +gem "useragent", require: false # Active Job group :job do diff --git a/Gemfile.lock b/Gemfile.lock index 8a9cd6a800499..3ccd78f0c2e5e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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 diff --git a/actionpack/actionpack.gemspec b/actionpack/actionpack.gemspec index d144f4d4b895d..814c177e4803f 100644 --- a/actionpack/actionpack.gemspec +++ b/actionpack/actionpack.gemspec @@ -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 diff --git a/actionpack/lib/action_controller.rb b/actionpack/lib/action_controller.rb index 2398ae04dfe0a..ca49e288a869b 100644 --- a/actionpack/lib/action_controller.rb +++ b/actionpack/lib/action_controller.rb @@ -27,6 +27,7 @@ module ActionController end autoload_under "metal" do + autoload :AllowBrowser autoload :ConditionalGet autoload :ContentSecurityPolicy autoload :Cookies diff --git a/actionpack/lib/action_controller/base.rb b/actionpack/lib/action_controller/base.rb index caabdd13ac1fb..565b8f442ef5d 100644 --- a/actionpack/lib/action_controller/base.rb +++ b/actionpack/lib/action_controller/base.rb @@ -215,6 +215,7 @@ def self.without_modules(*modules) ContentSecurityPolicy, PermissionsPolicy, RateLimiting, + AllowBrowser, Streaming, DataStreaming, HttpAuthentication::Basic::ControllerMethods, diff --git a/actionpack/lib/action_controller/metal/allow_browser.rb b/actionpack/lib/action_controller/metal/allow_browser.rb new file mode 100644 index 0000000000000..7375b426aef3b --- /dev/null +++ b/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 only: or except:). + # Only browsers matched in the hash or named set passed to versions: 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 :modern 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 diff --git a/actionpack/test/controller/allow_browser_test.rb b/actionpack/test/controller/allow_browser_test.rb new file mode 100644 index 0000000000000..4c8dd34057aca --- /dev/null +++ b/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 diff --git a/railties/lib/rails/generators/rails/app/app_generator.rb b/railties/lib/rails/generators/rails/app/app_generator.rb index 977a7e8d5db0a..f2ee4aacffeb6 100644 --- a/railties/lib/rails/generators/rails/app/app_generator.rb +++ b/railties/lib/rails/generators/rails/app/app_generator.rb @@ -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" diff --git a/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt b/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt index 938eff8ed0091..592aec8a31520 100644 --- a/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt @@ -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 diff --git a/railties/lib/rails/generators/rails/app/templates/public/426.html b/railties/lib/rails/generators/rails/app/templates/public/426.html new file mode 100644 index 0000000000000..4a0a84ac42038 --- /dev/null +++ b/railties/lib/rails/generators/rails/app/templates/public/426.html @@ -0,0 +1,66 @@ + + + + Your browser is not supported (426) + + + + + + +
+
+

Your browser is not supported.

+

Please upgrade your browser to continue.

+
+
+ + diff --git a/railties/test/application/rake_test.rb b/railties/test/application/rake_test.rb index 57230b9550072..fd3aa5d392e67 100644 --- a/railties/test/application/rake_test.rb +++ b/railties/test/application/rake_test.rb @@ -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 diff --git a/railties/test/generators/api_app_generator_test.rb b/railties/test/generators/api_app_generator_test.rb index 797f3068ec05c..b81b28fe89d9b 100644 --- a/railties/test/generators/api_app_generator_test.rb +++ b/railties/test/generators/api_app_generator_test.rb @@ -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