24 | <% end %>
25 |
26 | <%= yield %>
27 |
28 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/spec/dummy/config/secrets.yml:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Your secret key is used for verifying the integrity of signed cookies.
4 | # If you change this key, all old signed cookies will become invalid!
5 |
6 | # Make sure the secret is at least 30 characters and all random,
7 | # no regular words or you'll be exposed to dictionary attacks.
8 | # You can use `rake secret` to generate a secure secret key.
9 |
10 | # Make sure the secrets in this file are kept private
11 | # if you're sharing your code publicly.
12 |
13 | development:
14 | secret_key_base: d55ed9a6ec6fc0b93d2404994c8632220ab5835d77ccbd52760c6fc4b9e0c83f87d78d9c2b66d366a698933feeac81efc445b29bad22c9f267ebdadbc5aebbd4
15 |
16 | test:
17 | secret_key_base: 5df5772ea2c76236d1444a2e7a491c9f99f9bc96770b6b52e995555ea7a70b2bd3a3d0a45bbe7d32bf1a0eb450db7e32a838e6aa17c494b464ff381bb4bf9910
18 |
19 | # Do not keep production secrets in the repository,
20 | # instead read values from the environment.
21 | production:
22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2012-2024 Marc Anguera Insa
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/invisible_captcha.gemspec:
--------------------------------------------------------------------------------
1 | require './lib/invisible_captcha/version'
2 |
3 | Gem::Specification.new do |spec|
4 | spec.name = "invisible_captcha"
5 | spec.version = InvisibleCaptcha::VERSION
6 | spec.authors = ["Marc Anguera Insa"]
7 | spec.email = ["srmarc.ai@gmail.com"]
8 | spec.description = "Unobtrusive, flexible and complete spam protection for Rails applications using honeypot strategy for better user experience."
9 | spec.summary = "Honeypot spam protection for Rails"
10 | spec.homepage = "https://github.com/markets/invisible_captcha"
11 | spec.license = "MIT"
12 |
13 | spec.files = `git ls-files`.split($/)
14 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
15 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
16 | spec.require_paths = ["lib"]
17 |
18 | spec.add_dependency 'rails', '>= 5.2'
19 |
20 | spec.add_development_dependency 'rspec-rails'
21 | spec.add_development_dependency 'appraisal'
22 | spec.add_development_dependency 'webrick'
23 | spec.add_development_dependency 'simplecov'
24 | spec.add_development_dependency 'simplecov-cobertura'
25 | end
26 |
--------------------------------------------------------------------------------
/spec/dummy/app/views/topics/new.html.erb:
--------------------------------------------------------------------------------
1 | <% if @topic.errors.any? %>
2 |
3 | <%= pluralize(@topic.errors.count, "error") %> prohibited this record from being saved:
4 |
5 | <% @topic.errors.full_messages.each do |msg| %>
6 |
}
44 |
45 | expect(output).to match(regexp)
46 | end
47 |
48 | context "honeypot visibilty" do
49 | it 'visible from defaults' do
50 | InvisibleCaptcha.visual_honeypots = true
51 |
52 | expect(invisible_captcha).not_to match(/display:none/)
53 | end
54 |
55 | it 'visible from given instance (default override)' do
56 | expect(invisible_captcha(visual_honeypots: true)).not_to match(/display:none/)
57 | end
58 |
59 | it 'invisible from given instance (default override)' do
60 | InvisibleCaptcha.visual_honeypots = true
61 |
62 | expect(invisible_captcha(visual_honeypots: false)).to match(/display:none/)
63 | end
64 | end
65 |
66 | context "should have spinner field" do
67 | it 'that exists by default, spinner_enabled is true' do
68 | InvisibleCaptcha.spinner_enabled = true
69 | expect(invisible_captcha).to match(/spinner/)
70 | end
71 |
72 | it 'that does not exist if spinner_enabled is false' do
73 | InvisibleCaptcha.spinner_enabled = false
74 | expect(invisible_captcha).not_to match(/spinner/)
75 | end
76 | end
77 |
78 | it 'should set spam timestamp' do
79 | invisible_captcha
80 | expect(session[:invisible_captcha_timestamp]).to eq(Time.zone.now.iso8601)
81 | end
82 |
83 | context 'injectable_styles option' do
84 | it 'by default, render styles along with the honeypot' do
85 | expect(invisible_captcha).to match(/display:none/)
86 | expect(@view_flow.content[:invisible_captcha_styles]).to be_blank
87 | end
88 |
89 | it 'if injectable_styles is set, do not append styles inline' do
90 | InvisibleCaptcha.injectable_styles = true
91 |
92 | expect(invisible_captcha).not_to match(/display:none;/)
93 | expect(@view_flow.content[:invisible_captcha_styles]).to match(/display:none;/)
94 | end
95 | end
96 | end
97 |
--------------------------------------------------------------------------------
/lib/invisible_captcha/controller_ext.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module InvisibleCaptcha
4 | module ControllerExt
5 | module ClassMethods
6 | def invisible_captcha(options = {})
7 | if options.key?(:prepend)
8 | prepend_before_action(options) do
9 | detect_spam(options)
10 | end
11 | else
12 | before_action(options) do
13 | detect_spam(options)
14 | end
15 | end
16 | end
17 | end
18 |
19 | private
20 |
21 | def detect_spam(options = {})
22 | if timestamp_spam?(options)
23 | on_timestamp_spam(options)
24 | return if performed?
25 | end
26 |
27 | if honeypot_spam?(options) || spinner_spam?
28 | on_spam(options)
29 | end
30 | end
31 |
32 | def on_timestamp_spam(options = {})
33 | if action = options[:on_timestamp_spam]
34 | send(action)
35 | else
36 | flash[:error] = InvisibleCaptcha.timestamp_error_message
37 | redirect_back(fallback_location: defined?(root_path) ? root_path : "/")
38 | end
39 | end
40 |
41 | def on_spam(options = {})
42 | if action = options[:on_spam]
43 | send(action)
44 | else
45 | head(200)
46 | end
47 | end
48 |
49 | def timestamp_spam?(options = {})
50 | enabled = if options.key?(:timestamp_enabled)
51 | options[:timestamp_enabled]
52 | else
53 | InvisibleCaptcha.timestamp_enabled
54 | end
55 |
56 | return false unless enabled
57 |
58 | timestamp = session.delete(:invisible_captcha_timestamp)
59 |
60 | # Consider as spam if timestamp not in session, cause that means the form was not fetched at all
61 | unless timestamp
62 | warn_spam("Timestamp not found in session.")
63 | return true
64 | end
65 |
66 | time_to_submit = Time.zone.now - DateTime.iso8601(timestamp)
67 | threshold = options[:timestamp_threshold] || InvisibleCaptcha.timestamp_threshold
68 |
69 | # Consider as spam if form submitted too quickly
70 | if time_to_submit < threshold
71 | warn_spam("Timestamp threshold not reached (took #{time_to_submit.to_i}s).")
72 | return true
73 | end
74 |
75 | false
76 | end
77 |
78 | def spinner_spam?
79 | if InvisibleCaptcha.spinner_enabled && (params[:spinner].blank? || params[:spinner] != session[:invisible_captcha_spinner])
80 | warn_spam("Spinner value mismatch")
81 | return true
82 | end
83 |
84 | false
85 | end
86 |
87 | def honeypot_spam?(options = {})
88 | honeypot = options[:honeypot]
89 | scope = options[:scope] || controller_name.singularize
90 |
91 | if honeypot
92 | # If honeypot is defined for this controller-action, search for:
93 | # - honeypot: params[:subtitle]
94 | # - honeypot with scope: params[:topic][:subtitle]
95 | if params[honeypot].present? || (params[scope] && params[scope][honeypot].present?)
96 | warn_spam("Honeypot param '#{honeypot}' was present.")
97 | return true
98 | else
99 | # No honeypot spam detected, remove honeypot from params to avoid UnpermittedParameters exceptions
100 | params.delete(honeypot) if params.key?(honeypot)
101 | params[scope].try(:delete, honeypot) if params.key?(scope)
102 | end
103 | else
104 | InvisibleCaptcha.honeypots.each do |default_honeypot|
105 | if params[default_honeypot].present? || (params[scope] && params[scope][default_honeypot].present?)
106 | warn_spam("Honeypot param '#{scope}.#{default_honeypot}' was present.")
107 | return true
108 | end
109 | end
110 | end
111 |
112 | false
113 | end
114 |
115 | def warn_spam(message)
116 | message = "[Invisible Captcha] Potential spam detected for IP #{request.remote_ip}. #{message}"
117 |
118 | logger.warn(message)
119 |
120 | ActiveSupport::Notifications.instrument(
121 | 'invisible_captcha.spam_detected',
122 | message: message,
123 | remote_ip: request.remote_ip,
124 | user_agent: request.user_agent,
125 | controller: params[:controller],
126 | action: params[:action],
127 | url: request.url,
128 | params: request.filtered_parameters
129 | )
130 | end
131 | end
132 | end
133 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | ## [2.3.0]
6 |
7 | - Run honeypot + spinner checks and their callback also if timestamp triggers but passes through (#132)
8 | - Mark as spam requests with no spinner value (#134)
9 |
10 | ## [2.2.0]
11 |
12 | - Official support for Rails 7.1
13 | - Fix flash message for `on_timestamp_spam` callback (#125)
14 | - Fix potential error when lookup the honeypot parameter using (#128)
15 |
16 | ## [2.1.0]
17 |
18 | - Drop official support for EOL Rubies: 2.5 and 2.6
19 | - Allow random honeypots to be scoped (#117)
20 |
21 | ## [2.0.0]
22 |
23 | - New spinner, IP based, validation check (#89)
24 | - Drop official support for unmaintained Rails versions: 5.1, 5.0 and 4.2 (#86)
25 | - Drop official support for EOL Rubies: 2.4 and 2.3 (#86)
26 |
27 | ## [1.1.0]
28 |
29 | - New option `prepend: true` for the controller macro (#77)
30 |
31 | ## [1.0.1]
32 |
33 | - Fix naming issue with Ruby 2.7 (#65)
34 |
35 | ## [1.0.0]
36 |
37 | - Remove Ruby 2.2 and Rails 3.2 support
38 | - Add Instrumentation event (#62)
39 |
40 | ## [0.13.0]
41 |
42 | - Add support for the Content Security Policy nonce (#61)
43 | - Freeze all strings (#60)
44 |
45 | ## [0.12.2]
46 |
47 | - Allow new timestamp to be set during `on_timestamp_spam` callback (#53)
48 |
49 | ## [0.12.1]
50 |
51 | - Clear timestamp stored in `session[:invisible_captcha_timestamp]` (#50)
52 | - Rails 6 support
53 |
54 | ## [0.12.0]
55 |
56 | - Honeypot input with autocomplete="off" by default (#42)
57 |
58 | ## [0.11.0]
59 |
60 | - Improve logging (#40, #41)
61 | - Official Rails 5.2 support
62 | - Drop Ruby 2.1 from CI
63 |
64 | ## [0.10.0]
65 |
66 | - New timestamp on each request to avoid stale timestamps (#24)
67 | - Allow to inject styles manually anywhere in the layout (#27)
68 | - Allow to change threshold per action
69 | - Dynamic css strategy to hide the honeypot
70 | - Remove Ruby 1.9 support
71 | - Random default honeypots on each restart
72 | - Allow to pass html_options to honeypot input (#28)
73 | - Improvements on demo application and tests
74 | - Better strong parameters interaction (#30, #33)
75 |
76 | ## [0.9.3]
77 |
78 | - Rails 5.1 support (#29)
79 | - Modernize CI Rubies
80 |
81 | ## [0.9.2]
82 |
83 | - Rails 5.0 official support (#23)
84 | - Travis CI matrix improvements
85 |
86 | ## [0.9.1]
87 |
88 | - Add option (`timestamp_enabled`) to disable timestamp check (#22)
89 |
90 | ## [0.9.0]
91 |
92 | - Remove model style validations (#14)
93 | - Consider as spam if timestamp not in session (#11)
94 | - Allow to define a different threshold per action (#8)
95 | - Appraisals integration (#8)
96 | - CI improvements: use new Travis infrastructure (#8)
97 |
98 | ## [0.8.2]
99 |
100 | - Default timestamp action redirects to back (#19)
101 | - Stores timestamps as string in session (#17)
102 |
103 | ## [0.8.1]
104 |
105 | - Time-sensitive form submissions (#7)
106 | - I18n integration (#13)
107 |
108 | ## [0.8.0]
109 |
110 | - Better Rails integration with `ActiveSupport.on_load` callbacks (#5)
111 | - Allow to override settings via the view helper (#5)
112 |
113 | ## [0.7.0]
114 |
115 | - Revamped code base to allow more customizations (#2)
116 | - Added basic specs (#2)
117 | - Travis integration (#2)
118 | - Demo app (#2)
119 |
120 | ## [0.6.5]
121 |
122 | - Stop using Jeweler
123 |
124 | ## [0.6.4]
125 |
126 | - Docs! (#1)
127 |
128 | ## [0.6.3]
129 |
130 | - Internal re-naming
131 |
132 | ## [0.6.2]
133 |
134 | - Fix gem initialization
135 |
136 | ## [0.6.0]
137 |
138 | - Allow to configure via `InvisibleCaptcha.setup` block
139 |
140 | ## [0.5.0]
141 |
142 | - First version of controller filters
143 |
144 | [2.3.0]: https://github.com/markets/invisible_captcha/compare/v2.2.0...v2.3.0
145 | [2.2.0]: https://github.com/markets/invisible_captcha/compare/v2.1.0...v2.2.0
146 | [2.1.0]: https://github.com/markets/invisible_captcha/compare/v2.0.0...v2.1.0
147 | [2.0.0]: https://github.com/markets/invisible_captcha/compare/v1.1.0...v2.0.0
148 | [1.1.0]: https://github.com/markets/invisible_captcha/compare/v1.0.1...v1.1.0
149 | [1.0.1]: https://github.com/markets/invisible_captcha/compare/v1.0.0...v1.0.1
150 | [1.0.0]: https://github.com/markets/invisible_captcha/compare/v0.13.0...v1.0.0
151 | [0.13.0]: https://github.com/markets/invisible_captcha/compare/v0.12.2...v0.13.0
152 | [0.12.2]: https://github.com/markets/invisible_captcha/compare/v0.12.1...v0.12.2
153 | [0.12.1]: https://github.com/markets/invisible_captcha/compare/v0.12.0...v0.12.1
154 | [0.12.0]: https://github.com/markets/invisible_captcha/compare/v0.11.0...v0.12.0
155 | [0.11.0]: https://github.com/markets/invisible_captcha/compare/v0.10.0...v0.11.0
156 | [0.10.0]: https://github.com/markets/invisible_captcha/compare/v0.9.3...v0.10.0
157 | [0.9.3]: https://github.com/markets/invisible_captcha/compare/v0.9.2...v0.9.3
158 | [0.9.2]: https://github.com/markets/invisible_captcha/compare/v0.9.1...v0.9.2
159 | [0.9.1]: https://github.com/markets/invisible_captcha/compare/v0.9.0...v0.9.1
160 | [0.9.0]: https://github.com/markets/invisible_captcha/compare/v0.8.2...v0.9.0
161 | [0.8.2]: https://github.com/markets/invisible_captcha/compare/v0.8.1...v0.8.2
162 | [0.8.1]: https://github.com/markets/invisible_captcha/compare/v0.8.0...v0.8.1
163 | [0.8.0]: https://github.com/markets/invisible_captcha/compare/v0.7.0...v0.8.0
164 | [0.7.0]: https://github.com/markets/invisible_captcha/compare/v0.6.5...v0.7.0
165 | [0.6.5]: https://github.com/markets/invisible_captcha/compare/v0.6.4...v0.6.5
166 | [0.6.4]: https://github.com/markets/invisible_captcha/compare/v0.6.3...v0.6.4
167 | [0.6.3]: https://github.com/markets/invisible_captcha/compare/v0.6.2...v0.6.3
168 | [0.6.2]: https://github.com/markets/invisible_captcha/compare/v0.6.0...v0.6.2
169 | [0.6.0]: https://github.com/markets/invisible_captcha/compare/v0.5.0...v0.6.0
170 | [0.5.0]: https://github.com/markets/invisible_captcha/compare/v0.4.1...v0.5.0
171 |
--------------------------------------------------------------------------------
/spec/controllers_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe InvisibleCaptcha::ControllerExt, type: :controller do
4 | render_views
5 |
6 | before(:each) do
7 | @controller = TopicsController.new
8 | request.env['HTTP_REFERER'] = 'http://test.host/topics'
9 |
10 | InvisibleCaptcha.init!
11 | InvisibleCaptcha.timestamp_threshold = 1
12 | InvisibleCaptcha.spinner_enabled = false
13 | end
14 |
15 | context 'without invisible_captcha_timestamp in session' do
16 | it 'fails like if it was submitted too fast' do
17 | post :create, params: { topic: { title: 'foo' } }
18 |
19 | expect(response).to redirect_to 'http://test.host/topics'
20 | expect(flash[:error]).to eq(InvisibleCaptcha.timestamp_error_message)
21 | end
22 |
23 | it 'passes if disabled at action level' do
24 | post :copy, params: { topic: { title: 'foo' } }
25 |
26 | expect(flash[:error]).not_to be_present
27 | expect(response.body).to be_present
28 | end
29 |
30 | it 'passes if disabled at app level' do
31 | InvisibleCaptcha.timestamp_enabled = false
32 |
33 | post :create, params: { topic: { title: 'foo' } }
34 |
35 | expect(flash[:error]).not_to be_present
36 | expect(response.body).to be_present
37 | end
38 | end
39 |
40 | context 'submission timestamp_threshold' do
41 | before(:each) do
42 | session[:invisible_captcha_timestamp] = Time.zone.now.iso8601
43 | end
44 |
45 | it 'fails if submission before timestamp_threshold' do
46 | post :create, params: { topic: { title: 'foo' } }
47 |
48 | expect(response).to redirect_to 'http://test.host/topics'
49 | expect(flash[:error]).to eq(InvisibleCaptcha.timestamp_error_message)
50 |
51 | # Make sure session is cleared
52 | expect(session[:invisible_captcha_timestamp]).to be_nil
53 | end
54 |
55 | it 'allows a custom on_timestamp_spam callback' do
56 | put :update, params: { id: 1, topic: { title: 'bar' } }
57 |
58 | expect(response.status).to eq(204)
59 | end
60 |
61 | it 'allows a new timestamp to be set in the on_timestamp_spam callback' do
62 | @controller.singleton_class.class_eval do
63 | def custom_timestamp_callback
64 | session[:invisible_captcha_timestamp] = 2.seconds.from_now(Time.zone.now).iso8601
65 | head(204)
66 | end
67 | end
68 |
69 | expect { put :update, params: { id: 1, topic: { title: 'bar' } } }
70 | .to change { session[:invisible_captcha_timestamp] }
71 | .to be_present
72 | end
73 |
74 | it 'runs on_spam callback if on_timestamp_spam callback is defined but passes' do
75 | put :test_passthrough, params: { id: 1, topic: { title: 'bar', subtitle: 'foo' } }
76 |
77 | expect(response.status).to eq(204)
78 | end
79 |
80 | context 'successful submissions' do
81 | it 'passes if submission on or after timestamp_threshold' do
82 | sleep InvisibleCaptcha.timestamp_threshold
83 |
84 | post :create, params: {
85 | topic: {
86 | title: 'foobar',
87 | author: 'author',
88 | body: 'body that passes validation'
89 | }
90 | }
91 |
92 | expect(flash[:error]).not_to be_present
93 | expect(response.body).to redirect_to(new_topic_path)
94 |
95 | # Make sure session is cleared
96 | expect(session[:invisible_captcha_timestamp]).to be_nil
97 | end
98 |
99 | it 'allow to set a custom timestamp_threshold per action' do
100 | sleep 2 # custom threshold
101 |
102 | post :publish, params: { id: 1 }
103 |
104 | expect(flash[:error]).not_to be_present
105 | expect(response.body).to redirect_to(new_topic_path)
106 | end
107 |
108 | it 'passes if on_timestamp_spam doesn\'t perform' do
109 | put :test_passthrough, params: { id: 1, topic: { title: 'bar' } }
110 |
111 | expect(response.body).to redirect_to(new_topic_path)
112 | end
113 | end
114 | end
115 |
116 | context 'honeypot attribute' do
117 | before(:each) do
118 | session[:invisible_captcha_timestamp] = Time.zone.now.iso8601
119 |
120 | # Wait for valid submission
121 | sleep InvisibleCaptcha.timestamp_threshold
122 | end
123 |
124 | it 'fails with spam' do
125 | post :create, params: { topic: { subtitle: 'foo' } }
126 |
127 | expect(response.body).to be_blank
128 | end
129 |
130 | it 'passes with no spam' do
131 | post :create, params: { topic: { title: 'foo' } }
132 |
133 | expect(response.body).to be_present
134 | end
135 |
136 | context 'with random honeypot' do
137 | context 'auto-scoped' do
138 | it 'passes with no spam' do
139 | post :categorize, params: { topic: { title: 'foo' } }
140 |
141 | expect(response.body).to redirect_to(new_topic_path)
142 | end
143 |
144 | it 'fails with spam' do
145 | post :categorize, params: { topic: { "#{InvisibleCaptcha.honeypots.sample}": 'foo' } }
146 |
147 | expect(response.body).not_to redirect_to(new_topic_path)
148 | end
149 | end
150 |
151 | context 'with no scope' do
152 | it 'passes with no spam' do
153 | post :categorize
154 |
155 | expect(response.body).to redirect_to(new_topic_path)
156 | end
157 |
158 | it 'fails with spam' do
159 | post :categorize, params: { "#{InvisibleCaptcha.honeypots.sample}": 'foo' }
160 |
161 | expect(response.body).not_to redirect_to(new_topic_path)
162 | end
163 | end
164 |
165 | context 'with scope' do
166 | it 'fails with spam' do
167 | post :rename, params: { topic: { "#{InvisibleCaptcha.honeypots.sample}": 'foo' } }
168 |
169 | expect(response.body).to be_blank
170 | end
171 |
172 | it 'passes with no spam' do
173 | post :rename, params: { topic: { title: 'foo' } }
174 |
175 | expect(response.body).to be_blank
176 | end
177 | end
178 | end
179 |
180 | it 'allow a custom on_spam callback' do
181 | put :update, params: { id: 1, topic: { subtitle: 'foo' } }
182 |
183 | expect(response.body).to redirect_to(new_topic_path)
184 | end
185 |
186 | it 'honeypot is removed from params if you use a custom honeypot' do
187 | post :create, params: { topic: { title: 'foo', subtitle: '' } }
188 |
189 | expect(flash[:error]).not_to be_present
190 | expect(@controller.params[:topic].key?(:subtitle)).to eq(false)
191 | end
192 |
193 | describe 'ActiveSupport::Notifications' do
194 | let(:dummy_handler) { double(handle_event: nil) }
195 |
196 | let!(:subscriber) do
197 | subscriber = ActiveSupport::Notifications.subscribe('invisible_captcha.spam_detected') do |*args, data|
198 | dummy_handler.handle_event(data)
199 | end
200 |
201 | subscriber
202 | end
203 |
204 | after { ActiveSupport::Notifications.unsubscribe(subscriber) }
205 |
206 | it 'dispatches an `invisible_captcha.spam_detected` event' do
207 | expect(dummy_handler).to receive(:handle_event).once.with({
208 | message: "[Invisible Captcha] Potential spam detected for IP 0.0.0.0. Honeypot param 'subtitle' was present.",
209 | remote_ip: '0.0.0.0',
210 | user_agent: 'Rails Testing',
211 | controller: 'topics',
212 | action: 'create',
213 | url: 'http://test.host/topics',
214 | params: {
215 | topic: { subtitle: "foo"},
216 | controller: 'topics',
217 | action: 'create'
218 | }
219 | })
220 |
221 | post :create, params: { topic: { subtitle: 'foo' } }
222 | end
223 | end
224 | end
225 |
226 | context 'spinner attribute' do
227 | before(:each) do
228 | InvisibleCaptcha.spinner_enabled = true
229 | InvisibleCaptcha.secret = 'secret'
230 | session[:invisible_captcha_timestamp] = Time.zone.now.iso8601
231 | session[:invisible_captcha_spinner] = '32ab649161f9f6faeeb323746de1a25d'
232 |
233 | # Wait for valid submission
234 | sleep InvisibleCaptcha.timestamp_threshold
235 | end
236 |
237 | it 'fails with no spam, but mismatch of spinner' do
238 | post :create, params: { topic: { title: 'foo' }, spinner: 'mismatch' }
239 |
240 | expect(response.body).to be_blank
241 | end
242 |
243 | it 'passes with no spam and spinner match' do
244 | post :create, params: { topic: { title: 'foo' }, spinner: '32ab649161f9f6faeeb323746de1a25d' }
245 |
246 | expect(response.body).to be_present
247 | end
248 | end
249 | end
250 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Invisible Captcha
2 |
3 | [](https://rubygems.org/gems/invisible_captcha)
4 | [](https://github.com/markets/invisible_captcha/actions)
5 | [](https://codecov.io/gh/markets/invisible_captcha)
6 |
7 | > Complete and flexible spam protection solution for Rails applications.
8 |
9 | Invisible Captcha provides different techniques to protect your application against spambots.
10 |
11 | The main protection is a solution based on the `honeypot` principle, which provides a better user experience since there are no extra steps for real users, only for the bots.
12 |
13 | Essentially, the strategy consists on adding an input field :honey_pot: into the form that:
14 |
15 | - shouldn't be visible by the real users
16 | - should be left empty by the real users
17 | - will most likely be filled by spam bots
18 |
19 | It also comes with:
20 | - a time-sensitive :hourglass: form submission
21 | - an IP based :mag: spinner validation
22 |
23 | ## Installation
24 |
25 | Invisible Captcha is tested against Rails `>= 5.2` and Ruby `>= 2.7`.
26 |
27 | Add this line to your Gemfile and then execute `bundle install`:
28 |
29 | ```ruby
30 | gem 'invisible_captcha'
31 | ```
32 |
33 | ## Usage
34 |
35 | View code:
36 |
37 | ```erb
38 | <%= form_for(@topic) do |f| %>
39 | <%= f.invisible_captcha :subtitle %>
40 |
41 | <%= invisible_captcha :subtitle, :topic %>
42 | <% end %>
43 | ```
44 |
45 | Controller code:
46 |
47 | ```ruby
48 | class TopicsController < ApplicationController
49 | invisible_captcha only: [:create, :update], honeypot: :subtitle
50 | end
51 | ```
52 |
53 | This method will act as a `before_action` that triggers when spam is detected (honeypot field has some value). By default, it responds with no content (only headers: `head(200)`). This is a good default, since the bot will surely read the response code and will think that it has achieved to submit the form properly. But, anyway, you can define your own callback by passing a method to the `on_spam` option:
54 |
55 | ```ruby
56 | class TopicsController < ApplicationController
57 | invisible_captcha only: [:create, :update], on_spam: :your_spam_callback_method
58 |
59 | private
60 |
61 | def your_spam_callback_method
62 | redirect_to root_path
63 | end
64 | end
65 | ```
66 |
67 | You should _not_ name your method `on_spam`, as this will collide with an internal method of the same name.
68 |
69 | Note that it is not mandatory to specify a `honeypot` attribute (neither in the view nor in the controller). In this case, the engine will take a random field from `InvisibleCaptcha.honeypots`. So, if you're integrating it following this path, in your form:
70 |
71 | ```erb
72 | <%= form_tag(new_contact_path) do |f| %>
73 | <%= invisible_captcha %>
74 | <% end %>
75 | ```
76 |
77 | In your controller:
78 |
79 | ```
80 | invisible_captcha only: [:new_contact]
81 | ```
82 |
83 | `invisible_captcha` sends all messages to `flash[:error]`. For messages to appear on your pages, add `<%= flash[:error] %>` to `app/views/layouts/application.html.erb` (somewhere near the top of your `` element):
84 |
85 | ```erb
86 |
87 |
88 |
89 | Yet another Rails app
90 | <%= stylesheet_link_tag "application", media: "all" %>
91 | <%= javascript_include_tag "application" %>
92 | <%= csrf_meta_tags %>
93 |
94 |
95 | <%= flash[:error] %>
96 | <%= yield %>
97 |
98 |
99 | ```
100 |
101 | You can place `<%= flash[:error] %>` next to `:alert` and `:notice` message types, if you have them in your `app/views/layouts/application.html.erb`.
102 |
103 | **NOTE:** This gem relies on data set by the backend, so in order to properly work, your forms should be rendered by Rails. Forms generated via JavaScript are not going to work well.
104 |
105 | ## Options and customization
106 |
107 | This section contains a description of all plugin options and customizations.
108 |
109 | ### Plugin options:
110 |
111 | You can customize:
112 |
113 | - `sentence_for_humans`: text for real users if input field was visible. By default, it uses I18n (see below).
114 | - `honeypots`: collection of default honeypots. Used by the view helper, called with no args, to generate a random honeypot field name. By default, a random collection is already generated. As the random collection is stored in memory, it will not work if you are running multiple Rails instances behind a load balancer (see [Multiple Rails instances](#multiple-rails-instances)). Beware that Chrome may ignore `autocomplete="off"`. Thus, consider not to use field names, which would be autocompleted, like for example `name`, `country`.
115 | - `visual_honeypots`: make honeypots visible, also useful to test/debug your implementation.
116 | - `timestamp_threshold`: fastest time (in seconds) to expect a human to submit the form (see [original article by Yoav Aner](https://blog.gingerlime.com/2012/simple-detection-of-comment-spam-in-rails/) outlining the idea). By default, 4 seconds. **NOTE:** It's recommended to deactivate the autocomplete feature to avoid false positives (`autocomplete="off"`).
117 | - `timestamp_enabled`: option to disable the time threshold check at application level. Could be useful, for example, on some testing scenarios. By default, true.
118 | - `timestamp_error_message`: flash error message thrown when form submitted quicker than the `timestamp_threshold` value. It uses I18n by default.
119 | - `injectable_styles`: if enabled, you should call anywhere in your layout the following helper `<%= invisible_captcha_styles %>`. This allows you to inject styles, for example, in ``. False by default, styles are injected inline with the honeypot.
120 | - `spinner_enabled`: option to disable the IP spinner validation. By default, true.
121 | - `secret`: customize the secret key to encode some internal values. By default, it reads the environment variable `ENV['INVISIBLE_CAPTCHA_SECRET']` and fallbacks to random value. Be careful, if you are running multiple Rails instances behind a load balancer, use always the same value via the environment variable.
122 |
123 | To change these defaults, add the following to an initializer (recommended `config/initializers/invisible_captcha.rb`):
124 |
125 | ```ruby
126 | InvisibleCaptcha.setup do |config|
127 | # config.honeypots << ['more', 'fake', 'attribute', 'names']
128 | # config.visual_honeypots = false
129 | # config.timestamp_threshold = 2
130 | # config.timestamp_enabled = true
131 | # config.injectable_styles = false
132 | # config.spinner_enabled = true
133 |
134 | # Leave these unset if you want to use I18n (see below)
135 | # config.sentence_for_humans = 'If you are a human, ignore this field'
136 | # config.timestamp_error_message = 'Sorry, that was too quick! Please resubmit.'
137 | end
138 | ```
139 |
140 | #### Multiple Rails instances
141 |
142 | If you have multiple Rails instances running behind a load balancer, you have to share the same honeypots collection between the instances.
143 |
144 | Either use a fixed collection or share them between the instances using `Rails.cache`:
145 |
146 | ```ruby
147 | InvisibleCaptcha.setup do |config|
148 | config.honeypots = Rails.cache.fetch('invisible_captcha_honeypots') do
149 | (1..20).map { InvisibleCaptcha.generate_random_honeypot }
150 | end
151 | end
152 | ```
153 |
154 | Be careful also with the `secret` setting. Since it will be stored in-memory, if you are running this setup, the best idea is to provide the environment variable (`ENV['INVISIBLE_CAPTCHA_SECRET']`) from your infrastructure.
155 |
156 | ### Controller method options:
157 |
158 | The `invisible_captcha` method accepts some options:
159 |
160 | - `only`: apply to given controller actions.
161 | - `except`: exclude to given controller actions.
162 | - `honeypot`: name of custom honeypot.
163 | - `scope`: name of scope, ie: 'topic[subtitle]' -> 'topic' is the scope. By default, it's inferred from the `controller_name`.
164 | - `on_spam`: custom callback to be called on spam detection.
165 | - `timestamp_enabled`: enable/disable this technique at action level.
166 | - `on_timestamp_spam`: custom callback to be called when form submitted too quickly. The default action redirects to `:back` printing a warning in `flash[:error]`.
167 | - `timestamp_threshold`: custom threshold per controller/action. Overrides the global value for `InvisibleCaptcha.timestamp_threshold`.
168 | - `prepend`: the spam detection will run in a `prepend_before_action` if `prepend: true`, otherwise will run in a `before_action`.
169 |
170 | ### View helpers options:
171 |
172 | Using the view/form helper you can override some defaults for the given instance. Actually, it allows to change:
173 |
174 | - `sentence_for_humans`
175 |
176 | ```erb
177 | <%= form_for(@topic) do |f| %>
178 | <%= f.invisible_captcha :subtitle, sentence_for_humans: "hey! leave this input empty!" %>
179 | <% end %>
180 | ```
181 | - `visual_honeypots`
182 |
183 | ```erb
184 | <%= form_for(@topic) do |f| %>
185 | <%= f.invisible_captcha :subtitle, visual_honeypots: true %>
186 | <% end %>
187 | ```
188 |
189 | You can also pass html options to the input:
190 |
191 | ```erb
192 | <%= invisible_captcha :subtitle, :topic, id: "your_id", class: "your_class" %>
193 | ```
194 |
195 | ### Spam detection notifications
196 |
197 | In addition to the `on_spam` controller callback, you can use the [Active Support Instrumentation API](https://guides.rubyonrails.org/active_support_instrumentation.html) to set up a global event handler that fires whenever spam is detected. This is useful for advanced logging, background processing, etc.
198 |
199 | To set up a global event handler, [subscribe](https://guides.rubyonrails.org/active_support_instrumentation.html#subscribing-to-an-event) to the `invisible_captcha.spam_detected` event in an initializer:
200 |
201 | ```ruby
202 | # config/initializers/invisible_captcha.rb
203 |
204 | ActiveSupport::Notifications.subscribe('invisible_captcha.spam_detected') do |*args, data|
205 | AwesomeLogger.warn(data[:message], data) # Log to an external logging service.
206 | SpamRequest.create(data) # Record the blocked request in your database.
207 | end
208 | ```
209 |
210 | The `data` passed to the subscriber is hash containing information about the request that was detected as spam. For example:
211 |
212 | ```ruby
213 | {
214 | message: "Honeypot param 'subtitle' was present.",
215 | remote_ip: '127.0.0.1',
216 | user_agent: 'Chrome 77',
217 | controller: 'users',
218 | action: 'create',
219 | url: 'http://example.com/users',
220 | params: {
221 | topic: { subtitle: 'foo' },
222 | controller: 'users',
223 | action: 'create'
224 | }
225 | }
226 | ```
227 |
228 | **NOTE:** `params` will be filtered according to your `Rails.application.config.filter_parameters` configuration, making them (probably) safe for logging. But always double-check that you're not inadvertently logging sensitive form data, like passwords and credit cards.
229 |
230 | ### Content Security Policy
231 |
232 | If you're using a Content Security Policy (CSP) in your Rails app, you will need to generate a nonce on the server, and pass `nonce: true` attribute to the view helper. Uncomment the following lines in your `config/initializers/content_security_policy.rb` file:
233 |
234 | ```ruby
235 | # Be sure to restart your server when you modify this file.
236 |
237 | # If you are using UJS then enable automatic nonce generation
238 | Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) }
239 |
240 | # Set the nonce only to specific directives
241 | Rails.application.config.content_security_policy_nonce_directives = %w(style-src)
242 | ```
243 | Note that if you are already generating nonce for scripts, you'd have to include `script-src` to `content_security_policy_nonce_directives` as well:
244 |
245 | ```ruby
246 | Rails.application.config.content_security_policy_nonce_directives = %w(script-src style-src)
247 | ```
248 |
249 | And in your view helper, you need to pass `nonce: true` to the `invisible_captcha` helper:
250 |
251 | ```erb
252 | <%= invisible_captcha nonce: true %>
253 | ```
254 |
255 | **NOTE:** Content Security Policy can break your site! If you already run a website with third-party scripts, styles, images, and fonts, it is highly recommended to enable CSP in report-only mode and observe warnings as they appear. Learn more at MDN:
256 |
257 | * https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
258 | * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
259 |
260 | ### I18n
261 |
262 | `invisible_captcha` tries to use I18n when it's available by default. The keys it looks for are the following:
263 |
264 | ```yaml
265 | en:
266 | invisible_captcha:
267 | sentence_for_humans: "If you are human, ignore this field"
268 | timestamp_error_message: "Sorry, that was too quick! Please resubmit."
269 | ```
270 |
271 | You can override the English ones in your i18n config files as well as add new ones for other locales.
272 |
273 | If you intend to use I18n with `invisible_captcha`, you _must not_ set `sentence_for_humans` or `timestamp_error_message` to strings in the setup phase.
274 |
275 | ## Testing your controllers
276 |
277 | If you're encountering unexpected behaviour while testing controllers that use the `invisible_captcha` action filter, you may want to disable timestamp check for the test environment. Add the following snippet to the `config/initializers/invisible_captcha.rb` file:
278 |
279 | ```ruby
280 | # Be sure to restart your server when you modify this file.
281 |
282 | InvisibleCaptcha.setup do |config|
283 | config.timestamp_enabled = !Rails.env.test?
284 | end
285 | ```
286 |
287 | Another option is to wait for the timestamp check to be valid:
288 |
289 | ```ruby
290 | # Maybe inside a before block
291 | InvisibleCaptcha.init!
292 | InvisibleCaptcha.timestamp_threshold = 1
293 |
294 | # Before testing your controller action
295 | sleep InvisibleCaptcha.timestamp_threshold
296 | ```
297 |
298 | If you're using the "random honeypot" approach, you may want to set a known honeypot:
299 |
300 | ```ruby
301 | config.honeypots = ['my_honeypot_field'] if Rails.env.test?
302 | ```
303 |
304 | And for the "spinner validation" check, you may want to disable it:
305 |
306 | ```ruby
307 | config.spinner_enabled = !Rails.env.test?
308 | ```
309 |
310 | Or alternativelly, you should send a valid spinner value along your requests:
311 |
312 | ```ruby
313 | # RSpec example
314 | session[:invisible_captcha_spinner] = '32ab649161f9f6faeeb323746de1a25d'
315 | post :create, params: { topic: { title: 'foo' }, spinner: '32ab649161f9f6faeeb323746de1a25d' }
316 | ```
317 |
318 | ## Contribute
319 |
320 | Any kind of idea, feedback or bug report are welcome! Open an [issue](https://github.com/markets/invisible_captcha/issues) or send a [pull request](https://github.com/markets/invisible_captcha/pulls).
321 |
322 | ## Development
323 |
324 | Clone/fork this repository, start to hack on it and send a pull request.
325 |
326 | Run the test suite:
327 |
328 | ```
329 | $ bundle exec rspec
330 | ```
331 |
332 | Run the test suite against all supported versions:
333 |
334 | ```
335 | $ bundle exec appraisal install
336 | $ bundle exec appraisal rspec
337 | ```
338 |
339 | Run specs against specific version:
340 |
341 | ```
342 | $ bundle exec appraisal rails-7.0 rspec
343 | ```
344 |
345 | ### Demo
346 |
347 | Start a sample Rails app ([source code](spec/dummy)) with `InvisibleCaptcha` integrated:
348 |
349 | ```
350 | $ bundle exec rake web # PORT=4000 (default: 3000)
351 | ```
352 |
353 | ## License
354 |
355 | Copyright (c) Marc Anguera. Invisible Captcha is released under the [MIT](LICENSE) License.
356 |
--------------------------------------------------------------------------------