├── spec ├── dummy │ ├── log │ │ └── .gitkeep │ ├── public │ │ ├── favicon.ico │ │ ├── styles.css │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── app │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── controllers │ │ │ ├── application_controller.rb │ │ │ └── topics_controller.rb │ │ ├── models │ │ │ └── topic.rb │ │ └── views │ │ │ ├── layouts │ │ │ └── application.html.erb │ │ │ └── topics │ │ │ └── new.html.erb │ ├── bin │ │ ├── rake │ │ ├── bundle │ │ ├── rails │ │ └── setup │ ├── config │ │ ├── boot.rb │ │ ├── environment.rb │ │ ├── initializers │ │ │ ├── cookies_serializer.rb │ │ │ ├── session_store.rb │ │ │ ├── mime_types.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── backtrace_silencers.rb │ │ │ ├── secret_token.rb │ │ │ ├── wrap_parameters.rb │ │ │ └── inflections.rb │ │ ├── routes.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── secrets.yml │ │ ├── application.rb │ │ └── environments │ │ │ ├── test.rb │ │ │ └── development.rb │ ├── config.ru │ ├── Rakefile │ └── README.md ├── spec_helper.rb ├── invisible_captcha_spec.rb ├── view_helpers_spec.rb └── controllers_spec.rb ├── .rspec ├── Gemfile ├── lib ├── invisible_captcha │ ├── version.rb │ ├── form_helpers.rb │ ├── railtie.rb │ ├── view_helpers.rb │ └── controller_ext.rb └── invisible_captcha.rb ├── .gitignore ├── gemfiles ├── rails_5.2.gemfile ├── rails_6.0.gemfile ├── rails_6.1.gemfile ├── rails_7.0.gemfile ├── rails_7.1.gemfile ├── rails_7.2.gemfile └── rails_8.0.gemfile ├── Rakefile ├── Appraisals ├── LICENSE ├── invisible_captcha.gemspec ├── .github └── workflows │ └── ci.yml ├── CHANGELOG.md └── README.md /spec/dummy/log/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /spec/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /lib/invisible_captcha/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module InvisibleCaptcha 4 | VERSION = "2.3.0" 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | 3 | require 'bundler/setup' # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../../config/application', __FILE__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Dummy::Application 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.action_dispatch.cookies_serializer = :marshal 4 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_dummy_session' 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | pkg 3 | .rvmrc 4 | .ruby-version 5 | .ruby-gemset 6 | Gemfile.lock 7 | *.gemfile.lock 8 | spec/dummy/log/*.log 9 | spec/dummy/tmp/ 10 | .byebug_history 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /gemfiles/rails_5.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 5.2.0" 6 | gem "concurrent-ruby", "< 1.3.5" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_6.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 6.0.0" 6 | gem "concurrent-ruby", "< 1.3.5" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 6.1.0" 6 | gem "concurrent-ruby", "< 1.3.5" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 7.0.0" 6 | gem "concurrent-ruby", "< 1.3.5" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_7.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 7.1.0" 6 | gem "concurrent-ruby", "< 1.3.5" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_7.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 7.2.0" 6 | gem "concurrent-ruby", "< 1.3.5" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_8.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 8.0.0" 6 | gem "concurrent-ruby", "< 1.3.5" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /lib/invisible_captcha/form_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module InvisibleCaptcha 4 | module FormHelpers 5 | def invisible_captcha(honeypot = nil, options = {}) 6 | @template.invisible_captcha(honeypot, self.object_name, options) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | # Add your own tasks in files placed in lib/tasks ending in .rake, 3 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 4 | 5 | require File.expand_path('../config/application', __FILE__) 6 | 7 | Dummy::Application.load_tasks 8 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | resources :topics do 3 | post :publish, on: :member 4 | post :rename, on: :collection 5 | post :categorize, on: :collection 6 | post :copy, on: :collection 7 | post :test_passthrough, on: :collection 8 | end 9 | 10 | root to: 'topics#new' 11 | end 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | 5 | desc 'Start development Rails app' 6 | task :web do 7 | app_path = 'spec/dummy' 8 | port = ENV['PORT'] || 3000 9 | 10 | puts "Starting application in http://localhost:#{port} ... \n" 11 | 12 | Dir.chdir(app_path) 13 | exec("rails s -p #{port}") 14 | end 15 | -------------------------------------------------------------------------------- /spec/dummy/README.md: -------------------------------------------------------------------------------- 1 | # Dummy App 2 | 3 | Dummy Rails Application to test `Invisible Captcha`. 4 | 5 | It's also used as a demo application to show `Invisible Captcha` in action. You can run the app by using the following command, from the root of the project: 6 | 7 | > bundle exec rake web 8 | 9 | [« Back to Docs](https://github.com/markets/invisible_captcha#invisible-captcha) 10 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | %w( 2 | 8.0 3 | 7.2 4 | 7.1 5 | 7.0 6 | 6.1 7 | 6.0 8 | 5.2 9 | ).each do |version| 10 | appraise "rails-#{version}" do 11 | gem "rails", "~> #{version}.0" 12 | 13 | # NOTE: The gem concurrent-ruby no longer loads the logger gem since v1.3.5. 14 | # More info: https://github.com/rails/rails/pull/54264 15 | gem "concurrent-ruby", "< 1.3.5" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /spec/dummy/app/models/topic.rb: -------------------------------------------------------------------------------- 1 | class Topic 2 | include ActiveModel::Validations 3 | include ActiveModel::Conversion 4 | 5 | attr_accessor :title, :author, :body, :subtitle 6 | 7 | validates :title, length: { minimum: 5 } 8 | validates :author, presence: true 9 | validates :body, length: { minimum: 10 } 10 | 11 | def initialize(attributes = {}) 12 | attributes.each do |name, value| 13 | send("#{name}=", value) 14 | end 15 | end 16 | 17 | def persisted? 18 | false 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/secret_token.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | # Make sure the secret is at least 30 characters and all random, 6 | # no regular words or you'll be exposed to dictionary attacks. 7 | Dummy::Application.config.secret_token = '99a354dfaace29d4d6daae4be11b63c13799e60ddff44a5880b12bf2e551de5daef2688495da1475131b5b0a85e57c4303a6b72834edc8a1b51a8c94d9949d59' 8 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] if respond_to?(:wrap_parameters) 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /lib/invisible_captcha/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module InvisibleCaptcha 4 | class Railtie < Rails::Railtie 5 | initializer 'invisible_captcha.rails_integration' do 6 | ActiveSupport.on_load(:action_controller) do 7 | include InvisibleCaptcha::ControllerExt 8 | extend InvisibleCaptcha::ControllerExt::ClassMethods 9 | end 10 | 11 | ActiveSupport.on_load(:action_view) do 12 | include InvisibleCaptcha::ViewHelpers 13 | ActionView::Helpers::FormBuilder.send :include, InvisibleCaptcha::FormHelpers 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['RAILS_ENV'] = 'test' 4 | 5 | require 'simplecov' 6 | if ENV['CI'] 7 | require 'simplecov-cobertura' 8 | SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter 9 | end 10 | SimpleCov.start 11 | 12 | require File.expand_path("../dummy/config/environment.rb", __FILE__) 13 | require 'rspec/rails' 14 | require 'invisible_captcha' 15 | 16 | RSpec.configure do |config| 17 | config.include ActionDispatch::ContentSecurityPolicy::Request, type: :helper 18 | config.disable_monkey_patching! 19 | config.order = :random 20 | config.expect_with :rspec 21 | config.mock_with :rspec do |mocks| 22 | mocks.verify_partial_doubles = true 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /spec/dummy/public/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, Helvetica, sans-serif; 3 | background-color: #ccc; 4 | margin: 2em; 5 | } 6 | 7 | h1 { 8 | border-bottom: 3px solid; 9 | } 10 | 11 | a { 12 | color: #000; 13 | } 14 | 15 | input, textarea { 16 | border: 0; 17 | margin-bottom: 1.5em; 18 | } 19 | 20 | input { 21 | height: 2em; 22 | } 23 | 24 | button { 25 | color: #fff; 26 | background-color: #000; 27 | border: none; 28 | border-radius: 0.25em; 29 | height: 3em; 30 | width: 10em; 31 | font-size: 1em; 32 | } 33 | 34 | footer { 35 | position: fixed; 36 | bottom: 0; 37 | width: 100%; 38 | padding: 1em 0; 39 | font-size: 0.8em; 40 | background-color: #ccc; 41 | } 42 | 43 | .errors { 44 | color: darkred; 45 | } 46 | -------------------------------------------------------------------------------- /spec/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

