bot_challenge_page 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f388eccf957733cbaab0c019d82e9803b215bf8035b2b10dc7c656b821907e7d
4
- data.tar.gz: 5b8126932d6bd901ddadab4dc68259bb1d2652992de882421fbfb0cfca67b500
3
+ metadata.gz: 826b15d243c27b3003ad7c37650836ee67536319177d947b4a046eb200914df7
4
+ data.tar.gz: 4709e5302b7c4298fc06bb490646273cce5a916e3337ed7d788c5390f345777e
5
5
  SHA512:
6
- metadata.gz: e5a1b5e05aefd618aca8e14570e1e0c9a32f4d5144f6bd93dabae265d1ac69759ebe19bc4dcdaf9fc07a0b121ba81be819f4d46d7ac1b111454d1bb191131906
7
- data.tar.gz: 58cf73819292626fc40a696fecb7c7737ab89f92b7093e9c707bb42fe95ff83cda7303c510b807ab41a221fd0fe5c0ed5d3251983b466726dea9f252f276bdac
6
+ metadata.gz: e6e260de1875ebbb96a8b809907b50f8d92abe1a9e1d6aee711fd7f3f08567ba627325a035ac08b67f45e42468ad982a85b2f4c45c6709460abe186acbb71b6a
7
+ data.tar.gz: 3a11694e124de65ca11df02d6fc3aa38b7ee4d93881b2eba64f89a6c30924f3bee3d98297cc8ff9340aa029823bf5cef9e19077cad3b8acfc39aa9cc57e7fe29
data/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # BotChallengePage
2
2
 
