spamtrap 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91ab3ea3a6d506bbc30ec82be0f584e51dbe9c51cc93b0677d9e97edc9d8ea47
4
- data.tar.gz: 6c3667c9e712016f6f668db20e4e7e175290e148f4d259032a6400b9bf75986c
3
+ metadata.gz: 7c25ad9a77e76067aa77db7ac9f97aad1efde11b3f44a1ea6547a26da434bedd
4
+ data.tar.gz: 29211ceda85283fb468690b305e942246b9a00afb50e10450ca5e9b4a2839d7f
5
5
  SHA512:
6
- metadata.gz: 753c5c75e6a39d958b647bc45ee1e433f59f05c514a702e35e532f6a1dfc8abcb0dfeeecf5edbb3c0d489c488ec093fddb9c3cc342dab2f71027cd5a8f483ac6
7
- data.tar.gz: f5896fef5cad634cb91e2d46c52ab5b1fca74fbc76a9061fc05e209b793b0ddd8d066770ec02c33f74a45e565687ae63778140bfc5a8d485164809a0581b0a65
6
+ metadata.gz: ed39e2e15276baee291686c81c295bb18b180bc2d58240d48ef019cb1db36bcd90a05b67aa4e68c069d69b934a7e1533a1f16b22e8aec063310a1755cbacea64
7
+ data.tar.gz: 127e9108db3e7b72c0ea22f1ca586152f94a6cd6c2ba0645aa5b25dbb4c7ad1cb5249c2b7dd2587339297cbeddb0452fa549b84b143c1549f8ea8b431be248aa
@@ -1,4 +1,5 @@
1
1
  module Spamtrap::Controller
2
+ include Spamtrap::Crypto
2
3
 
3
4
  def self.included(base)
4
5
  base.extend ActsAsMethods
@@ -6,18 +7,64 @@ module Spamtrap::Controller
6
7
 
7
8
  module ActsAsMethods
8
9
  def spamtrap(honeypot = 'spamtrap', options = {}, &block)
10
+ nonce_enabled = options.delete(:nonce)
11
+ nonce_timeout = options.delete(:nonce_timeout) || Spamtrap.nonce_timeout
12
+ mutate_enabled = options.delete(:mutate)
13
+
9
14
  before_action(options) do |controller|
10
15
  controller.instance_eval(&block) if block_given?
11
16
  controller.instance_eval do
17
+ spamtrap_remap_params if mutate_enabled
18
+
12
19
  if params[honeypot].present?
13
20
  Rails.logger.warn "Spamtrap triggered by #{request.remote_ip}."
14
21
  head 200
22
+ elsif nonce_enabled && !spamtrap_valid_nonce?(nonce_timeout)
23
+ Rails.logger.warn "Spamtrap nonce invalid from #{request.remote_ip}."
24
+ head 200
15
25
  end
16
26
  end
17
27
  end
18
28
  end
19
29
  end
20
30
 
31
+ def spamtrap_valid_nonce?(timeout)
32
+ timestamp = params[:spamtrap_timestamp].to_i
33
+ nonce = params[:spamtrap_nonce].to_s
34
+ return false if timestamp.zero? || nonce.empty?
35
+ return false if Time.now.to_i - timestamp > timeout.to_i
36
+
37
+ secret = Rails.application.secret_key_base
38
+ expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}:#{request.remote_ip}")
39
+ ActiveSupport::SecurityUtils.secure_compare(nonce, expected)
40
+ end
41
+
42
+ def spamtrap_remap_params
43
+ salt_hex = params[:spamtrap_mutation_salt].to_s
44
+ return if salt_hex.empty?
45
+
46
+ salt_bytes = [salt_hex].pack('H*')
47
+ return unless salt_bytes.bytesize == Spamtrap::Crypto::NONCE_LEN
48
+
49
+ spamtrap_remap_hash(params, salt_bytes)
50
+ rescue ArgumentError
51
+ nil
52
+ end
53
+
54
+ def spamtrap_remap_hash(hash, salt)
55
+ hash.each_key.to_a.each do |key|
56
+ real = spamtrap_decrypt_field(key.to_s, salt)
57
+ if real
58
+ hash[real] = hash.delete(key)
59
+ key = real
60
+ end
61
+ child = hash[key]
62
+ spamtrap_remap_hash(child, salt) if child.is_a?(ActionController::Parameters)
63
+ end
64
+ end
65
+
66
+ private :spamtrap_valid_nonce?, :spamtrap_remap_params, :spamtrap_remap_hash
67
+
21
68
  end
