spamtrap 0.3.0 → 0.3.2

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: 7c25ad9a77e76067aa77db7ac9f97aad1efde11b3f44a1ea6547a26da434bedd
4
- data.tar.gz: 29211ceda85283fb468690b305e942246b9a00afb50e10450ca5e9b4a2839d7f
3
+ metadata.gz: f0cec3797da20a8ab16dd95113f15c271acb6764478291372e14fc8d3556e0a3
4
+ data.tar.gz: 960f481d33667095c861f91665786ab6e5a60c5a162e9f90fea709b99821b91e
5
5
  SHA512:
6
- metadata.gz: ed39e2e15276baee291686c81c295bb18b180bc2d58240d48ef019cb1db36bcd90a05b67aa4e68c069d69b934a7e1533a1f16b22e8aec063310a1755cbacea64
7
- data.tar.gz: 127e9108db3e7b72c0ea22f1ca586152f94a6cd6c2ba0645aa5b25dbb4c7ad1cb5249c2b7dd2587339297cbeddb0452fa549b84b143c1549f8ea8b431be248aa
6
+ metadata.gz: 846a83081e7139d5c6ba56674d3f5506abebc583c9f2fc62501fa8dc30d230f391f3a61a63a8a231456368a09c8ba20635d8af1176356d7574c97d1c4c0a9f9c
7
+ data.tar.gz: fab5e0b02f702ee3f71bb2914d766a6a3ba1fedc90232189844497e5c818927b7cd31ff17eeb0fbf3583494b4e3e54c14e00511055df6a604608a5d850e3c0ad
@@ -7,20 +7,29 @@ module Spamtrap::Controller
7
7
 
8
8
  module ActsAsMethods
9
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)
10
+ # Capture explicit per-call values; use sentinel so globals are read
11
+ # at request time rather than at class definition time.
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
13
16
 
14
17
  before_action(options) do |controller|
15
18
  controller.instance_eval(&block) if block_given?
16
19
  controller.instance_eval do
20
+ nonce_enabled = nonce_opt == :global ? Spamtrap.nonce : nonce_opt
21
+ nonce_timeout = timeout_opt == :global ? Spamtrap.nonce_timeout : timeout_opt
22
+ mutate_enabled = mutate_opt == :global ? Spamtrap.mutate : mutate_opt
23
+
17
24
  spamtrap_remap_params if mutate_enabled
18
25
 
19
26
  if params[honeypot].present?
20
27
  Rails.logger.warn "Spamtrap triggered by #{request.remote_ip}."
28
+ spamtrap_invoke_on_trap(:honeypot, on_trap_opt)
21
29
  head 200
22
30
  elsif nonce_enabled && !spamtrap_valid_nonce?(nonce_timeout)
23
31
  Rails.logger.warn "Spamtrap nonce invalid from #{request.remote_ip}."
32
+ spamtrap_invoke_on_trap(:nonce, on_trap_opt)
24
33
  head 200
25
34
  end
26
35
  end
@@ -63,7 +72,15 @@ module Spamtrap::Controller
63
72
  end
64
73
  end
65
74
 
66
- private :spamtrap_valid_nonce?, :spamtrap_remap_params, :spamtrap_remap_hash
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
67
84
 
68
85
  end
69
86
 
@@ -38,8 +38,8 @@ class ActionView::Helpers::FormBuilder
38
38
  prepend Spamtrap::FormBuilderMutation
39
39
 
40
40
  def spamtrap(parameter = 'spamtrap', options = {})
41
- mutate = options.delete(:mutate)
42
- nonce = options.delete(:nonce)
41
+ mutate = options.key?(:mutate) ? options.delete(:mutate) : Spamtrap.mutate
42
+ nonce = options.key?(:nonce) ? options.delete(:nonce) : Spamtrap.nonce
43
43
  options.reverse_merge!(class: 'spamtrap')
44
44
 
45
45
  if mutate
@@ -1,5 +1,5 @@
1
1
  module Spamtrap
2
2
 
3
- VERSION = '0.3.0'
3
+ VERSION = '0.3.2'
4
4
 
5
5
  end
data/lib/spamtrap.rb CHANGED
@@ -2,11 +2,20 @@ require 'openssl'
2
2
 
3
3
  module Spamtrap
4
4
  class << self
5
- attr_writer :nonce_timeout
5
+ attr_accessor :on_trap
6
+ attr_writer :nonce, :nonce_timeout, :mutate
7
+
8
+ def nonce
9
+ @nonce || false
10
+ end
6
11
 
7
12
  def nonce_timeout
8
13
  @nonce_timeout || 1800
9
14
  end
15
+
16
+ def mutate
17
+ @mutate || false
18
+ end
10
19
  end