We're sorry, but something went wrong.

23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /spec/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

The change you wanted was rejected.

23 |

Maybe you tried to change something you didn't have access to.

24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /spec/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

The page you were looking for doesn't exist.

23 |

You may have mistyped the address or the page may have moved.

24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | 4 | # path to your application root. 5 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 6 | 7 | Dir.chdir APP_ROOT do 8 | # This script is a starting point to setup your application. 9 | # Add necessary setup steps to this file: 10 | 11 | puts "== Installing dependencies ==" 12 | system "gem install bundler --conservative" 13 | system "bundle check || bundle install" 14 | 15 | # puts "\n== Copying sample files ==" 16 | # unless File.exist?("config/database.yml") 17 | # system "cp config/database.yml.sample config/database.yml" 18 | # end 19 | 20 | puts "\n== Preparing database ==" 21 | system "bin/rake db:setup" 22 | 23 | puts "\n== Removing old logs and tempfiles ==" 24 | system "rm -f log/*" 25 | system "rm -rf tmp/cache" 26 | 27 | puts "\n== Restarting application server ==" 28 | system "touch tmp/restart.txt" 29 | end 30 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | InvisibleCaptcha Demo 5 | <%= stylesheet_link_tag "/styles.css" %> 6 | <%= csrf_meta_tags %> 7 | <%= invisible_captcha_styles %> 8 | 9 | 10 |