22
69
 
23
70
  ActionController::Base.send :include, Spamtrap::Controller
@@ -0,0 +1,46 @@
1
+ require 'base64'
2
+
3
+ module Spamtrap
4
+ module Crypto
5
+ CIPHER = 'aes-128-gcm'
6
+ KEY_LEN = 16
7
+ NONCE_LEN = 12
8
+ TAG_LEN = 16
9
+
10
+ private
11
+
12
+ def spamtrap_crypto_key
13
+ @spamtrap_crypto_key ||= OpenSSL::KDF.hkdf(
14
+ Rails.application.secret_key_base,
15
+ salt: 'spamtrap-mutation',
16
+ info: '',
17
+ length: KEY_LEN,
18
+ hash: 'SHA256'
19
+ )
20
+ end
21
+
22
+ def spamtrap_encrypt_field(field_name, salt_bytes)
23
+ cipher = OpenSSL::Cipher.new(CIPHER)
24
+ cipher.encrypt
25
+ cipher.key = spamtrap_crypto_key
26
+ cipher.iv = salt_bytes
27
+ ct = cipher.update(field_name.to_s) + cipher.final
28
+ Base64.urlsafe_encode64(ct + cipher.auth_tag(TAG_LEN), padding: false)
29
+ end
30
+
31
+ def spamtrap_decrypt_field(token, salt_bytes)
32
+ raw = Base64.urlsafe_decode64(token)
33
+ return nil if raw.bytesize <= TAG_LEN
34
+ ct = raw[0, raw.bytesize - TAG_LEN]
35
+ tag = raw[-TAG_LEN..]
36
+ cipher = OpenSSL::Cipher.new(CIPHER)
37
+ cipher.decrypt
38
+ cipher.key = spamtrap_crypto_key
39
+ cipher.iv = salt_bytes
40
+ cipher.auth_tag = tag
41
+ (cipher.update(ct) + cipher.final).to_sym
42
+ rescue OpenSSL::Cipher::CipherError, ArgumentError
43
+ nil
44
+ end
45
+ end
46
+ end
@@ -1,17 +1,68 @@
1
+ module Spamtrap
2
+ module FormBuilderMutation
3
+ include Spamtrap::Crypto
4
+
5
+ MUTABLE_FIELDS = %i[
6
+ text_field email_field password_field number_field url_field telephone_field
7
+ text_area check_box hidden_field file_field date_field time_field
8
+ datetime_local_field month_field week_field search_field color_field
9
+ range_field select collection_select grouped_collection_select label
10
+ ].freeze
11
+
12
+ MUTABLE_FIELDS.each do |m|
13
+ define_method(m) do |field, *args, &blk|
14
+ field = spamtrap_encrypt_field(field.to_s, @spamtrap_salt) if @spamtrap_salt
15
+ super(field, *args, &blk)
16
+ end
17
+ end
18
+
19
+ def initialize(object_name, object, template, options)
20
+ super
21
+ @spamtrap_salt = options[:spamtrap_salt] if options[:spamtrap_salt]
22
+ end
23
+
24
+ def fields_for(record_name, record_object = nil, fields_options = {}, &block)
25
+ if @spamtrap_salt
26
+ if record_object.is_a?(Hash) && record_object.extractable_options?
27
+ record_object = record_object.merge(spamtrap_salt: @spamtrap_salt)
28
+ else
29
+ fields_options = fields_options.merge(spamtrap_salt: @spamtrap_salt)
30
+ end
31
+ end
32
+ super(record_name, record_object, fields_options, &block)
33
+ end
34
+ end
35
+ end
36
+
1
37
  class ActionView::Helpers::FormBuilder
2
- def spamtrap(parameter='spamtrap', options={})
3
- options.reverse_merge!({:class => 'spamtrap'})
4
- @template.text_area_tag(parameter, nil, options)
38
+ prepend Spamtrap::FormBuilderMutation
39
+
40
+ def spamtrap(parameter = 'spamtrap', options = {})
41
+ mutate = options.delete(:mutate)
42
+ nonce = options.delete(:nonce)
43
+ options.reverse_merge!(class: 'spamtrap')
44
+
45
+ if mutate
46
+ @spamtrap_salt = SecureRandom.bytes(Spamtrap::Crypto::NONCE_LEN)
47
+ salt_field = @template.hidden_field_tag(
48
+ :spamtrap_mutation_salt, @spamtrap_salt.unpack1('H*')
49
+ )
50
+ end
51
+
52
+ @template.text_area_tag(parameter, nil, options) +
53
+ (nonce ? spamtrap_nonce_fields : ''.html_safe) +
54
+ (salt_field || ''.html_safe)
5
55
  end