11
20
 
12
21
  require 'spamtrap/crypto'
@@ -242,3 +242,232 @@ class NestedMutationControllerTest < ActionController::TestCase
242
242
  assert_equal 'body;city,street', response.body
243
243
  end
244
244
  end
245
+
246
+ # Controller that relies entirely on global defaults (no per-call options).
247
+ class GlobalDefaultsController < ActionController::Base
248
+ spamtrap :trap_field, only: :create
249
+
250
+ def create
251
+ render plain: params[:comment].to_unsafe_h.keys.sort.join(',')
252
+ end
253
+ end
254
+
255
+ # Controller that explicitly overrides global defaults with false.
256
+ class GlobalOverrideController < ActionController::Base
257
+ spamtrap :trap_field, nonce: false, mutate: false, only: :create
258
+
259
+ def create
260
+ render plain: params[:comment].to_unsafe_h.keys.sort.join(',')
261
+ end
262
+ end
263
+
264
+ class GlobalDefaultsControllerTest < ActionController::TestCase
265
+ include MutationTestHelper
266
+ include NonceTestHelper
267
+ tests GlobalDefaultsController
268
+
269
+ setup do
270
+ Spamtrap.nonce = true
271
+ Spamtrap.mutate = true
272
+ end
273
+
274
+ teardown do
275
+ Spamtrap.nonce = false
276
+ Spamtrap.mutate = false
277
+ end
278
+
279
+ def test_global_nonce_default_rejects_missing_nonce
280
+ post :create, params: { trap_field: '', comment: { body: 'Hello' } }
281
+ assert_response :ok
282
+ assert_empty response.body
283
+ end
284
+
285
+ def test_global_nonce_default_accepts_valid_nonce
286
+ timestamp = Time.now.to_i
287
+ post :create, params: {
288
+ trap_field: '',
289
+ spamtrap_timestamp: timestamp,
290
+ spamtrap_nonce: generate_nonce(timestamp),
291
+ spamtrap_mutation_salt: MUTATION_SALT_HEX,
292
+ comment: { spamtrap_encrypt_field('body', MUTATION_SALT) => 'Hello' }
293
+ }
294
+ assert_response :ok
295
+ assert_equal 'body', response.body
296
+ end
297
+
298
+ def test_global_mutate_default_remaps_encrypted_fields
299
+ body_token = spamtrap_encrypt_field('body', MUTATION_SALT)
300
+ timestamp = Time.now.to_i
301
+ post :create, params: {
302
+ trap_field: '',
303
+ spamtrap_timestamp: timestamp,
304
+ spamtrap_nonce: generate_nonce(timestamp),
305
+ spamtrap_mutation_salt: MUTATION_SALT_HEX,
306
+ comment: { body_token => 'Hello' }
307
+ }
308
+ assert_response :ok
309
+ assert_equal 'body', response.body
310
+ end
311
+ end
312
+
313
+ class GlobalOverrideControllerTest < ActionController::TestCase
314
+ include MutationTestHelper
315
+ tests GlobalOverrideController
316
+
317
+ setup do
318
+ Spamtrap.nonce = true
319
+ Spamtrap.mutate = true
320
+ end
321
+
322
+ teardown do
323
+ Spamtrap.nonce = false
324
+ Spamtrap.mutate = false
325
+ end
326
+
327
+ def test_per_call_false_overrides_global_nonce
328
+ # No nonce params — would fail if global nonce: true were in effect
329
+ post :create, params: {
330
+ trap_field: '',
331
+ comment: { body: 'Hello' }
332
+ }
333
+ assert_response :ok
334
+ assert_equal 'body', response.body
335
+ end
336
+
337
+ def test_per_call_false_overrides_global_mutate
338
+ # Plain field name — would be remapped if global mutate: true were in effect
339
+ post :create, params: {
340
+ trap_field: '',
341
+ spamtrap_mutation_salt: MUTATION_SALT_HEX,
342
+ comment: { body: 'Hello' }
343
+ }
344
+ assert_response :ok
345
+ assert_equal 'body', response.body
346
+ end
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
data/test/test_helper.rb CHANGED
@@ -18,7 +18,12 @@ Rails.application.routes.draw do
18
18
  post 'nonce/create', to: 'nonce#create'
19
19
  post 'nonce_timeout/create', to: 'nonce_timeout#create'
20
20
  post 'mutation/create', to: 'mutation#create'
21
- post 'nested_mutation/create', to: 'nested_mutation#create'
21
+ post 'nested_mutation/create', to: 'nested_mutation#create'
22
+ post 'global_defaults/create', to: 'global_defaults#create'
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'
22
27
  end
23
28
 
24
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.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cedric Howe