InvisibleCaptcha v<%= InvisibleCaptcha::VERSION %> - Demo

11 | 12 |

13 | <%= link_to "Default settings", new_topic_path %> | 14 | <%= link_to "With visual honeypots", new_topic_path(context: "visual_honeypots") %> | 15 | <%= link_to "With timestamp disabled", new_topic_path(context: "timestamp_disabled") %> 16 |

17 | 18 | <% flash.each do |key, value| %> 19 | 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 | 9 |
10 | <% end %> 11 | 12 | <%= form_for(@topic, url: { action: params[:context] == 'timestamp_disabled' ? :copy : :create }) do |f| %> 13 | <%= hidden_field_tag :context, params[:context] %> 14 | 15 | <% if params[:context] && params[:context] == 'visual_honeypots' %> 16 | <%= f.invisible_captcha :subtitle, visual_honeypots: true %> 17 | <% else %> 18 | <%= f.invisible_captcha :subtitle %> 19 | <% end %> 20 | 21 |
22 | <%= f.label :title %>
23 | <%= f.text_field :title %> 24 |
25 | 26 |
27 | <%= f.label :author %>
28 | <%= f.text_field :author %> 29 |
30 | 31 |
32 | <%= f.label :body %>
33 | <%= f.text_area :body, rows: 10, cols: 40 %> 34 |
35 | 36 |
37 | <%= f.button 'Save' %> 38 |
39 | <% end %> 40 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | test: 11 | name: CI 12 | runs-on: ubuntu-latest 13 | env: 14 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | ruby: ["3.4", "3.3", "3.2", "3.1"] 19 | gemfile: [rails_8.0, rails_7.2, rails_7.1, rails_7.0, rails_6.1] 20 | exclude: 21 | - ruby: "3.1" 22 | gemfile: rails_8.0 23 | - ruby: "3.4" 24 | gemfile: rails_7.0 25 | - ruby: "3.4" 26 | gemfile: rails_6.1 27 | include: 28 | - ruby: "2.7" 29 | gemfile: rails_5.2 30 | - ruby: "3.0" 31 | gemfile: rails_6.0 32 | - ruby: head 33 | gemfile: rails_8.0 34 | steps: 35 | - uses: actions/checkout@v3 36 | - uses: ruby/setup-ruby@v1 37 | with: 38 | ruby-version: ${{ matrix.ruby }} 39 | bundler-cache: true 40 | - run: bundle exec rspec 41 | continue-on-error: ${{ endsWith(matrix.ruby, 'head') }} 42 | - name: Upload coverage reports to Codecov 43 | uses: codecov/codecov-action@v3 44 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | require 'action_controller/railtie' 4 | require 'action_view/railtie' 5 | require 'active_model/railtie' 6 | 7 | # Require the gems listed in Gemfile, including any gems 8 | # you've limited to :test, :development, or :production. 9 | Bundler.require(*Rails.groups) 10 | 11 | require 'invisible_captcha' 12 | 13 | module Dummy 14 | class Application < Rails::Application 15 | # Settings in config/environments/* take precedence over those specified here. 16 | # Application configuration should go into files in config/initializers 17 | # -- all .rb files in that directory are automatically loaded. 18 | 19 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 20 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 21 | # config.time_zone = 'Central Time (US & Canada)' 22 | 23 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 24 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 25 | # config.i18n.default_locale = :de 26 | 27 | # Do not swallow errors in after_commit/after_rollback callbacks. 28 | # config.active_record.raise_in_transactional_callbacks = true 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Disable serving static files from the `/public` folder by default since 16 | # Apache or NGINX already handles this. 17 | config.public_file_server.enabled = true 18 | config.public_file_server.headers = {'Cache-Control' => 'public, max-age=3600'} 19 | 20 | # Show full error reports and disable caching. 21 | config.consider_all_requests_local = true 22 | config.action_controller.perform_caching = false 23 | 24 | # Raise exceptions instead of rendering exception templates. 25 | config.action_dispatch.show_exceptions = false 26 | 27 | # Disable request forgery protection in test environment. 28 | config.action_controller.allow_forgery_protection = false 29 | 30 | # Tell Action Mailer not to deliver emails to the real world. 31 | # The :test delivery method accumulates sent emails in the 32 | # ActionMailer::Base.deliveries array. 33 | # config.action_mailer.delivery_method = :test 34 | 35 | # Print deprecation notices to the stderr. 36 | config.active_support.deprecation = :stderr 37 | end 38 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports and disable caching. 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send. 17 | # config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger. 20 | config.active_support.deprecation = :log 21 | 22 | # Raise an error on page load if there are pending migrations. 23 | # config.active_record.migration_error = :page_load 24 | 25 | # Debug mode disables concatenation and preprocessing of assets. 26 | # This option may cause significant delays in view rendering with a large 27 | # number of complex assets. 28 | # config.assets.debug = true 29 | 30 | # Asset digests allow you to set far-future HTTP expiration dates on all assets, 31 | # yet still be able to expire them through the digest params. 32 | # config.assets.digest = true 33 | 34 | # Adds additional error checking when serving assets at runtime. 35 | # Checks for improperly declared sprockets dependencies. 36 | # Raises helpful error messages. 37 | # config.assets.raise_runtime_errors = true 38 | 39 | # Raises error for missing translations 40 | # config.action_view.raise_on_missing_translations = true 41 | end 42 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/topics_controller.rb: -------------------------------------------------------------------------------- 1 | class TopicsController < ApplicationController 2 | invisible_captcha honeypot: :subtitle, only: :create 3 | 4 | invisible_captcha honeypot: :subtitle, only: :update, 5 | on_spam: :custom_callback, 6 | on_timestamp_spam: :custom_timestamp_callback 7 | 8 | invisible_captcha honeypot: :subtitle, only: :publish, timestamp_threshold: 2 9 | 10 | invisible_captcha honeypot: :subtitle, only: :copy, timestamp_enabled: false 11 | 12 | invisible_captcha scope: :topic, only: :rename 13 | 14 | invisible_captcha only: :categorize 15 | 16 | invisible_captcha honeypot: :subtitle, only: :test_passthrough, 17 | on_spam: :catching_on_spam_callback, 18 | on_timestamp_spam: :on_timestamp_spam_callback_with_passthrough 19 | 20 | def index 21 | redirect_to new_topic_path 22 | end 23 | 24 | def new 25 | @topic = Topic.new 26 | end 27 | 28 | def create 29 | @topic = Topic.new(params[:topic]) 30 | 31 | if @topic.valid? 32 | redirect_to new_topic_path(context: params[:context]), notice: 'Topic valid!' 33 | else 34 | render action: 'new' 35 | end 36 | end 37 | 38 | def update 39 | redirect_to new_topic_path 40 | end 41 | 42 | def rename 43 | end 44 | 45 | def categorize 46 | redirect_to new_topic_path 47 | end 48 | 49 | def publish 50 | redirect_to new_topic_path 51 | end 52 | 53 | def copy 54 | @topic = Topic.new(params[:topic]) 55 | 56 | if @topic.valid? 57 | redirect_to new_topic_path(context: params[:context]), notice: 'Success!' 58 | else 59 | render action: 'new' 60 | end 61 | end 62 | 63 | def test_passthrough 64 | redirect_to new_topic_path 65 | end 66 | 67 | private 68 | 69 | def custom_callback 70 | redirect_to new_topic_path 71 | end 72 | 73 | def custom_timestamp_callback 74 | head(204) 75 | end 76 | 77 | def on_timestamp_spam_callback_with_passthrough 78 | end 79 | 80 | def catching_on_spam_callback 81 | head(204) 82 | end 83 | 84 | end 85 | -------------------------------------------------------------------------------- /spec/invisible_captcha_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe InvisibleCaptcha do 4 | it 'initialize with defaults' do 5 | InvisibleCaptcha.init! 6 | 7 | expect(InvisibleCaptcha.sentence_for_humans).to eq('If you are a human, ignore this field') 8 | expect(InvisibleCaptcha.timestamp_threshold).to eq(4.seconds) 9 | expect(InvisibleCaptcha.timestamp_error_message).to eq('Sorry, that was too quick! Please resubmit.') 10 | expect(InvisibleCaptcha.honeypots).to be_an_instance_of(Array) 11 | expect(InvisibleCaptcha.injectable_styles).to eq(false) 12 | end 13 | 14 | it 'allow setup via block' do 15 | InvisibleCaptcha.setup do |ic| 16 | ic.sentence_for_humans = 'Another sentence' 17 | end 18 | 19 | expect(InvisibleCaptcha.sentence_for_humans).to eq('Another sentence') 20 | end 21 | 22 | it 'It uses I18n when available' do 23 | InvisibleCaptcha.init! 24 | 25 | I18n.available_locales = [:en, :fr] 26 | 27 | I18n.backend.store_translations(:en, 28 | 'invisible_captcha' => { 29 | 'sentence_for_humans' => "Can't touch this", 30 | 'timestamp_error_message' => 'Fast and furious' }) 31 | 32 | I18n.backend.store_translations(:fr, 33 | 'invisible_captcha' => { 34 | 'sentence_for_humans' => 'Ne touchez pas', 35 | 'timestamp_error_message' => 'Plus doucement SVP' }) 36 | 37 | I18n.locale = :en 38 | expect(InvisibleCaptcha.sentence_for_humans).to eq("Can't touch this") 39 | expect(InvisibleCaptcha.timestamp_error_message).to eq('Fast and furious') 40 | 41 | I18n.locale = :fr 42 | expect(InvisibleCaptcha.sentence_for_humans).to eq('Ne touchez pas') 43 | expect(InvisibleCaptcha.timestamp_error_message).to eq('Plus doucement SVP') 44 | 45 | I18n.backend.reload! 46 | expect(InvisibleCaptcha.sentence_for_humans).to eq('If you are a human, ignore this field') 47 | expect(InvisibleCaptcha.timestamp_error_message).to eq('Sorry, that was too quick! Please resubmit.') 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/invisible_captcha.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'invisible_captcha/version' 4 | require 'invisible_captcha/controller_ext' 5 | require 'invisible_captcha/view_helpers' 6 | require 'invisible_captcha/form_helpers' 7 | require 'invisible_captcha/railtie' 8 | 9 | module InvisibleCaptcha 10 | class << self 11 | attr_writer :sentence_for_humans, 12 | :timestamp_error_message 13 | 14 | attr_accessor :honeypots, 15 | :timestamp_threshold, 16 | :timestamp_enabled, 17 | :visual_honeypots, 18 | :injectable_styles, 19 | :spinner_enabled, 20 | :secret 21 | 22 | def init! 23 | # Default sentence for real users if text field was visible 24 | self.sentence_for_humans = -> { I18n.t('invisible_captcha.sentence_for_humans', default: 'If you are a human, ignore this field') } 25 | 26 | # Timestamp check enabled by default 27 | self.timestamp_enabled = true 28 | 29 | # Fastest time (in seconds) to expect a human to submit the form 30 | self.timestamp_threshold = 4 31 | 32 | # Default error message for validator when form submitted too quickly 33 | self.timestamp_error_message = -> { I18n.t('invisible_captcha.timestamp_error_message', default: 'Sorry, that was too quick! Please resubmit.') } 34 | 35 | # Make honeypots visibles 36 | self.visual_honeypots = false 37 | 38 | # If enabled, you should call anywhere in your layout the following helper, to inject the honeypot styles: 39 | # <%= invisible_captcha_styles %> 40 | self.injectable_styles = false 41 | 42 | # Spinner check enabled by default 43 | self.spinner_enabled = true 44 | 45 | # A secret key to encode some internal values 46 | self.secret = ENV['INVISIBLE_CAPTCHA_SECRET'] || SecureRandom.hex(64) 47 | end 48 | 49 | def sentence_for_humans 50 | call_lambda_or_return(@sentence_for_humans) 51 | end 52 | 53 | def timestamp_error_message 54 | call_lambda_or_return(@timestamp_error_message) 55 | end 56 | 57 | def setup 58 | yield(self) if block_given? 59 | end 60 | 61 | def honeypots 62 | @honeypots ||= (1..5).map { generate_random_honeypot } 63 | end 64 | 65 | def generate_random_honeypot 66 | "abcdefghijkl-mnopqrstuvwxyz".chars.sample(rand(10..20)).join 67 | end 68 | 69 | def get_honeypot 70 | honeypots.sample 71 | end 72 | 73 | def css_strategy 74 | [ 75 | "display:none;", 76 | "position:absolute!important;top:-9999px;left:-9999px;", 77 | "position:absolute!important;height:1px;width:1px;overflow:hidden;" 78 | ].sample 79 | end 80 | 81 | def encode(value) 82 | Digest::MD5.hexdigest("#{self.secret}-#{value}") 83 | end 84 | 85 | private 86 | 87 | def call_lambda_or_return(obj) 88 | obj.respond_to?(:call) ? obj.call : obj 89 | end 90 | end 91 | end 92 | 93 | InvisibleCaptcha.init! 94 | -------------------------------------------------------------------------------- /lib/invisible_captcha/view_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module InvisibleCaptcha 4 | module ViewHelpers 5 | # Builds the honeypot html 6 | # 7 | # @param honeypot [Symbol] name of honeypot, ie: subtitle => input name: subtitle 8 | # @param scope [Symbol] name of honeypot scope, ie: topic => input name: topic[subtitle] 9 | # @param options [Hash] html_options for input and invisible_captcha options 10 | # 11 | # @return [String] the generated html 12 | def invisible_captcha(honeypot = nil, scope = nil, options = {}) 13 | @captcha_ocurrences = 0 unless defined?(@captcha_ocurrences) 14 | @captcha_ocurrences += 1 15 | 16 | if InvisibleCaptcha.timestamp_enabled || InvisibleCaptcha.spinner_enabled 17 | session[:invisible_captcha_timestamp] = Time.zone.now.iso8601 18 | end 19 | 20 | if InvisibleCaptcha.spinner_enabled && @captcha_ocurrences == 1 21 | session[:invisible_captcha_spinner] = InvisibleCaptcha.encode("#{session[:invisible_captcha_timestamp]}-#{request.remote_ip}") 22 | end 23 | 24 | build_invisible_captcha(honeypot, scope, options) 25 | end 26 | 27 | def invisible_captcha_styles 28 | if content_for?(:invisible_captcha_styles) 29 | content_for(:invisible_captcha_styles) 30 | end 31 | end 32 | 33 | private 34 | 35 | def build_invisible_captcha(honeypot = nil, scope = nil, options = {}) 36 | if honeypot.is_a?(Hash) 37 | options = honeypot 38 | honeypot = nil 39 | end 40 | 41 | honeypot = honeypot ? honeypot.to_s : InvisibleCaptcha.get_honeypot 42 | label = options.delete(:sentence_for_humans) || InvisibleCaptcha.sentence_for_humans 43 | css_class = "#{honeypot}_#{Time.zone.now.to_i}" 44 | 45 | styles = visibility_css(css_class, options) 46 | 47 | provide(:invisible_captcha_styles) do 48 | styles 49 | end if InvisibleCaptcha.injectable_styles 50 | 51 | content_tag(:div, class: css_class) do 52 | concat styles unless InvisibleCaptcha.injectable_styles 53 | concat label_tag(build_label_name(honeypot, scope), label) 54 | concat text_field_tag(build_input_name(honeypot, scope), nil, default_honeypot_options.merge(options)) 55 | if InvisibleCaptcha.spinner_enabled 56 | concat hidden_field_tag("spinner", session[:invisible_captcha_spinner], id: nil) 57 | end 58 | end 59 | end 60 | 61 | def visibility_css(css_class, options) 62 | visible = if options.key?(:visual_honeypots) 63 | options.delete(:visual_honeypots) 64 | else 65 | InvisibleCaptcha.visual_honeypots 66 | end 67 | 68 | return if visible 69 | 70 | nonce = content_security_policy_nonce if options[:nonce] 71 | 72 | content_tag(:style, media: 'screen', nonce: nonce) do 73 | ".#{css_class} {#{InvisibleCaptcha.css_strategy}}" 74 | end 75 | end 76 | 77 | def build_label_name(honeypot, scope = nil) 78 | if scope.present? 79 | "#{scope}_#{honeypot}" 80 | else 81 | honeypot 82 | end 83 | end 84 | 85 | def build_input_name(honeypot, scope = nil) 86 | if scope.present? 87 | "#{scope}[#{honeypot}]" 88 | else 89 | honeypot 90 | end 91 | end 92 | 93 | def default_honeypot_options 94 | { autocomplete: 'off', tabindex: -1 } 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/view_helpers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe InvisibleCaptcha::ViewHelpers, type: :helper do 4 | before(:each) do 5 | allow(InvisibleCaptcha).to receive(:css_strategy).and_return("display:none;") 6 | allow_any_instance_of(ActionDispatch::ContentSecurityPolicy::Request).to receive(:content_security_policy_nonce).and_return('123') 7 | 8 | # to test content_for and provide 9 | @view_flow = ActionView::OutputFlow.new 10 | 11 | # mock request object for Rails < 7.0 12 | if Rails.version < '7.0' 13 | allow(request).to receive(:remote_ip).and_return('0.0.0.0') 14 | end 15 | 16 | InvisibleCaptcha.init! 17 | end 18 | 19 | it 'with no arguments' do 20 | InvisibleCaptcha.honeypots = [:foo_id] 21 | expect(invisible_captcha).to match(/name="foo_id"/) 22 | end 23 | 24 | it 'with specific honeypot' do 25 | expect(invisible_captcha(:subtitle)).to match(/name="subtitle"/) 26 | end 27 | 28 | it 'with specific honeypot and scope' do 29 | expect(invisible_captcha(:subtitle, :topic)).to match(/name="topic\[subtitle\]"/) 30 | end 31 | 32 | it 'with custom html options' do 33 | expect(invisible_captcha(:subtitle, :topic, { class: 'foo_class' })).to match(/class="foo_class"/) 34 | end 35 | 36 | it 'with CSP nonce' do 37 | expect(invisible_captcha(:subtitle, :topic, { nonce: true })).to match(/nonce="123"/) 38 | end 39 | 40 | it 'generated html + styles' do 41 | InvisibleCaptcha.honeypots = [:foo_id] 42 | output = invisible_captcha.gsub("\"", "'") 43 | regexp = %r{
.foo_id_\w* {display:none;}#{InvisibleCaptcha.sentence_for_humans}
} 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 | [![Gem](https://img.shields.io/gem/v/invisible_captcha.svg?style=flat-square)](https://rubygems.org/gems/invisible_captcha) 4 | [![Build Status](https://github.com/markets/invisible_captcha/workflows/CI/badge.svg)](https://github.com/markets/invisible_captcha/actions) 5 | [![codecov](https://codecov.io/gh/markets/invisible_captcha/branch/master/graph/badge.svg?token=nADSa6rbhM)](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 | --------------------------------------------------------------------------------