6
56
 
7
- # # def nonce(method, tag_value, options = {})
8
- # def cryptographic_nonce(options = {})
9
- # # @template.hidden_field(@object_name, method, tag_value, objectify_options(options))
10
- # now, random = Time.now.to_i, SecureRandom.hex
11
- # # OpenSSL::HMAC.hexdigest()
12
- # cryptographic_nonce = Digest::MD5.hexdigest([timestamp, Rails.application.config.secret_key_base].join(':'))
13
- # @template.hidden_field(@object_name, :timestamp, now, objectify_options(options)) +
14
- # @template.hidden_field(@object_name, :random, random, objectify_options(options)) +
15
- # @template.hidden_field(@object_name, :spinner, cryptographic_nonce(now), objectify_options(options))
16
- # end
57
+ private
58
+
59
+ def spamtrap_nonce_fields
60
+ timestamp = Time.now.to_i
61
+ ip = @template.request.remote_ip
62
+ secret = Rails.application.secret_key_base
63
+ nonce = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}:#{ip}")
64
+
65
+ @template.hidden_field_tag(:spamtrap_timestamp, timestamp) +
66
+ @template.hidden_field_tag(:spamtrap_nonce, nonce)
67
+ end
17
68
  end
@@ -1,5 +1,5 @@
1
1
  module Spamtrap
2
2
 
3
- VERSION = '0.2.0'
3
+ VERSION = '0.3.0'
4
4
 
5
5
  end
data/lib/spamtrap.rb CHANGED
@@ -1,4 +1,15 @@
1
+ require 'openssl'
2
+
1
3
  module Spamtrap
4
+ class << self
5
+ attr_writer :nonce_timeout
6
+
7
+ def nonce_timeout
8
+ @nonce_timeout || 1800
9
+ end
10
+ end
11
+
12
+ require 'spamtrap/crypto'
2
13
  require 'spamtrap/controller'
3
14
  require 'spamtrap/helper'
4
15
  end
@@ -1,10 +1,244 @@
1
1
  require File.join(File.dirname(__FILE__), 'test_helper')
2
2
 
3
- class ControllerTest < Minitest::Test
3
+ # Test controllers — render a body so we can distinguish a real response
4
+ # from a spamtrap-blocked one (which uses head 200 and has an empty body).
4
5
 
