spamtrap 0.3.1 → 0.3.3
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 +4 -4
- data/lib/spamtrap/controller.rb +15 -4
- data/lib/spamtrap/helper.rb +41 -2
- data/lib/spamtrap/version.rb +1 -1
- data/lib/spamtrap.rb +2 -1
- data/test/controller_test.rb +126 -0
- data/test/form_builder_mutation_test.rb +142 -0
- data/test/test_helper.rb +3 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3ff3c236a80418a4048beacd5cb3ff88c555a45d4736a8ea743a97a924d1283
|
|
4
|
+
data.tar.gz: 882c96f34aa70bfe8fab481704d96b97b8a9424c1d5b637140d7d60cb56abb30
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2316d8928764a5071ff756e37717f3d52924f85f8e7fb34a6de9109bec0d103716e2cfe428456557d9664e0c117fb2c9c8b735d231c16440610ec391d77afb00
|
|
7
|
+
data.tar.gz: db528095a79dc96011fad7ee831f7164650c3d3cfb92004ea6edced38fec1aa39df5ce87d4cef793631c713ccc668dd040f61b9e56d70c6b8b174338732ed9ec
|
data/lib/spamtrap/controller.rb
CHANGED
|
@@ -9,9 +9,10 @@ module Spamtrap::Controller
|
|
|
9
9
|
def spamtrap(honeypot = 'spamtrap', options = {}, &block)
|
|
10
10
|
# Capture explicit per-call values; use sentinel so globals are read
|
|
11
11
|
# at request time rather than at class definition time.
|
|
12
|
-
nonce_opt
|
|
13
|
-
timeout_opt
|
|
14
|
-
mutate_opt
|
|
12
|
+
nonce_opt = options.key?(:nonce) ? options.delete(:nonce) : :global
|
|
13
|
+
timeout_opt = options.key?(:nonce_timeout) ? options.delete(:nonce_timeout) : :global
|
|
14
|
+
mutate_opt = options.key?(:mutate) ? options.delete(:mutate) : :global
|
|
15
|
+
on_trap_opt = options.key?(:on_trap) ? options.delete(:on_trap) : :global
|
|
15
16
|
|
|
16
17
|
before_action(options) do |controller|
|
|
17
18
|
controller.instance_eval(&block) if block_given?
|
|
@@ -24,9 +25,11 @@ module Spamtrap::Controller
|
|
|
24
25
|
|
|
25
26
|
if params[honeypot].present?
|
|
26
27
|
Rails.logger.warn "Spamtrap triggered by #{request.remote_ip}."
|
|
28
|
+
spamtrap_invoke_on_trap(:honeypot, on_trap_opt)
|
|
27
29
|
head 200
|
|
28
30
|
elsif nonce_enabled && !spamtrap_valid_nonce?(nonce_timeout)
|
|
29
31
|
Rails.logger.warn "Spamtrap nonce invalid from #{request.remote_ip}."
|
|
32
|
+
spamtrap_invoke_on_trap(:nonce, on_trap_opt)
|
|
30
33
|
head 200
|
|
31
34
|
end
|
|
32
35
|
end
|
|
@@ -69,7 +72,15 @@ module Spamtrap::Controller
|
|
|
69
72
|
end
|
|
70
73
|
end
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
def spamtrap_invoke_on_trap(reason, on_trap_opt)
|
|
76
|
+
callback = on_trap_opt == :global ? Spamtrap.on_trap : on_trap_opt
|
|
77
|
+
return unless callback.respond_to?(:call)
|
|
78
|
+
callback.call(reason: reason, request: request)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
Rails.logger.error "Spamtrap on_trap callback raised: #{e.class}: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private :spamtrap_valid_nonce?, :spamtrap_remap_params, :spamtrap_remap_hash, :spamtrap_invoke_on_trap
|
|
73
84
|
|
|
74
85
|
end
|
|
75
86
|
|
data/lib/spamtrap/helper.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Spamtrap
|
|
2
4
|
module FormBuilderMutation
|
|
3
5
|
include Spamtrap::Crypto
|
|
@@ -11,8 +13,45 @@ module Spamtrap
|
|
|
11
13
|
|
|
12
14
|
MUTABLE_FIELDS.each do |m|
|
|
13
15
|
define_method(m) do |field, *args, &blk|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
if @spamtrap_salt
|
|
17
|
+
encrypted_field = spamtrap_encrypt_field(field.to_s, @spamtrap_salt)
|
|
18
|
+
|
|
19
|
+
opts =
|
|
20
|
+
if m == :check_box
|
|
21
|
+
args.first.is_a?(Hash) ? args.shift.dup : {}
|
|
22
|
+
else
|
|
23
|
+
args.last.is_a?(Hash) ? args.pop.dup : {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
model_value = object.respond_to?(field) ? object.public_send(field) : nil
|
|
27
|
+
|
|
28
|
+
case m
|
|
29
|
+
when :check_box
|
|
30
|
+
# :checked controls the checked state; :value is the submitted value ("1" by default)
|
|
31
|
+
opts[:checked] = !!model_value unless opts.key?(:checked)
|
|
32
|
+
super(encrypted_field, opts, *args, &blk)
|
|
33
|
+
when :select, :collection_select, :grouped_collection_select
|
|
34
|
+
# :selected belongs in the inner options hash, not html_options (the last hash).
|
|
35
|
+
# After popping html_options into opts, args.last is the options hash (if present).
|
|
36
|
+
if args.last.is_a?(Hash)
|
|
37
|
+
sel_opts = args.pop.dup
|
|
38
|
+
sel_opts[:selected] = model_value unless sel_opts.key?(:selected)
|
|
39
|
+
args.push(sel_opts)
|
|
40
|
+
else
|
|
41
|
+
opts[:selected] = model_value unless opts.key?(:selected)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
super(encrypted_field, *args, opts, &blk)
|
|
45
|
+
when :label
|
|
46
|
+
# label renders a <label> element and does not read a value from the model object
|
|
47
|
+
super(encrypted_field, *args, opts, &blk)
|
|
48
|
+
else
|
|
49
|
+
opts[:value] = model_value unless opts.key?(:value)
|
|
50
|
+
super(encrypted_field, *args, opts, &blk)
|
|
51
|
+
end
|
|
52
|
+
else
|
|
53
|
+
super(field, *args, &blk)
|
|
54
|
+
end
|
|
16
55
|
end
|
|
17
56
|
end
|
|
18
57
|
|
data/lib/spamtrap/version.rb
CHANGED
data/lib/spamtrap.rb
CHANGED
data/test/controller_test.rb
CHANGED
|
@@ -345,3 +345,129 @@ class GlobalOverrideControllerTest < ActionController::TestCase
|
|
|
345
345
|
assert_equal 'body', response.body
|
|
346
346
|
end
|
|
347
347
|
end
|
|
348
|
+
|
|
349
|
+
# Controllers for on_trap callback tests.
|
|
350
|
+
class OnTrapGlobalCallbackController < ActionController::Base
|
|
351
|
+
spamtrap :trap_field, only: :create
|
|
352
|
+
|
|
353
|
+
def create
|
|
354
|
+
render plain: 'success'
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
class OnTrapGlobalNonceCallbackController < ActionController::Base
|
|
359
|
+
spamtrap :trap_field, nonce: true, only: :create
|
|
360
|
+
|
|
361
|
+
def create
|
|
362
|
+
render plain: 'success'
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
class OnTrapPerDeclarationController < ActionController::Base
|
|
367
|
+
spamtrap :trap_field, only: :create,
|
|
368
|
+
on_trap: ->(reason:, request:) { Thread.current[:per_decl_calls] << { reason: reason, ip: request.remote_ip } }
|
|
369
|
+
|
|
370
|
+
def create
|
|
371
|
+
render plain: 'success'
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
class OnTrapCallbackTest < ActionController::TestCase
|
|
376
|
+
tests OnTrapGlobalCallbackController
|
|
377
|
+
|
|
378
|
+
setup do
|
|
379
|
+
@calls = []
|
|
380
|
+
Spamtrap.on_trap = ->(reason:, request:) { @calls << { reason: reason, ip: request.remote_ip } }
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
teardown do
|
|
384
|
+
Spamtrap.on_trap = nil
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def test_global_callback_invoked_on_honeypot_trap
|
|
388
|
+
post :create, params: { trap_field: 'spam' }
|
|
389
|
+
assert_response :ok
|
|
390
|
+
assert_empty response.body
|
|
391
|
+
assert_equal 1, @calls.size
|
|
392
|
+
assert_equal :honeypot, @calls.first[:reason]
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def test_global_callback_not_invoked_on_legitimate_request
|
|
396
|
+
post :create, params: { trap_field: '' }
|
|
397
|
+
assert_response :ok
|
|
398
|
+
assert_equal 'success', response.body
|
|
399
|
+
assert_empty @calls
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def test_no_callback_when_on_trap_is_nil
|
|
403
|
+
Spamtrap.on_trap = nil
|
|
404
|
+
post :create, params: { trap_field: 'spam' }
|
|
405
|
+
assert_response :ok
|
|
406
|
+
assert_empty response.body
|
|
407
|
+
# no error raised — test simply passes
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
class OnTrapNonceCallbackTest < ActionController::TestCase
|
|
412
|
+
include NonceTestHelper
|
|
413
|
+
tests OnTrapGlobalNonceCallbackController
|
|
414
|
+
|
|
415
|
+
setup do
|
|
416
|
+
@calls = []
|
|
417
|
+
Spamtrap.on_trap = ->(reason:, request:) { @calls << { reason: reason, ip: request.remote_ip } }
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
teardown do
|
|
421
|
+
Spamtrap.on_trap = nil
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def test_global_callback_invoked_on_nonce_trap
|
|
425
|
+
post :create, params: { trap_field: '' }
|
|
426
|
+
assert_response :ok
|
|
427
|
+
assert_empty response.body
|
|
428
|
+
assert_equal 1, @calls.size
|
|
429
|
+
assert_equal :nonce, @calls.first[:reason]
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
class OnTrapPerDeclarationCallbackTest < ActionController::TestCase
|
|
434
|
+
tests OnTrapPerDeclarationController
|
|
435
|
+
|
|
436
|
+
setup do
|
|
437
|
+
Thread.current[:per_decl_calls] = []
|
|
438
|
+
@global_calls = []
|
|
439
|
+
Spamtrap.on_trap = ->(reason:, request:) { @global_calls << reason }
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
teardown do
|
|
443
|
+
Spamtrap.on_trap = nil
|
|
444
|
+
Thread.current[:per_decl_calls] = nil
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def test_per_declaration_callback_takes_precedence_over_global
|
|
448
|
+
post :create, params: { trap_field: 'spam' }
|
|
449
|
+
assert_response :ok
|
|
450
|
+
assert_empty response.body
|
|
451
|
+
assert_equal 1, Thread.current[:per_decl_calls].size
|
|
452
|
+
assert_equal :honeypot, Thread.current[:per_decl_calls].first[:reason]
|
|
453
|
+
assert_empty @global_calls
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
class OnTrapCallbackErrorResilienceTest < ActionController::TestCase
|
|
458
|
+
tests OnTrapGlobalCallbackController
|
|
459
|
+
|
|
460
|
+
setup do
|
|
461
|
+
Spamtrap.on_trap = ->(**) { raise 'callback exploded' }
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
teardown do
|
|
465
|
+
Spamtrap.on_trap = nil
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def test_broken_callback_does_not_prevent_head_200
|
|
469
|
+
post :create, params: { trap_field: 'spam' }
|
|
470
|
+
assert_response :ok
|
|
471
|
+
assert_empty response.body
|
|
472
|
+
end
|
|
473
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
|
4
|
+
require 'action_view'
|
|
5
|
+
require 'action_view/test_case'
|
|
6
|
+
|
|
7
|
+
# A simple model-like struct to back the form builder, mimicking an AR model.
|
|
8
|
+
Message = Struct.new(:name, :email, :body, :active, :country)
|
|
9
|
+
|
|
10
|
+
class FormBuilderMutationTest < ActionView::TestCase
|
|
11
|
+
include Spamtrap::Crypto
|
|
12
|
+
|
|
13
|
+
# Build a minimal form builder instance backed by a Message object.
|
|
14
|
+
def build_form_builder(object)
|
|
15
|
+
ActionView::Helpers::FormBuilder.new(:message, object, self, {})
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# --- text-like inputs ---
|
|
19
|
+
|
|
20
|
+
def test_text_field_does_not_raise_when_mutate_is_true
|
|
21
|
+
msg = Message.new('Alice', 'alice@example.com', 'Hello')
|
|
22
|
+
f = build_form_builder(msg)
|
|
23
|
+
f.spamtrap(:trap, mutate: true)
|
|
24
|
+
|
|
25
|
+
assert_nothing_raised { f.text_field(:name) }
|
|
26
|
+
assert_nothing_raised { f.email_field(:email) }
|
|
27
|
+
assert_nothing_raised { f.text_area(:body) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_model_value_is_preserved_in_encrypted_field
|
|
31
|
+
msg = Message.new('Alice', 'alice@example.com', 'Hello')
|
|
32
|
+
f = build_form_builder(msg)
|
|
33
|
+
f.spamtrap(:trap, mutate: true)
|
|
34
|
+
|
|
35
|
+
assert_includes f.text_field(:name), 'Alice'
|
|
36
|
+
assert_includes f.email_field(:email), 'alice@example.com'
|
|
37
|
+
assert_includes f.text_area(:body), 'Hello'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_encrypted_field_name_is_used_in_html
|
|
41
|
+
msg = Message.new('Alice', 'alice@example.com', 'Hello')
|
|
42
|
+
f = build_form_builder(msg)
|
|
43
|
+
f.spamtrap(:trap, mutate: true)
|
|
44
|
+
|
|
45
|
+
html = f.text_field(:name)
|
|
46
|
+
|
|
47
|
+
# The original unencrypted name must NOT appear as the input name attribute
|
|
48
|
+
refute_match(/name="message\[name\]"/, html)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def test_nil_model_value_does_not_raise
|
|
52
|
+
msg = Message.new(nil, nil, nil)
|
|
53
|
+
f = build_form_builder(msg)
|
|
54
|
+
f.spamtrap(:trap, mutate: true)
|
|
55
|
+
|
|
56
|
+
assert_nothing_raised { f.text_field(:name) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_virtual_field_without_model_method_does_not_raise
|
|
60
|
+
msg = Message.new('Alice', 'alice@example.com', 'Hello')
|
|
61
|
+
f = build_form_builder(msg)
|
|
62
|
+
f.spamtrap(:trap, mutate: true)
|
|
63
|
+
|
|
64
|
+
# :nonexistent is not a method on Message; should render with empty value, not crash
|
|
65
|
+
assert_nothing_raised { f.text_field(:nonexistent) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def test_explicit_value_option_is_preserved
|
|
69
|
+
msg = Message.new('Alice', 'alice@example.com', 'Hello')
|
|
70
|
+
f = build_form_builder(msg)
|
|
71
|
+
f.spamtrap(:trap, mutate: true)
|
|
72
|
+
|
|
73
|
+
html = f.text_field(:name, value: 'Overridden')
|
|
74
|
+
|
|
75
|
+
assert_includes html, 'Overridden'
|
|
76
|
+
refute_includes html, 'Alice'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# --- no mutation ---
|
|
80
|
+
|
|
81
|
+
def test_no_salt_leaves_field_names_unencrypted
|
|
82
|
+
msg = Message.new('Alice', 'alice@example.com', 'Hello')
|
|
83
|
+
f = build_form_builder(msg)
|
|
84
|
+
|
|
85
|
+
html = f.text_field(:name)
|
|
86
|
+
|
|
87
|
+
assert_match(/name="message\[name\]"/, html)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# --- check_box ---
|
|
91
|
+
|
|
92
|
+
def test_check_box_does_not_raise_when_mutate_is_true
|
|
93
|
+
msg = Message.new(nil, nil, nil, true)
|
|
94
|
+
f = build_form_builder(msg)
|
|
95
|
+
f.spamtrap(:trap, mutate: true)
|
|
96
|
+
|
|
97
|
+
assert_nothing_raised { f.check_box(:active) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def test_check_box_checked_when_model_value_is_true
|
|
101
|
+
msg = Message.new(nil, nil, nil, true)
|
|
102
|
+
f = build_form_builder(msg)
|
|
103
|
+
f.spamtrap(:trap, mutate: true)
|
|
104
|
+
|
|
105
|
+
assert_includes f.check_box(:active), 'checked'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def test_check_box_unchecked_when_model_value_is_false
|
|
109
|
+
msg = Message.new(nil, nil, nil, false)
|
|
110
|
+
f = build_form_builder(msg)
|
|
111
|
+
f.spamtrap(:trap, mutate: true)
|
|
112
|
+
|
|
113
|
+
refute_includes f.check_box(:active), 'checked'
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# --- select ---
|
|
117
|
+
|
|
118
|
+
def test_select_does_not_raise_when_mutate_is_true
|
|
119
|
+
msg = Message.new(nil, nil, nil, nil, 'US')
|
|
120
|
+
f = build_form_builder(msg)
|
|
121
|
+
f.spamtrap(:trap, mutate: true)
|
|
122
|
+
|
|
123
|
+
assert_nothing_raised { f.select(:country, %w[US CA GB]) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def test_select_preselects_model_value
|
|
127
|
+
msg = Message.new(nil, nil, nil, nil, 'CA')
|
|
128
|
+
f = build_form_builder(msg)
|
|
129
|
+
f.spamtrap(:trap, mutate: true)
|
|
130
|
+
|
|
131
|
+
html = f.select(:country, %w[US CA GB])
|
|
132
|
+
|
|
133
|
+
assert_match(/selected.*CA|CA.*selected/, html)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
# ActionView::TestCase helpers expect a #request method; provide a stub.
|
|
139
|
+
def request
|
|
140
|
+
@request ||= ActionDispatch::TestRequest.create
|
|
141
|
+
end
|
|
142
|
+
end
|
data/test/test_helper.rb
CHANGED
|
@@ -21,6 +21,9 @@ Rails.application.routes.draw do
|
|
|
21
21
|
post 'nested_mutation/create', to: 'nested_mutation#create'
|
|
22
22
|
post 'global_defaults/create', to: 'global_defaults#create'
|
|
23
23
|
post 'global_override/create', to: 'global_override#create'
|
|
24
|
+
post 'on_trap_global_callback/create', to: 'on_trap_global_callback#create'
|
|
25
|
+
post 'on_trap_global_nonce_callback/create', to: 'on_trap_global_nonce_callback#create'
|
|
26
|
+
post 'on_trap_per_declaration/create', to: 'on_trap_per_declaration#create'
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
class ActionController::TestCase
|
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.3.
|
|
4
|
+
version: 0.3.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cedric Howe
|
|
@@ -36,6 +36,7 @@ files:
|
|
|
36
36
|
- lib/spamtrap/helper.rb
|
|
37
37
|
- lib/spamtrap/version.rb
|
|
38
38
|
- test/controller_test.rb
|
|
39
|
+
- test/form_builder_mutation_test.rb
|
|
39
40
|
- test/test_helper.rb
|
|
40
41
|
homepage: http://github.com/cedric/spamtrap/
|
|
41
42
|
licenses:
|
|
@@ -60,4 +61,5 @@ specification_version: 4
|
|
|
60
61
|
summary: Simple spamtrap for spambots.
|
|
61
62
|
test_files:
|
|
62
63
|
- test/controller_test.rb
|
|
64
|
+
- test/form_builder_mutation_test.rb
|
|
63
65
|
- test/test_helper.rb
|