3
- [![CI](https://github.com/samvera-labs/bot_challenge_page/actions/workflows/ci.yml/badge.svg)](https://github.com/samvera-labs/bot_challenge_page/actions/workflows/ci.yml)
3
+ [![CI](https://github.com/samvera-labs/bot_challenge_page/actions/workflows/ci.yml/badge.svg)](https://github.com/samvera-labs/bot_challenge_page/actions/workflows/ci.yml) [![Gem Version](https://badge.fury.io/rb/bot_challenge_page.png)](http://badge.fury.io/rb/bot_challenge_page)
4
4
 
5
- BotChallengePage lets you protect certain routes in your Rails app with [CloudFlare Turnstile](https://www.cloudflare.com/application-services/products/turnstile/) "CAPTHCA alternate" bot detector. Rather than the typical form submission use case for Turnstile, the user will be redirected to an interstitial challenge page, and redirected back on success.
5
+ BotChallengePage lets you protect certain routes in your Rails app with [CloudFlare Turnstile](https://www.cloudflare.com/application-services/products/turnstile/) "CAPTHCA alternate" bot detector. Rather than the typical form submission use case for Turnstile, the user will be redirected to an interstitial challenge page, and automatically redirected back immediately on success.
6
6
 
7
- The motivating use case is fairly dumb (probably AI-related) crawlers, rather than targetted attacks, although we have tried to pay attention to security. Many of our use cases were crawlers getting caught in "infinite" page variations by following every combination of voluminous facet values in search results in a near "infinite space", and causing us resource usage issues.
7
+ The motivating use case is fairly dumb (probably AI-related) crawlers, rather than targetted attacks, although we have tried to pay attention to security. Many of our use cases were crawlers getting caught following every combination of voluminous facet values in search results in a near "infinite space", and causing us resource usage issues.
8
8
 
9
9
  ![challenge page screenshot](docs/challenge-page-example.png)
10
10
 
@@ -18,26 +18,37 @@ The motivating use case is fairly dumb (probably AI-related) crawlers, rather th
18
18
 
19
19
  ## Installation and Configuration
20
20
 
21
- * Get a CloudFlare account and Turnstile widget set up, which should give you a turnstile `sitekey` and `secret_key` you will need later in configuration.
21
+ * Get a [CloudFlare account and Turnstile widget set up](https://www.cloudflare.com/application-services/products/turnstile/), which should give you a turnstile `sitekey` and `secret_key` you will need later in configuration.
22
22
 
23
23
  * `bundle add bot_challenge_page`, `bundle install`
24
24
 
25
25
  * Run the installer
26
26
  * if you want to use rack-attack for some permissive pre-challenge rate, `rails g bot_challenge_page:install`
27
- * If you do not want to use rack-attack and want challenge on FIRST request, `rails g bot_challenge_page:install --without-rack-attack`
27
+
28
+ * If you do not want to use rack-attack and want challenge on FIRST request, `rails g bot_challenge_page:install --no-rack-attack`
29
+
30
+ * If you are **not using rack-attack**, you need to add a before_action to the controller(s)
31
+ you'd like to protect, eg:
32
+
33
+ before_action only: :index do |controller|
34
+ BotChallengePage::BotChallengePageController.bot_challenge_enforce_filter(controller, immediate: true)
35
+ end
36
+
28
37
 
29
38
  * Configure in the generated `./config/initializers/bot_challenge_page.rb`
30
39
  * At a minimum you need to configure your Cloudflare Turnstile keys, and some paths to protect!
31
40
  * Note that we can only protect GET paths, and also think about making sure you DON'T protect
32
41
  any path your front-end needs JS `fetch` access to, as this would block it (at least
33
42
  without custom front-end code we haven't really explored)
43
+
34
44
  * If you are tempted to just protect `/` that may work, but worth thinking about any hearbeat paths, front-end requestable paths, or other machine-access-desired paths.
45
+
35
46
  * Some other configuration options are offered -- more advanced/specialized ones are available that are not mentioned in generated config file, see [Config class](./app/models/bot_challenge_page/config.rb)
36
47
 
37
48
 
38
49
  ## Customize challenge page display
39
50
 
40
- Some of the default challenge page html uses bootstrap alert classes. You may want to provide custom CSS if you aren't using bootstrap. You can see the default challenge page html at [challenge.html.erb](./app/views/bot_challenge_page/bot_challenge_page/challenge.html). You may wish to CSS-style other parts too!
51
+ Some of the default challenge page html uses bootstrap alert classes. You may want to provide custom CSS if you aren't using bootstrap. You can see the default challenge page html at [challenge.html.erb](./app/views/bot_challenge_page/bot_challenge_page/challenge.html.erb). You may wish to CSS-style other parts too!
41
52
 
42
53
  You can customize all text via I18n, see keys in [bot_challenge_page.en.yml](./config/locales/bot_challenge_page.en.yml)
43
54
 
@@ -82,7 +93,7 @@ Rails.application.config.to_prepare do
82
93
  #
83
94
  # sec-fetch-dest is set to 'empty' by browser on fetch requests, to limit us further;
84
95
  # sure an attacker could fake it, we don't mind if someone determined can avoid
85
- # rate-limiting on this one action
96
+ # bot challenge on this one action
86
97
  ( controller.params[:action] == "facet" &&
87
98
  controller.request.headers["sec-fetch-dest"] == "empty" &&
88
99
  controller.kind_of?(CatalogController)
@@ -94,6 +105,18 @@ end
94
105
 
95
106
  ```
96
107
 
108
+ ## Development and automated testing
109
+
110
+ All logic and config hangs off a controller, with the idea that you could sub-class the controller to override any functionality -- or even have multiple sub-classes in your app with different configuration or customized config. But this hasn't really been tested/fleshed out yet.
111
+
112
+ Run tests with `bundle exec rspec`.
113
+
114
+ We test with a checked-into-repo dummy app at `./spec/dummy`, and use [Appraisal](https://github.com/thoughtbot/appraisal) to test under different rails versions.
115
+
116
+ Locally one way to test with a specific rails version appraisal is `bundle exec appraisal rails-7.2 rspec`
117
+
118
+ If you make any changes to `Gemfile` you may need to run `bundle exec appraisal install` and commit changes.
119
+
97
120
  ## Possible future features?
98
121
 
99
122
  * allow regex in default location_matcher? Easy to do if you want it, just say so.
@@ -114,6 +137,10 @@ The gem is available as open source under the terms of the [MIT License](https:/
114
137
 
115
138
  * [Similar feature built into PHP VuFind app](https://github.com/vufind-org/vufind/pull/4079)
116
139
 
117
- * Wow only after I developed all this did I notice [rails-cloudflare-turnstile](https://github.com/instrumentl/rails-cloudflare-turnstile) which implements some pieces that could have been re-used here, but I feel good.
140
+ * [My own blog post about this approach](https://bibwild.wordpress.com/2025/01/16/using-cloudflare-turnstile-to-protect-certain-pages-on-a-rails-app/).
141
+
142
+ * Wow only after I developed all this did I notice [rails-cloudflare-turnstile](https://github.com/instrumentl/rails-cloudflare-turnstile) which implements some pieces that could have been re-used here, but I feel good becuase we wanted these weird features. But if you want a much simpler more straightforward Turnstile implementation for more standard use cases or your own different use cases, I'd go here.
118
143
 
119
144
  * And yet another implementation in Rails that perhaps makes more assumptions about use cases, [turnstile-captcha](https://github.com/pfeiffer/turnstile-captcha). Haven't looked at it much.
145
+
146
+
@@ -11,6 +11,9 @@ require 'http'
11
11
  #
12
12
  module BotChallengePage
13
13
  class BotChallengePageController < ::ApplicationController
14
+ include BotChallengePage::RackAttackInit
15
+ include BotChallengePage::EnforceFilter
16
+
14
17
  # Config for bot detection is held in class object here -- idea is
15
18
  # to support different controllers with different config protecting
16
19
  # different paths in your app if you like, is why config is with controller
@@ -25,91 +28,6 @@ module BotChallengePage
25
28
  # for allowing unsubscribe for testing
26
29
  class_attribute :_track_notification_subscription, instance_accessor: false
27
30
 
28
- # perhaps in an initializer, and after changing any config, run:
29
- #
30
- # Rails.application.config.to_prepare do
31
- # BotChallengePage::BotChallengePageController.rack_attack_init
32
- # end
33
- #
34
- # Safe to call more than once if you change config and want to call again, say in testing.
35
- def self.rack_attack_init
36
- self._rack_attack_uninit # make it safe for calling multiple times
37
-
38
- ## Turnstile bot detection throttling
39
- #
40
- # for paths matched by `rate_limited_locations`, after over rate_limit count requests in rate_limit_period,
41
- # token will be stored in rack env instructing challenge is required.
42
- #
43
- # For actual challenge, need before_action in controller.
44
- #
45
- # You could rate limit detect on wider paths than you actually challenge on, or the same. You probably
46
- # don't want to rate-limit detect on narrower list of paths than you challenge on!
47
- Rack::Attack.track("bot_detect/rate_exceeded/#{self.name}",
48
- limit: self.bot_challenge_config.rate_limit_count,
49
- period: self.bot_challenge_config.rate_limit_period) do |req|
50
- if self.bot_challenge_config.enabled && self.bot_challenge_config.location_matcher.call(req, self.bot_challenge_config)
51
- self.bot_challenge_config.rate_limit_discriminator.call(req, self.bot_challenge_config)
52
- end
53
- end
54
-
55
- self._track_notification_subscription = ActiveSupport::Notifications.subscribe("track.rack_attack") do |_name, _start, _finish, request_id, payload|
56
- rack_request = payload[:request]
57
- rack_env = rack_request.env
58
- match_name = rack_env["rack.attack.matched"] # name of rack-attack rule
59
- #
60
- if match_name == "bot_detect/rate_exceeded/#{self.name}"
61
- match_data = rack_env["rack.attack.match_data"]
62
- match_data_formatted = match_data.slice(:count, :limit, :period).map { |k, v| "#{k}=#{v}"}.join(" ")
63
- discriminator = rack_env["rack.attack.match_discriminator"] # unique key for rate limit, usually includes ip
64
-
65
- rack_env[self.bot_challenge_config.env_challenge_trigger_key] = true
66
- end
67
- end
68
- end
69
-
70
- def self._rack_attack_uninit
71
- Rack::Attack.track("bot_detect/rate_exceeded/#{self.name}") {} # overwrite track name with empty proc
72
- ActiveSupport::Notifications.unsubscribe(self._track_notification_subscription) if self._track_notification_subscription
73
- self._track_notification_subscription = nil
74
- end
75
-
76
- # Usually in your ApplicationController,
77
- #
78
- # before_action { |controller| BotChallengePage::BotChallengePageController.bot_challenge_enforce_filter(controller) }
79
- #
80
- # @param immediate [Boolean] always force bot protection, ignore any allowed pre-challenge rate limit
81
- def self.bot_challenge_enforce_filter(controller, immediate: false)
82
- if self.bot_challenge_config.enabled &&
83
- (controller.request.env[self.bot_challenge_config.env_challenge_trigger_key] || immediate) &&
84
- ! self._bot_detect_passed_good?(controller.request) &&
85
- ! controller.kind_of?(self) && # don't ever guard ourself, that'd be a mess!
86
- ! self.bot_challenge_config.allow_exempt.call(controller, self.bot_challenge_config)
87
-
88
- # we can only do GET requests right now
89
- if !controller.request.get?
90
- Rails.logger.warn("#{self}: Asked to protect request we could not, unprotected: #{controller.request.method} #{controller.request.url}, (#{controller.request.remote_ip}, #{controller.request.user_agent})")
91
- return
92
- end
93
-
94
- Rails.logger.info("#{self.name}: Cloudflare Turnstile challenge redirect: (#{controller.request.remote_ip}, #{controller.request.user_agent}): from #{controller.request.url}")
95
- # status code temporary
96
- controller.redirect_to controller.bot_detect_challenge_path(dest: controller.request.original_fullpath), status: 307
97
- end
98
- end
99
-
100
- # Does the session already contain a bot detect pass that is good for this request
101
- # Tie to IP address to prevent session replay shared among IPs
102
- def self._bot_detect_passed_good?(request)
103
- session_data = request.session[self.bot_challenge_config.session_passed_key]
104
-
105
- return false unless session_data && session_data.kind_of?(Hash)
106
-
107
- datetime = session_data[SESSION_DATETIME_KEY]
108
- ip = session_data[SESSION_IP_KEY]
109
-
110
- (ip == request.remote_ip) && (Time.now - Time.iso8601(datetime) < self.bot_challenge_config.session_passed_good_for )
111
- end
112
-
113
31
 
114
32
  def challenge
115
33
  # possible custom render to choose layouts or templates, but normally
@@ -0,0 +1,48 @@
1
+ module BotChallengePage
2
+
3
+ # Extracted to concern in separate file mostly for readability, not expected to be used
4
+ # anywehre but BotChallengePageController -- we hang all logic off controller to allow multiple
5
+ # controllers in an app, and over-ride in sub-classes.
6
+ module EnforceFilter
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ # Usually in your ApplicationController, unless using `immediate`.
11
+ #
12
+ # before_action { |controller| BotChallengePage::BotChallengePageController.bot_challenge_enforce_filter(controller) }
13
+ #
14
+ # @param immediate [Boolean] always force bot protection, ignore any allowed pre-challenge rate limit
15
+ def bot_challenge_enforce_filter(controller, immediate: false)
16
+ if self.bot_challenge_config.enabled &&
17
+ (controller.request.env[self.bot_challenge_config.env_challenge_trigger_key] || immediate) &&
18
+ ! self._bot_detect_passed_good?(controller.request) &&
19
+ ! controller.kind_of?(self) && # don't ever guard ourself, that'd be a mess!
20
+ ! self.bot_challenge_config.allow_exempt.call(controller, self.bot_challenge_config)
21
+
22
+ # we can only do GET requests right now
23
+ if !controller.request.get?
24
+ Rails.logger.warn("#{self}: Asked to protect request we could not, unprotected: #{controller.request.method} #{controller.request.url}, (#{controller.request.remote_ip}, #{controller.request.user_agent})")
25
+ return
26
+ end
27
+
28
+ Rails.logger.info("#{self.name}: Cloudflare Turnstile challenge redirect: (#{controller.request.remote_ip}, #{controller.request.user_agent}): from #{controller.request.url}")
29
+ # status code temporary
30
+ controller.redirect_to controller.bot_detect_challenge_path(dest: controller.request.original_fullpath), status: 307
31
+ end
32
+ end
33
+
34
+ # Does the session already contain a bot detect pass that is good for this request
35
+ # Tie to IP address to prevent session replay shared among IPs
36
+ def _bot_detect_passed_good?(request)
37
+ session_data = request.session[self.bot_challenge_config.session_passed_key]
38
+
39
+ return false unless session_data && session_data.kind_of?(Hash)
40
+
41
+ datetime = session_data[BotChallengePageController::SESSION_DATETIME_KEY]
42
+ ip = session_data[BotChallengePageController::SESSION_IP_KEY]
43
+
44
+ (ip == request.remote_ip) && (Time.now - Time.iso8601(datetime) < self.bot_challenge_config.session_passed_good_for )
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,60 @@
1
+ module BotChallengePage
2
+
3
+ # Extracted to concern in separate file mostly for readability, not expected to be used
4
+ # anywehre but BotChallengePageController -- we hang all logic off controller to allow multiple
5
+ # controllers in an app, and over-ride in sub-classes.
6
+ module RackAttackInit
7
+ extend ActiveSupport::Concern
8
+
9
+
10
+ class_methods do
11
+ # perhaps in an initializer, and after changing any config, run:
12
+ #
13
+ # Rails.application.config.to_prepare do
14
+ # BotChallengePage::BotChallengePageController.rack_attack_init
15
+ # end
16
+ #
17
+ # Safe to call more than once if you change config and want to call again, say in testing.
18
+ def rack_attack_init
19
+ self._rack_attack_uninit # make it safe for calling multiple times
20
+
21
+ ## Turnstile bot detection throttling
22
+ #
23
+ # for paths matched by `rate_limited_locations`, after over rate_limit count requests in rate_limit_period,
24
+ # token will be stored in rack env instructing challenge is required.
25
+ #
26
+ # For actual challenge, need before_action in controller.
27
+ #
28
+ # You could rate limit detect on wider paths than you actually challenge on, or the same. You probably
29
+ # don't want to rate-limit detect on narrower list of paths than you challenge on!
30
+ Rack::Attack.track("bot_detect/rate_exceeded/#{self.name}",
31
+ limit: self.bot_challenge_config.rate_limit_count,
32
+ period: self.bot_challenge_config.rate_limit_period) do |req|
33
+ if self.bot_challenge_config.enabled && self.bot_challenge_config.location_matcher.call(req, self.bot_challenge_config)
34
+ self.bot_challenge_config.rate_limit_discriminator.call(req, self.bot_challenge_config)
35
+ end
36
+ end
37
+
38
+ self._track_notification_subscription = ActiveSupport::Notifications.subscribe("track.rack_attack") do |_name, _start, _finish, request_id, payload|
39
+ rack_request = payload[:request]
40
+ rack_env = rack_request.env
41
+ match_name = rack_env["rack.attack.matched"] # name of rack-attack rule
42
+ #
43
+ if match_name == "bot_detect/rate_exceeded/#{self.name}"
44
+ match_data = rack_env["rack.attack.match_data"]
45
+ match_data_formatted = match_data.slice(:count, :limit, :period).map { |k, v| "#{k}=#{v}"}.join(" ")
46
+ discriminator = rack_env["rack.attack.match_discriminator"] # unique key for rate limit, usually includes ip
47
+
48
+ rack_env[self.bot_challenge_config.env_challenge_trigger_key] = true
49
+ end
50
+ end
51
+ end
52
+
53
+ def _rack_attack_uninit
54
+ Rack::Attack.track("bot_detect/rate_exceeded/#{self.name}") {} # overwrite track name with empty proc
55
+ ActiveSupport::Notifications.unsubscribe(self._track_notification_subscription) if self._track_notification_subscription
56
+ self._track_notification_subscription = nil
57
+ end
58
+ end
59
+ end
60
+ end
@@ -1,3 +1,3 @@
1
1
  module BotChallengePage
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -10,12 +10,12 @@ module BotChallengePage
10
10
  end
11
11
 
12
12
  def add_before_filter_enforcement
13
+ # make the user do this themselves if they aren't using rack-attack, as it should
14
+ # only be on protected filters
15
+ return unless options[:rack_attack]
16
+
13
17
  inject_into_class "app/controllers/application_controller.rb", "ApplicationController" do
14
- filter_code = if options[:rack_attack]
15
- "BotChallengePage::BotChallengePageController.bot_challenge_enforce_filter(controller)"
16
- else
17
- "BotChallengePage::BotChallengePageController.bot_challenge_enforce_filter(controller, immediate: true)"
18
- end
18
+ filter_code = "BotChallengePage::BotChallengePageController.bot_challenge_enforce_filter(controller)"
19
19
 
20
20
  <<-EOS
21
21
  # This will only protect CONFIGURED routes, but also could be put on just certain
@@ -41,5 +41,23 @@ module BotChallengePage
41
41
  template "initializer.rb.erb", "config/initializers/bot_challenge_page.rb"
42
42
  end
43
43
 
44
+ def suggest_filter
45
+ unless options[:rack_attack]
46
+ instructions = <<~EOS
47
+ You must add before_action to protect controllers
48
+
49
+ Add, eg:
50
+
51
+ before_action only: :index do |controller|
52
+ BotChallengePage::BotChallengePageController.bot_challenge_enforce_filter(controller, immediate: true)
53
+ end
54
+
55
+ To desired controllers and/or ApplicationController
56
+ EOS
57
+
58
+ say_status("advise", instructions, :green)
59
+ end
60
+ end
61
+
44
62
  end
45
63
  end
@@ -3,9 +3,11 @@ Rails.application.config.to_prepare do
3
3
  BotChallengePage::BotChallengePageController.bot_challenge_config.enabled = true
4
4
 
5
5
  # Get from CloudFlare Turnstile: https://www.cloudflare.com/application-services/products/turnstile/
6
+ # Some testing keys are also available: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
6
7
  BotChallengePage::BotChallengePageController.bot_challenge_config.cf_turnstile_sitekey = "MUST GET"
7
8
  BotChallengePage::BotChallengePageController.bot_challenge_config.cf_turnstile_secret_key = "MUST GET"
8
9
 
10
+ <%- if options[:rack_attack] %>
9
11
  # What paths do you want to protect?
10
12
  #
11
13
  # You can use path prefixes: "/catalog" or even "/"
@@ -22,15 +24,14 @@ Rails.application.config.to_prepare do
22
24
  BotChallengePage::BotChallengePageController.bot_challenge_config.rate_limited_locations = [
23
25
  ]
24
26
 
25
- # How long will a challenge success exempt a session from further challenges?
26
- # BotChallengePage::BotChallengePageController.bot_challenge_config.session_passed_good_for = 36.hours
27
-
28
- <%- if options[:rack_attack] %>
29
27
  # allow rate_limit_count requests in rate_limit_period, before issuing challenge
30
28
  BotChallengePage::BotChallengePageController.bot_challenge_config.rate_limit_period = 12.hour
31
29
  BotChallengePage::BotChallengePageController.bot_challenge_config.rate_limit_count = 2
32
30
  <% end -%>
33
31
 
32
+ # How long will a challenge success exempt a session from further challenges?
33
+ # BotChallengePage::BotChallengePageController.bot_challenge_config.session_passed_good_for = 36.hours
34
+
34
35
  # Exempt some requests from bot challenge protection
35
36
  # BotChallengePage::BotChallengePageController.allow_exempt = ->(controller) {
36
37
  # # controller.params
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bot_challenge_page
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonathan Rochkind
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-02-27 00:00:00.000000000 Z
11
+ date: 2025-03-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: appraisal
@@ -153,6 +153,8 @@ files:
153
153
  - README.md
154
154
  - Rakefile
155
155
  - app/controllers/bot_challenge_page/bot_challenge_page_controller.rb
156
+ - app/controllers/concerns/bot_challenge_page/enforce_filter.rb
157
+ - app/controllers/concerns/bot_challenge_page/rack_attack_init.rb
156
158
  - app/models/bot_challenge_page/config.rb
157
159
  - app/views/bot_challenge_page/_local_turnstile_script_tag.html.erb
158
160
  - app/views/bot_challenge_page/_turnstile_widget_placeholder.html.erb