5
- def test_foobar
6
- assert true
7
- assert_equal "hello", "hello"
6
+ class HoneypotController < ActionController::Base
7
+ spamtrap :trap_field, only: :create
8
+
9
+ def create
10
+ render plain: 'success'
11
+ end
12
+ end
13
+
14
+ class NonceController < ActionController::Base
15
+ spamtrap :trap_field, nonce: true, only: :create
16
+
17
+ def create
18
+ render plain: 'success'
19
+ end
20
+ end
21
+
22
+ class NonceTimeoutController < ActionController::Base
23
+ spamtrap :trap_field, nonce: true, nonce_timeout: 60, only: :create
24
+
25
+ def create
26
+ render plain: 'success'
27
+ end
28
+ end
29
+
30
+ class MutationController < ActionController::Base
31
+ spamtrap :trap_field, mutate: true, only: :create
32
+
33
+ def create
34
+ # Render the remapped comment param keys so tests can assert on them
35
+ render plain: params[:comment].to_unsafe_h.keys.sort.join(',')
36
+ end
37
+ end
38
+
39
+ class NestedMutationController < ActionController::Base
40
+ spamtrap :trap_field, mutate: true, only: :create
41
+
42
+ def create
43
+ # Render top-level and nested address keys to verify salt propagation
44
+ comment_keys = params[:comment].to_unsafe_h.except('address').keys.sort
45
+ address_keys = params.dig(:comment, :address).to_unsafe_h.keys.sort rescue []
46
+ render plain: "#{comment_keys.join(',')};#{address_keys.join(',')}"
47
+ end
48
+ end
49
+
50
+ module NonceTestHelper
51
+ REMOTE_IP = '0.0.0.0'
52
+
53
+ def generate_nonce(timestamp, ip = REMOTE_IP)
54
+ secret = Rails.application.secret_key_base
55
+ OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}:#{ip}")
56
+ end
57
+ end
58
+
59
+ module MutationTestHelper
60
+ include Spamtrap::Crypto
61
+
62
+ MUTATION_SALT = SecureRandom.bytes(Spamtrap::Crypto::NONCE_LEN)
63
+ MUTATION_SALT_HEX = MUTATION_SALT.unpack1('H*')
64
+ end
65
+
66
+ class HoneypotControllerTest < ActionController::TestCase
67
+ tests HoneypotController
68
+
69
+ def test_empty_honeypot_allows_request
70
+ post :create, params: { trap_field: '' }
71
+ assert_response :ok
72
+ assert_equal 'success', response.body
73
+ end
74
+
75
+ def test_filled_honeypot_silently_discards
76
+ post :create, params: { trap_field: 'buy cheap meds' }
77
+ assert_response :ok
78
+ assert_empty response.body
79
+ end
80
+ end
81
+
82
+ class NonceControllerTest < ActionController::TestCase
83
+ include NonceTestHelper
84
+ tests NonceController
85
+
86
+ def test_valid_nonce_allows_request
87
+ timestamp = Time.now.to_i
88
+ post :create, params: {
89
+ trap_field: '',
90
+ spamtrap_timestamp: timestamp,
91
+ spamtrap_nonce: generate_nonce(timestamp)
92
+ }
93
+ assert_response :ok
94
+ assert_equal 'success', response.body
95
+ end
96
+
97
+ def test_tampered_nonce_is_rejected
98
+ timestamp = Time.now.to_i
99
+ post :create, params: {
100
+ trap_field: '',
101
+ spamtrap_timestamp: timestamp,
102
+ spamtrap_nonce: 'deadbeef'
103
+ }
104
+ assert_response :ok
105
+ assert_empty response.body
106
+ end
107
+
108
+ def test_expired_nonce_is_rejected
109
+ timestamp = Time.now.to_i - 7200
110
+ post :create, params: {
111
+ trap_field: '',
112
+ spamtrap_timestamp: timestamp,
113
+ spamtrap_nonce: generate_nonce(timestamp)
114
+ }
115
+ assert_response :ok
116
+ assert_empty response.body
117
+ end
118
+
119
+ def test_missing_nonce_fields_are_rejected
120
+ post :create, params: { trap_field: '' }
121
+ assert_response :ok
122
+ assert_empty response.body
123
+ end
124
+
125
+ def test_filled_honeypot_takes_priority_over_valid_nonce
126
+ timestamp = Time.now.to_i
127
+ post :create, params: {
128
+ trap_field: 'spam',
129
+ spamtrap_timestamp: timestamp,
130
+ spamtrap_nonce: generate_nonce(timestamp)
131
+ }
132
+ assert_response :ok
133
+ assert_empty response.body
134
+ end
135
+ end
136
+
137
+ class NonceTimeoutControllerTest < ActionController::TestCase
138
+ include NonceTestHelper
139
+ tests NonceTimeoutController
140
+
141
+ def test_nonce_within_custom_timeout_passes
142
+ timestamp = Time.now.to_i - 30
143
+ post :create, params: {
144
+ trap_field: '',
145
+ spamtrap_timestamp: timestamp,
146
+ spamtrap_nonce: generate_nonce(timestamp)
147
+ }
148
+ assert_response :ok
149
+ assert_equal 'success', response.body
8
150
  end
9
151
 
