bot-away 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ === 0.0.1 2010-03-24
2
+
3
+ * 1 major enhancement:
4
+ * Initial release
@@ -0,0 +1,23 @@
1
+ History.txt
2
+ Manifest.txt
3
+ README.rdoc
4
+ Rakefile
5
+ lib/bot-away.rb
6
+ lib/bot-away/action_controller/request.rb
7
+ lib/bot-away/action_view/helpers/instance_tag.rb
8
+ lib/bot-away/param_parser.rb
9
+ lib/bot-away/spinner.rb
10
+ script/console
11
+ script/destroy
12
+ script/generate
13
+ spec/controllers/test_controller_spec.rb
14
+ spec/lib/action_view/helpers/instance_tag_spec.rb
15
+ spec/lib/builder_spec.rb
16
+ spec/lib/param_parser_spec.rb
17
+ spec/spec_helper.rb
18
+ spec/support/controllers/test_controller.rb
19
+ spec/support/honeypot_matcher.rb
20
+ spec/support/obfuscation_helper.rb
21
+ spec/support/obfuscation_matcher.rb
22
+ spec/support/views/test/index.html.erb
23
+ spec/support/views/test/model_form.html.erb
@@ -0,0 +1,88 @@
1
+ = bot-away
2
+
3
+ * http://github.com/sinisterchipmunk/bot-away
4
+
5
+ == DESCRIPTION:
6
+
7
+ Unobtrusively detects form submissions made by spambots, and silently drops those submissions. The key word here is
8
+ "unobtrusive" -- this is NOT a CAPTCHA. This is transparent, modular implementation of the bot-catching techniques
9
+ discussed by Ned Batchelder at http://nedbatchelder.com/text/stopbots.html
10
+
11
+ If a submission is detected, the params hash is cleared, so the data can't be used. Since this includes the authenticity
12
+ token, Rails should barf due to an invalid or missing authenticity token. Congrats, spam blocked.
13
+
14
+ The specifics of the techniques employed for filtering spambots are discussed Ned's site at the above location; however,
15
+ here's a brief run-down of what's going on:
16
+
17
+ * Your code stays the same. After the bot-away gem has been activated, all Rails-generated forms on your site
18
+ will automatically be transformed into bot-resistent forms.
19
+ * All of the form elements that you create (for instance, a "comment" model with a "body" field) are turned into
20
+ dummy elements, or honeypots, and are made invisible to the end user. This is done using div elements and inline CSS
21
+ stylesheets. There are several ways an element can be hidden, and these approaches are chosen at random to help
22
+ minimize predictability. In the rare event that a real user actually can see the element, it has a label next to it
23
+ along the lines of "Leave this blank" -- though the exact message is randomized to help prevent detection.
24
+ * All of the form elements are mirrored by hashes. The hashes are generated using the session's authenticity token,
25
+ so they can't be predicted.
26
+ * When data is submitted, bot-away steps in and 1.) validates that no honeypots have been filled in; and 2)
27
+ converts the hashed elements back into the field names that you are expecting (replacing the honeypot fields).
28
+ * If a honeypot has been filled in, or a hashed element is missing where it was expected, then the request is
29
+ considered to be either spam, or tampered with; and the entire params hash is emptied. Since this happens at the
30
+ lowest level, the most likely result is that Rails will complain that the user's authenticity token is invalid. If
31
+ that does not happen, then your code will be passed a params hash containing only a "suspected_bot" key, and an error
32
+ will result. Either way, the spambot has been foiled!
33
+
34
+ == FEATURES/PROBLEMS:
35
+
36
+ * Wherever protection from forgery is not enabled in your Rails app, the Rails forms will be generated as if this gem
37
+ did not exist. That means hashed elements won't be generated, honeypots won't be generated, and posted forms will not
38
+ be intercepted.
39
+
40
+ * By default, protection from forgery is enabled for all Rails controllers, so by default the above-mentioned checks
41
+ will also be triggered. For more details on forgery protection, see:
42
+ http://api.rubyonrails.org/classes/ActionController/RequestForgeryProtection/ClassMethods.html
43
+
44
+ * The techniques implemented by this library will be very difficult for a spambot to circumvent. However, keep in mind
45
+ that since the pages have to be machine-readable by definition, and since this gem has to follow certain protocols
46
+ in order to avoid confusing lots of humans (such as hiding the honeypots), it is always theoretically possible for
47
+ a spambot to get around it. It's just very, very difficult.
48
+
49
+ == REQUIREMENTS:
50
+
51
+ * Rails 2.3.5 or better.
52
+
53
+ == INSTALL:
54
+
55
+ * sudo gem install bot-away
56
+
57
+ == USAGE:
58
+
59
+ In your Rails config/environment.rb:
60
+
61
+ * config.gem 'bot-away'
62
+
63
+ That's it.
64
+
65
+ == LICENSE:
66
+
67
+ (The MIT License)
68
+
69
+ Copyright (c) 2010 Colin MacKenzie IV
70
+
71
+ Permission is hereby granted, free of charge, to any person obtaining
72
+ a copy of this software and associated documentation files (the
73
+ 'Software'), to deal in the Software without restriction, including
74
+ without limitation the rights to use, copy, modify, merge, publish,
75
+ distribute, sublicense, and/or sell copies of the Software, and to
76
+ permit persons to whom the Software is furnished to do so, subject to
77
+ the following conditions:
78
+
79
+ The above copyright notice and this permission notice shall be
80
+ included in all copies or substantial portions of the Software.
81
+
82
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
83
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
84
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
85
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
86
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
87
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
88
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,45 @@
1
+ require 'rubygems'
2
+ gem 'hoe', '>= 2.1.0'
3
+ require 'hoe'
4
+ require 'fileutils'
5
+ require './lib/bot-away'
6
+
7
+ Hoe.plugin :newgem
8
+ # Hoe.plugin :website
9
+ # Hoe.plugin :cucumberfeatures
10
+
11
+ # Generate all the Rake tasks
12
+ # Run 'rake -T' to see list of generated tasks (from gem root directory)
13
+ $hoe = Hoe.spec 'bot-away' do
14
+ self.developer 'Colin MacKenzie IV', 'sinisterchipmunk@gmail.com'
15
+ self.extra_deps = [['actionpack','>= 2.3.5'],['sc-core-ext','>= 1.1.1']]
16
+ self.readme_file = "README.rdoc"
17
+ end
18
+
19
+ Rake::RDocTask.new(:docs) do |rdoc|
20
+ files = ['README.rdoc', # 'LICENSE', 'CHANGELOG',
21
+ 'lib/**/*.rb', 'doc/**/*.rdoc']#, 'spec/*.rb']
22
+ rdoc.rdoc_files.add(files)
23
+ rdoc.main = 'README.rdoc'
24
+ rdoc.title = 'EVE Documentation'
25
+ #rdoc.template = '/path/to/gems/allison-2.0/lib/allison'
26
+ rdoc.rdoc_dir = 'doc'
27
+ rdoc.options << '--line-numbers' << '--inline-source'
28
+ end
29
+
30
+
31
+ require 'newgem/tasks'
32
+ Dir['tasks/**/*.rake'].each { |t| load t }
33
+
34
+ require 'spec/rake/spectask'
35
+
36
+ desc "Run all examples with RCov"
37
+ Spec::Rake::SpecTask.new('rcov') do |t|
38
+ t.spec_files = FileList['spec/**/*_spec.rb']
39
+ t.rcov = true
40
+ t.rcov_opts = ['--exclude', 'spec,/home/*']
41
+ end
42
+
43
+ # TODO - want other tests/tasks run by default? Add them to the list
44
+ # remove_task :default
45
+ # task :default => [:spec, :features]
@@ -0,0 +1,19 @@
1
+ $:.unshift(File.dirname(__FILE__)) unless
2
+ $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
3
+
4
+ require 'rubygems' unless defined?(Gem)
5
+ require 'action_controller'
6
+ require 'action_view'
7
+ require 'sc-core-ext'
8
+
9
+ require 'bot-away/param_parser'
10
+ require 'bot-away/action_controller/request'
11
+ require 'bot-away/action_view/helpers/instance_tag'
12
+ require 'bot-away/spinner'
13
+
14
+ module BotAway
15
+ VERSION = '1.0.0'
16
+ end
17
+
18
+ # WHY do I have to do this???
19
+ ActionView::Base.send :include, ActionView::Helpers
@@ -0,0 +1,8 @@
1
+ class ActionController::Request < Rack::Request
2
+ def parameters_with_deobfuscation
3
+ @parameters ||= BotAway::ParamParser.new(ip, parameters_without_deobfuscation).params
4
+ end
5
+
6
+ alias_method_chain :parameters, :deobfuscation
7
+ alias_method :params, :parameters
8
+ end
@@ -0,0 +1,92 @@
1
+ class ActionView::Helpers::InstanceTag
2
+ attr_reader :spinner
3
+
4
+ def initialize_with_spinner(object_name, method_name, template_object, object = nil)
5
+ initialize_without_spinner(object_name, method_name, template_object, object)
6
+ if template_object.controller.send(:protect_against_forgery?)
7
+ @spinner = BotAway::Spinner.new(template_object.request.ip, object_name, template_object.form_authenticity_token)
8
+ end
9
+ end
10
+
11
+ def obfuscate_options(options)
12
+ add_default_name_and_id(options)
13
+ assuming(spinner && options) do
14
+ options['name'] &&= spinner.encode(options['name'])
15
+ options['id'] &&= spinner.encode(options['id'])
16
+ end
17
+ end
18
+
19
+ def honeypot_options(options)
20
+ add_default_name_and_id(options)
21
+ assuming(spinner && options) do
22
+ options['value'] &&= ''
23
+ options['autocomplete'] = 'off'
24
+ end
25
+ end
26
+
27
+ def assuming(object)
28
+ yield if object
29
+ object
30
+ end
31
+
32
+ def honeypot_tag(name, options = nil, *args)
33
+ tag_without_honeypot(name, honeypot_options(options.dup? || {}), *args)
34
+ end
35
+
36
+ def obfuscated_tag(name, options = nil, *args)
37
+ tag_without_honeypot(name, obfuscate_options(options.dup? || {}), *args)
38
+ end
39
+
40
+ def tag_with_honeypot(name, options = nil, *args)
41
+ if spinner
42
+ obfuscated_tag(name, options, *args) + disguise(honeypot_tag(name, options, *args))
43
+ else
44
+ tag_without_honeypot(name, options, *args)
45
+ end
46
+ end
47
+
48
+ # Special case
49
+ def to_label_tag_with_obfuscation(text = nil, options = {})
50
+ # TODO: Can this be simplified? It's pretty similar to to_label_tag_without_obfuscation...
51
+ options = options.stringify_keys
52
+ tag_value = options.delete("value")
53
+ name_and_id = options.dup
54
+ name_and_id["id"] = name_and_id["for"]
55
+ add_default_name_and_id_for_value(tag_value, name_and_id)
56
+ options["for"] ||= name_and_id["id"]
57
+ options["for"] = spinner.encode(options["for"]) if spinner && options["for"]
58
+ to_label_tag_without_obfuscation(text, options)
59
+ end
60
+
61
+ def content_tag_with_obfuscation(name, content_or_options_with_block = nil, options = nil, *args, &block)
62
+ if block_given?
63
+ content_tag_without_obfuscation(name, content_or_options_with_block, options, *args, &block)
64
+ else
65
+ # this should cover all Rails selects.
66
+ if spinner && options && (options.keys.include?('id') || options.keys.include?('name'))
67
+ disguise(content_tag_without_obfuscation(name, '', honeypot_options(options), *args)) +
68
+ content_tag_without_obfuscation(name, content_or_options_with_block, obfuscate_options(options), *args)
69
+ else
70
+ content_tag_without_obfuscation(name, content_or_options_with_block, options, *args)
71
+ end
72
+ end
73
+ end
74
+
75
+ alias_method_chain :initialize, :spinner
76
+ alias_method_chain :tag, :honeypot
77
+ alias_method_chain :to_label_tag, :obfuscation
78
+ alias_method_chain :content_tag, :obfuscation
79
+
80
+ def disguise(element)
81
+ case rand(3)
82
+ when 0 # Hidden
83
+ "<div style='display:none;'>Leave this empty: #{element}</div>"
84
+ when 1 # Off-screen
85
+ "<div style='position:absolute;left:-1000px;top:-1000px;'>Don't fill this in: #{element}</div>"
86
+ when 2 # Negligible size
87
+ "<div style='position:absolute;width:0px;height:1px;z-index:-1;color:transparent;overflow:hidden;'>Keep this blank: #{element}</div>"
88
+ else # this should never happen?
89
+ disguise(element)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,36 @@
1
+ module BotAway
2
+ class ParamParser
3
+ class ObfuscationMissing < StandardError; end #:nodoc:
4
+
5
+ attr_reader :params, :ip, :authenticity_token
6
+
7
+ def initialize(ip, params, authenticity_token = params[:authenticity_token])
8
+ @ip, @params, @authenticity_token = ip, params, authenticity_token
9
+ if authenticity_token
10
+ if catch(:bastard) { deobfuscate! } == :took_the_bait
11
+ params.clear
12
+ params[:suspected_bot] = true
13
+ end
14
+ end
15
+ end
16
+
17
+ def deobfuscate!(current = params, object_name = nil)
18
+ if object_name
19
+ spinner = BotAway::Spinner.new(ip, object_name, authenticity_token)
20
+ end
21
+
22
+ current.each do |key, value|
23
+ if object_name
24
+ if value.blank? && params.keys.include?(spun_key = spinner.encode("#{object_name}[#{key}]"))
25
+ current[key] = params.delete(spun_key)
26
+ else
27
+ throw :bastard, :took_the_bait
28
+ end
29
+ end
30
+ if value.kind_of?(Hash)
31
+ deobfuscate!(value, object_name ? "#{object_name}[#{key}]" : key)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,22 @@
1
+ class BotAway::Spinner
2
+ def initialize(ip, key, secret)
3
+ raise "Shouldn't have a nil ip" unless ip
4
+ raise "Shouldn't have a nil secret" unless secret
5
+ secret = File.join(#Time.now.to_i.to_s,
6
+ ip,
7
+ key.to_s,
8
+ secret)
9
+
10
+ #puts secret
11
+ @spinner = Digest::MD5.hexdigest(secret)
12
+ #puts @spinner
13
+ end
14
+
15
+ def spinner
16
+ @spinner
17
+ end
18
+
19
+ def encode(real_field_name)
20
+ Digest::MD5.hexdigest(File.join(real_field_name.to_s, spinner))
21
+ end
22
+ end
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # File: script/console
3
+ irb = RUBY_PLATFORM =~ /(:?mswin|mingw)/ ? 'irb.bat' : 'irb'
4
+
5
+ libs = " -r irb/completion"
6
+ # Perhaps use a console_lib to store any extra methods I may want available in the cosole
7
+ # libs << " -r #{File.dirname(__FILE__) + '/../lib/console_lib/console_logger.rb'}"
8
+ libs << " -r #{File.dirname(__FILE__) + '/../lib/bot-away.rb'}"
9
+ puts "Loading bot-away gem"
10
+ exec "#{irb} #{libs} --simple-prompt"
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..'))
3
+
4
+ begin
5
+ require 'rubigen'
6
+ rescue LoadError
7
+ require 'rubygems'
8
+ require 'rubigen'
9
+ end
10
+ require 'rubigen/scripts/destroy'
11
+
12
+ ARGV.shift if ['--help', '-h'].include?(ARGV[0])
13
+ RubiGen::Base.use_component_sources! [:rubygems, :newgem, :newgem_theme, :test_unit]
14
+ RubiGen::Scripts::Destroy.new.run(ARGV)
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..'))
3
+
4
+ begin
5
+ require 'rubigen'
6
+ rescue LoadError
7
+ require 'rubygems'
8
+ require 'rubigen'
9
+ end
10
+ require 'rubigen/scripts/generate'
11
+
12
+ ARGV.shift if ['--help', '-h'].include?(ARGV[0])
13
+ RubiGen::Base.use_component_sources! [:rubygems, :newgem, :newgem_theme, :test_unit]
14
+ RubiGen::Scripts::Generate.new.run(ARGV)
@@ -0,0 +1,128 @@
1
+ require 'spec_helper'
2
+
3
+ describe TestController do
4
+ include ActionController::TestProcess
5
+
6
+ def prepare!(action = 'index', method = 'get')
7
+ @controller.request = @request
8
+ @controller.params = {}
9
+ @controller.send(:initialize_current_url)
10
+ send(method, action)
11
+ if @response.template.instance_variable_get("@exception")
12
+ raise @response.template.instance_variable_get("@exception")
13
+ end
14
+ end
15
+
16
+ before :each do
17
+ @request = ActionController::TestRequest.new
18
+ # can the authenticity token so that we can predict the generated element names
19
+ @request.session[:_csrf_token] = 'aVjGViz+pIphXt2pxrWfXgRXShOI0KXOILR23yw0WBo='
20
+ @request.remote_addr = '208.77.188.166' # example.com
21
+ @response = ActionController::TestResponse.new
22
+ @controller = TestController.new
23
+ end
24
+
25
+ after :each do
26
+ # effectively disables forgery protection.
27
+ TestController.request_forgery_protection_token = nil
28
+ end
29
+
30
+ context "with a model" do
31
+ context "with forgery protection" do
32
+ before :each do
33
+ (class << @controller; self; end).send(:protect_from_forgery)
34
+ prepare!('model_form')
35
+ end
36
+
37
+ it "should work?" do
38
+ puts @response.body
39
+ end
40
+ end
41
+
42
+ context "without forgery protection" do
43
+ before :each do
44
+ prepare!('model_form')
45
+ end
46
+
47
+ it "should work?" do
48
+ puts @response.body
49
+ end
50
+ end
51
+ end
52
+
53
+ context "with forgery protection" do
54
+ before :each do
55
+ (class << @controller; self; end).send(:protect_from_forgery)
56
+ prepare!
57
+ end
58
+ #"object_name_method_name" name="object_name[method_name]" size="30" type="text" value="" /></div>
59
+ #<input id="e21372563297c728093bf74c3cb6b96c" name="a0844d45bf150668ff1d86a6eb491969" size="30" type="text" value="method_value" />
60
+
61
+ it "processes valid obfuscated form post" do
62
+ form = { 'authenticity_token' => '1234',
63
+ 'object_name' => { 'method_name' => '' },
64
+ '842d8d1c80014ce9f3d974614338605c' => 'some_value'
65
+ }
66
+ post 'proc_form', form
67
+ puts @response.body
68
+ @response.template.controller.params[:object_name].should == { 'method_name' => 'some_value' }
69
+ end
70
+
71
+ it "drops invalid obfuscated form post" do
72
+ form = { 'authenticity_token' => '1234',
73
+ 'object_name' => { 'method_name' => 'test' },
74
+ '842d8d1c80014ce9f3d974614338605c' => 'some_value'
75
+ }
76
+ post 'proc_form', form
77
+ puts @response.body
78
+ @response.template.controller.params.should == { 'suspected_bot' => true }
79
+ end
80
+
81
+ it "does not drop valid authentication request" do
82
+ #@request.session[:_csrf_token] = 'yPgTAsngzpBO8k1v83RGH26sTrQYD50Ou2oiMT4r/iw='
83
+ form = { 'authenticity_token' => 'yPgTAsngzpBO8k1v83RGH26sTrQYD50Ou2oiMT4r/iw=',
84
+ 'user_session' => {
85
+ 'login' => '',
86
+ 'password' => '',
87
+ 'remember_me' => ''
88
+ },
89
+ 'commit' => 'Log In',
90
+ '89dce8f562b119a2f88da6d29f535a0d' => 'admin',
91
+ '4b9bab79bc1b1cd5229041c357750e0c' => 'pwpwpw',
92
+ '256307a36284445cc84014dae651f2ed' => '1'
93
+ }
94
+ @request.remote_addr = '127.0.0.1'
95
+ post 'proc_form', form
96
+ puts @response.template.controller.params.inspect
97
+ @response.template.controller.params.should == { 'action' => 'proc_form', 'controller' => 'test',
98
+ 'authenticity_token' => 'yPgTAsngzpBO8k1v83RGH26sTrQYD50Ou2oiMT4r/iw=',
99
+ 'user_session' => {
100
+ 'login' => 'admin',
101
+ 'password' => 'pwpwpw',
102
+ 'remember_me' => '1'
103
+ },
104
+ 'commit' => 'Log In'
105
+ }
106
+ end
107
+ end
108
+
109
+ context "without forgery protection" do
110
+ before :each do
111
+ prepare!
112
+ end
113
+
114
+ it "processes non-obfuscated form post" do
115
+ form = { #'authenticity_token' => '1234',
116
+ 'object_name' => { 'method_name' => 'test' }
117
+ }
118
+ post 'proc_form', form
119
+ puts @response.body
120
+ @response.template.controller.params.should_not == { 'suspected_bot' => true }
121
+ @response.template.controller.params[:object_name].should == { 'method_name' => 'test' }
122
+ end
123
+
124
+ it "produces non-obfuscated form elements" do
125
+ @response.body.should_not match(/<\/div><input/)
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,69 @@
1
+ require 'spec_helper'
2
+
3
+ def template
4
+ return @response if @response
5
+ @response = TestController.call(Rack::MockRequest.env_for('/').merge({'REQUEST_URI' => '/',
6
+ 'REMOTE_ADDR' => '127.0.0.1'}))
7
+ @response.template.controller.request_forgery_protection_token = :authenticity_token
8
+ @response.template.controller.session[:_csrf_token] = '1234'
9
+ @response.template
10
+ end
11
+
12
+ def mock_object
13
+ @mock_object ||= MockObject.new
14
+ end
15
+
16
+ def default_instance_tag
17
+ ActionView::Helpers::InstanceTag.new("object_name", "method_name", template, mock_object)
18
+ end
19
+
20
+ describe ActionView::Helpers::InstanceTag do
21
+ subject { default_instance_tag }
22
+
23
+ context "with a valid text area tag" do
24
+ subject do
25
+ dump { default_instance_tag.to_text_area_tag }
26
+ end
27
+
28
+ it "should produce blank honeypot value" do
29
+ subject.should_not =~ /name="object_name\[method_name\]"[^>]+>method_value<\/textarea>/
30
+ end
31
+ end
32
+
33
+ context "with a valid input type=text tag" do
34
+ before(:each) { @tag_options = ["input", {:type => 'text', 'name' => 'object_name[method_name]', 'id' => 'object_name_method_name', 'value' => 'method_value'}] }
35
+ #subject { dump { default_instance_tag.tag(*@tag_options) } }
36
+
37
+ it "should turn off autocomplete for honeypots" do
38
+ subject.honeypot_tag(*@tag_options).should =~ /autocomplete="off"/
39
+ end
40
+
41
+ it "should obfuscate tag name" do
42
+ subject.obfuscated_tag(*@tag_options).should =~ /name="a0844d45bf150668ff1d86a6eb491969"/
43
+ end
44
+
45
+ it "should obfuscate tag id" do
46
+ subject.obfuscated_tag(*@tag_options).should =~ /id="e21372563297c728093bf74c3cb6b96c"/
47
+ end
48
+
49
+ it "should not obfuscate tag value" do
50
+ subject.obfuscated_tag(*@tag_options).should_not =~ /value="5a6a50d5fd0b5c8b1190d87eb0057e47"/
51
+ end
52
+
53
+ it "should include unobfuscated tag value" do
54
+ subject.obfuscated_tag(*@tag_options).should =~ /value="method_value"/
55
+ end
56
+
57
+ it "should create honeypot name" do
58
+ subject.honeypot_tag(*@tag_options).should =~ /name="object_name\[method_name\]"/
59
+ end
60
+
61
+ it "should create honeypot id" do
62
+ subject.honeypot_tag(*@tag_options).should =~ /id="object_name_method_name"/
63
+ end
64
+
65
+ it "should create empty honeypot tag value" do
66
+ subject.honeypot_tag(*@tag_options).should =~ /value=""/
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,48 @@
1
+ ###
2
+ # The original implementation of BotAway extended ActionView::Helpers::FormBuilder, and these tests were written
3
+ # for it. This approach has since been abandoned in favor of a direct override of ActionView::Helpers::InstanceTag for
4
+ # reasons of efficiency. The FormBuilder tests have been kept around simply for an extra layer of functional testing.
5
+ ###
6
+
7
+ require 'spec_helper'
8
+
9
+ class MockObject; attr_accessor :method_name; def initialize; @method_name = 'method_value'; end; end
10
+
11
+ describe ActionView::Helpers::FormBuilder do
12
+ subject { builder }
13
+
14
+ it "should not create honeypots with default values" do
15
+ builder.text_field(:method_name).should match(/name="object_name\[method_name\]"[^>]*?value=""/)
16
+ end
17
+
18
+ # select(method, choices, options = {}, html_options = {})
19
+ obfuscates(:select) { builder.select(:method_name, {1 => :a, 2 => :b }) }
20
+
21
+ #collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
22
+ obfuscates(:collection_select) { builder.collection_select method_name, [MockObject.new], :method_name, :method_name }
23
+
24
+ #grouped_collection_select(method, collection, group_method, group_label_method, option_key_method,
25
+ # option_value_method, options = {}, html_options = {})
26
+ obfuscates(:grouped_collection_select) do
27
+ builder.grouped_collection_select method_name, [MockObject.new], :method_name, :method_name, :to_s, :to_s
28
+ end
29
+
30
+ #time_zone_select(method, priority_zones = nil, options = {}, html_options = {})
31
+ obfuscates(:time_zone_select) do
32
+ builder.time_zone_select method_name
33
+ end
34
+
35
+ %w(hidden_field text_field text_area file_field password_field check_box).each do |field|
36
+ obfuscates(field) { builder.send(field, method_name) }
37
+ end
38
+
39
+ obfuscates(:radio_button, '53640013be550817d040597218884288') { builder.radio_button method_name, :value }
40
+
41
+ context "#label" do
42
+ subject { dump { builder.label(method_name) } }
43
+
44
+ it "links labels to their obfuscated elements" do
45
+ subject.should match(/for=\"e21372563297c728093bf74c3cb6b96c\"/)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,55 @@
1
+ describe BotAway::ParamParser do
2
+ def params(honeypots)
3
+ @params = { 'authenticity_token' => '1234',
4
+ 'bdf3e1964ac3a82c5f1c7385ed0100e4' => 'colin',
5
+ 'ed8fc4fe67d66bc7aeaf0b4817bd1311' => [1, 2]
6
+ }.merge(honeypots).with_indifferent_access
7
+ end
8
+
9
+ before(:each) do
10
+ # Root level is encoded with 208.77.188.166/test/1234
11
+ # which resolves to a spinner digest of 86ba3fd99e851587a849ad9ed9817f9b
12
+ @ip = '208.77.188.166'
13
+ @params = params('test' => { 'name' => '', 'posts' => [] })
14
+ end
15
+
16
+ subject { r = BotAway::ParamParser.new(@ip, @params); puts r.params.to_yaml; r }
17
+
18
+ context "with blank honeypots" do
19
+ it "drops obfuscated params" do
20
+ subject.params.keys.should_not include('bdf3e1964ac3a82c5f1c7385ed0100e4')
21
+ end
22
+
23
+ it "drops obfuscated subparams" do
24
+ subject.params.keys.should_not include('ed8fc4fe67d66bc7aeaf0b4817bd1311')
25
+ end
26
+
27
+ it "replaces honeypots" do
28
+ subject.params[:test].should_not be_blank
29
+ end
30
+
31
+ it "replaces subhoneypots" do
32
+ subject.params.keys.should include('test')
33
+ subject.params[:test][:name].should == 'colin'
34
+ subject.params[:test][:posts].should == [1,2]
35
+ end
36
+ end
37
+
38
+ context "with a filled honeypot" do
39
+ before(:each) { @params = params({'test' => {'name' => 'colin', 'posts' => []}}) }
40
+ subject { r = BotAway::ParamParser.new(@ip, @params); puts r.params.to_yaml; r }
41
+
42
+ it "drops all parameters" do
43
+ subject.params.should == { "suspected_bot" => true }
44
+ end
45
+ end
46
+
47
+ context "with a filled sub-honeypot" do
48
+ before(:each) { @params = params({'test' => {'name' => '', 'posts' => [1, 2]}}) }
49
+ subject { r = BotAway::ParamParser.new(@ip, @params); puts r.params.to_yaml; r }
50
+
51
+ it "drops all parameters" do
52
+ subject.params.should == { "suspected_bot" => true }
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,12 @@
1
+ require 'rubygems'
2
+ require 'action_controller'
3
+ require 'action_view'
4
+
5
+ ActionController::Routing::Routes.load!
6
+ ActionController::Base.session = { :key => "_myapp_session", :secret => "12345"*6 }
7
+
8
+ require File.join(File.dirname(__FILE__), '../lib/bot-away')
9
+
10
+ Dir[File.join(File.dirname(__FILE__), 'support/**/*.rb')].each do |fi|
11
+ require fi
12
+ end
@@ -0,0 +1,18 @@
1
+ class Post
2
+ attr_reader :subject, :body, :subscribers
3
+ end
4
+
5
+ class TestController < ActionController::Base
6
+ view_paths << File.expand_path(File.join(File.dirname(__FILE__), "../views"))
7
+
8
+ def index
9
+ end
10
+
11
+ def model_form
12
+ @post = Post.new
13
+ end
14
+
15
+ def proc_form
16
+ render :text => params.to_yaml
17
+ end
18
+ end
@@ -0,0 +1,26 @@
1
+ class HoneypotMatcher
2
+ def initialize(object_name, method_name)
3
+ @object_name, @method_name = object_name, method_name
4
+ end
5
+
6
+ def matches?(target)
7
+ target = target.call if target.kind_of?(Proc)
8
+ @target = target
9
+ @rx = /name="#{Regexp::escape @object_name}\[#{Regexp::escape @method_name}/m
10
+ @target[@rx]
11
+ end
12
+
13
+ def failure_message
14
+ "expected #{@target.inspect}\n to match #{@rx.to_s}"
15
+ end
16
+
17
+ def negative_failure_message
18
+ "expected #{@target.inspect}\n to not match #{@rx.to_s}"
19
+ end
20
+ end
21
+
22
+ def include_honeypot(object_name, method_name)
23
+ HoneypotMatcher.new(object_name, method_name)
24
+ end
25
+
26
+ alias contain_honeypot include_honeypot
@@ -0,0 +1,57 @@
1
+ module ObfuscationHelper
2
+ def includes_honeypot(object_name, method_name)
3
+ it "includes a honeypot called #{object_name}[#{method_name}]" do
4
+ subject.should include_honeypot(object_name, method_name)
5
+ end
6
+ end
7
+
8
+ def is_obfuscated_as(id, name)
9
+ it "is obfuscated as #{id}, #{name}" do
10
+ subject.should be_obfuscated_as(id, name)
11
+ end
12
+ end
13
+
14
+ def dump
15
+ returning(yield) { |x| puts x }
16
+ end
17
+
18
+ def builder
19
+ return @builder if @builder
20
+ response = TestController.call(Rack::MockRequest.env_for('/').merge({'REQUEST_URI' => '/',
21
+ 'REMOTE_ADDR' => '127.0.0.1'}))
22
+ response.template.controller.request_forgery_protection_token = :authenticity_token
23
+ response.template.controller.session[:_csrf_token] = '1234'
24
+ @builder = ActionView::Helpers::FormBuilder.new(:object_name, MockObject.new, response.template, {}, proc {})
25
+ end
26
+
27
+ def obfuscates(method, obfuscated_id = self.obfuscated_id, obfuscated_name = self.obfuscated_name)
28
+ value = yield
29
+ context "##{method}" do
30
+ subject { proc { dump { value } } }
31
+
32
+ includes_honeypot(object_name, method_name)
33
+ is_obfuscated_as(obfuscated_id, obfuscated_name)
34
+ end
35
+ end
36
+
37
+ def obfuscated_id
38
+ "e21372563297c728093bf74c3cb6b96c"
39
+ end
40
+
41
+ def obfuscated_name
42
+ "a0844d45bf150668ff1d86a6eb491969"
43
+ end
44
+
45
+ def object_name
46
+ "object_name"
47
+ end
48
+
49
+ def method_name
50
+ "method_name"
51
+ end
52
+ end
53
+
54
+ Spec::Runner.configure do |config|
55
+ config.extend ObfuscationHelper
56
+ config.include ObfuscationHelper
57
+ end
@@ -0,0 +1,28 @@
1
+ class ObfuscationMatcher
2
+ def initialize(id, name)
3
+ @id, @name = id, name
4
+ end
5
+
6
+ def matches?(target)
7
+ target = target.call if target.kind_of?(Proc)
8
+ @target = target
9
+ match(:id) && match(:name)
10
+ end
11
+
12
+ def match(which)
13
+ @rx = /#{which}=['"]#{Regexp::escape instance_variable_get("@#{which}")}/
14
+ @target[@rx]
15
+ end
16
+
17
+ def failure_message
18
+ "expected #{@target.inspect}\n to match #{@rx.inspect}"
19
+ end
20
+
21
+ def negative_failure_message
22
+ "expected #{@target.inspect}\n to not match #{@rx.inspect}"
23
+ end
24
+ end
25
+
26
+ def be_obfuscated_as(id, name)
27
+ ObfuscationMatcher.new(id, name)
28
+ end
@@ -0,0 +1,4 @@
1
+ <%form_for :test, :url => {:action => 'post_to'} do |f|%>
2
+ <%=f.text_field :name%>
3
+ <%end%>
4
+
@@ -0,0 +1,6 @@
1
+ <%form_for @post, :url => url_for('proc_form') do |f|%>
2
+ <p>
3
+ <%=f.label :subject%><br/>
4
+ <%=f.text_field :subject%>
5
+ </p>
6
+ <%end%>
metadata ADDED
@@ -0,0 +1,182 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bot-away
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 1
7
+ - 0
8
+ - 0
9
+ version: 1.0.0
10
+ platform: ruby
11
+ authors:
12
+ - Colin MacKenzie IV
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2010-04-01 00:00:00 -04:00
18
+ default_executable:
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ name: actionpack
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ segments:
28
+ - 2
29
+ - 3
30
+ - 5
31
+ version: 2.3.5
32
+ type: :runtime
33
+ version_requirements: *id001
34
+ - !ruby/object:Gem::Dependency
35
+ name: sc-core-ext
36
+ prerelease: false
37
+ requirement: &id002 !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ segments:
42
+ - 1
43
+ - 1
44
+ - 1
45
+ version: 1.1.1
46
+ type: :runtime
47
+ version_requirements: *id002
48
+ - !ruby/object:Gem::Dependency
49
+ name: rubyforge
50
+ prerelease: false
51
+ requirement: &id003 !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ segments:
56
+ - 2
57
+ - 0
58
+ - 3
59
+ version: 2.0.3
60
+ type: :development
61
+ version_requirements: *id003
62
+ - !ruby/object:Gem::Dependency
63
+ name: gemcutter
64
+ prerelease: false
65
+ requirement: &id004 !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ segments:
70
+ - 0
71
+ - 5
72
+ - 0
73
+ version: 0.5.0
74
+ type: :development
75
+ version_requirements: *id004
76
+ - !ruby/object:Gem::Dependency
77
+ name: hoe
78
+ prerelease: false
79
+ requirement: &id005 !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ segments:
84
+ - 2
85
+ - 5
86
+ - 0
87
+ version: 2.5.0
88
+ type: :development
89
+ version_requirements: *id005
90
+ description: |-
91
+ Unobtrusively detects form submissions made by spambots, and silently drops those submissions. The key word here is
92
+ "unobtrusive" -- this is NOT a CAPTCHA. This is transparent, modular implementation of the bot-catching techniques
93
+ discussed by Ned Batchelder at http://nedbatchelder.com/text/stopbots.html
94
+
95
+ If a submission is detected, the params hash is cleared, so the data can't be used. Since this includes the authenticity
96
+ token, Rails should barf due to an invalid or missing authenticity token. Congrats, spam blocked.
97
+
98
+ The specifics of the techniques employed for filtering spambots are discussed Ned's site at the above location; however,
99
+ here's a brief run-down of what's going on:
100
+
101
+ * Your code stays the same. After the bot-away gem has been activated, all Rails-generated forms on your site
102
+ will automatically be transformed into bot-resistent forms.
103
+ * All of the form elements that you create (for instance, a "comment" model with a "body" field) are turned into
104
+ dummy elements, or honeypots, and are made invisible to the end user. This is done using div elements and inline CSS
105
+ stylesheets. There are several ways an element can be hidden, and these approaches are chosen at random to help
106
+ minimize predictability. In the rare event that a real user actually can see the element, it has a label next to it
107
+ along the lines of "Leave this blank" -- though the exact message is randomized to help prevent detection.
108
+ * All of the form elements are mirrored by hashes. The hashes are generated using the session's authenticity token,
109
+ so they can't be predicted.
110
+ * When data is submitted, bot-away steps in and 1.) validates that no honeypots have been filled in; and 2)
111
+ converts the hashed elements back into the field names that you are expecting (replacing the honeypot fields).
112
+ * If a honeypot has been filled in, or a hashed element is missing where it was expected, then the request is
113
+ considered to be either spam, or tampered with; and the entire params hash is emptied. Since this happens at the
114
+ lowest level, the most likely result is that Rails will complain that the user's authenticity token is invalid. If
115
+ that does not happen, then your code will be passed a params hash containing only a "suspected_bot" key, and an error
116
+ will result. Either way, the spambot has been foiled!
117
+ email:
118
+ - sinisterchipmunk@gmail.com
119
+ executables: []
120
+
121
+ extensions: []
122
+
123
+ extra_rdoc_files:
124
+ - History.txt
125
+ - Manifest.txt
126
+ files:
127
+ - History.txt
128
+ - Manifest.txt
129
+ - README.rdoc
130
+ - Rakefile
131
+ - lib/bot-away.rb
132
+ - lib/bot-away/action_controller/request.rb
133
+ - lib/bot-away/action_view/helpers/instance_tag.rb
134
+ - lib/bot-away/param_parser.rb
135
+ - lib/bot-away/spinner.rb
136
+ - script/console
137
+ - script/destroy
138
+ - script/generate
139
+ - spec/controllers/test_controller_spec.rb
140
+ - spec/lib/action_view/helpers/instance_tag_spec.rb
141
+ - spec/lib/builder_spec.rb
142
+ - spec/lib/param_parser_spec.rb
143
+ - spec/spec_helper.rb
144
+ - spec/support/controllers/test_controller.rb
145
+ - spec/support/honeypot_matcher.rb
146
+ - spec/support/obfuscation_helper.rb
147
+ - spec/support/obfuscation_matcher.rb
148
+ - spec/support/views/test/index.html.erb
149
+ - spec/support/views/test/model_form.html.erb
150
+ has_rdoc: true
151
+ homepage: http://github.com/sinisterchipmunk/bot-away
152
+ licenses: []
153
+
154
+ post_install_message:
155
+ rdoc_options:
156
+ - --main
157
+ - README.rdoc
158
+ require_paths:
159
+ - lib
160
+ required_ruby_version: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - ">="
163
+ - !ruby/object:Gem::Version
164
+ segments:
165
+ - 0
166
+ version: "0"
167
+ required_rubygems_version: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - ">="
170
+ - !ruby/object:Gem::Version
171
+ segments:
172
+ - 0
173
+ version: "0"
174
+ requirements: []
175
+
176
+ rubyforge_project: bot-away
177
+ rubygems_version: 1.3.6
178
+ signing_key:
179
+ specification_version: 3
180
+ summary: Unobtrusively detects form submissions made by spambots, and silently drops those submissions
181
+ test_files: []
182
+