152
+ def test_nonce_beyond_custom_timeout_is_rejected
153
+ timestamp = Time.now.to_i - 120
154
+ post :create, params: {
155
+ trap_field: '',
156
+ spamtrap_timestamp: timestamp,
157
+ spamtrap_nonce: generate_nonce(timestamp)
158
+ }
159
+ assert_response :ok
160
+ assert_empty response.body
161
+ end
162
+ end
163
+
164
+ class MutationControllerTest < ActionController::TestCase
165
+ include MutationTestHelper
166
+ tests MutationController
167
+
168
+ def test_encrypted_field_names_are_remapped_to_real_names
169
+ body_token = spamtrap_encrypt_field('body', MUTATION_SALT)
170
+ email_token = spamtrap_encrypt_field('email', MUTATION_SALT)
171
+
172
+ post :create, params: {
173
+ trap_field: '',
174
+ spamtrap_mutation_salt: MUTATION_SALT_HEX,
175
+ comment: { body_token => 'Hello world', email_token => 'test@example.com' }
176
+ }
177
+
178
+ assert_response :ok
179
+ assert_equal 'body,email', response.body
180
+ end
181
+
182
+ def test_unencrypted_field_names_pass_through_unchanged
183
+ post :create, params: {
184
+ trap_field: '',
185
+ spamtrap_mutation_salt: MUTATION_SALT_HEX,
186
+ comment: { body: 'Hello', email: 'test@example.com' }
187
+ }
188
+
189
+ assert_response :ok
190
+ assert_equal 'body,email', response.body
191
+ end
192
+
193
+ def test_no_salt_leaves_encrypted_params_unmapped
194
+ body_token = spamtrap_encrypt_field('body', MUTATION_SALT)
195
+
196
+ post :create, params: {
197
+ trap_field: '',
198
+ comment: { body_token => 'Hello' }
199
+ }
200
+
201
+ assert_response :ok
202
+ refute_equal 'body', response.body
203
+ end
204
+
205
+ def test_wrong_salt_fails_to_decrypt
206
+ other_salt = SecureRandom.bytes(Spamtrap::Crypto::NONCE_LEN)
207
+ body_token = spamtrap_encrypt_field('body', other_salt)
208
+
209
+ post :create, params: {
210
+ trap_field: '',
211
+ spamtrap_mutation_salt: MUTATION_SALT_HEX,
212
+ comment: { body_token => 'Hello' }
213
+ }
214
+
215
+ assert_response :ok
216
+ refute_equal 'body', response.body
217
+ end
218
+ end
219
+
220
+ class NestedMutationControllerTest < ActionController::TestCase
221
+ include MutationTestHelper
222
+ tests NestedMutationController
223
+
224
+ def test_salt_propagates_to_fields_for_child_builder
225
+ body_token = spamtrap_encrypt_field('body', MUTATION_SALT)
226
+ street_token = spamtrap_encrypt_field('street', MUTATION_SALT)
227
+ city_token = spamtrap_encrypt_field('city', MUTATION_SALT)
228
+
229
+ post :create, params: {
230
+ trap_field: '',
231
+ spamtrap_mutation_salt: MUTATION_SALT_HEX,
232
+ comment: {
233
+ body_token => 'Hello',
234
+ address: {
235
+ street_token => '123 Main St',
236
+ city_token => 'Springfield'
237
+ }
238
+ }
239
+ }
240
+
241
+ assert_response :ok
242
+ assert_equal 'body;city,street', response.body
243
+ end
10
244
  end
data/test/test_helper.rb CHANGED
@@ -1,4 +1,28 @@
1
1
  require 'rubygems'
2
+ require 'rails'
2
3
  require 'action_controller'
4
+ require 'action_controller/test_case'
3
5
  require 'minitest/autorun'
4
6
  require 'spamtrap'
7
+
8
+ class SpamtrapTestApp < Rails::Application
9
+ config.secret_key_base = 'a' * 64
10
+ config.eager_load = false
11
+ config.logger = Logger.new(nil)
12
+ end
13
+
14
+ Rails.application.initialize!
15
+
16
+ Rails.application.routes.draw do
17
+ post 'honeypot/create', to: 'honeypot#create'
18
+ post 'nonce/create', to: 'nonce#create'
19
+ post 'nonce_timeout/create', to: 'nonce_timeout#create'
20
+ post 'mutation/create', to: 'mutation#create'
21
+ post 'nested_mutation/create', to: 'nested_mutation#create'
22
+ end
23
+
24
+ class ActionController::TestCase
25
+ setup do
26
+ @routes = Rails.application.routes
27
+ end
28
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spamtrap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cedric Howe
@@ -32,6 +32,7 @@ extra_rdoc_files: []
32
32
  files:
33
33
  - lib/spamtrap.rb
34
34
  - lib/spamtrap/controller.rb
35
+ - lib/spamtrap/crypto.rb
35
36
  - lib/spamtrap/helper.rb
36
37
  - lib/spamtrap/version.rb
37
38
  - test/controller_test.rb
@@ -54,7 +55,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
54
55
  - !ruby/object:Gem::Version
55
56
  version: 1.3.6
56
57
  requirements: []
57
- rubygems_version: 4.0.3
58
+ rubygems_version: 4.0.6
58
59
  specification_version: 4
59
60
  summary: Simple spamtrap for spambots.
60
61
  test_files: