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 +4 -4
- data/lib/spamtrap/controller.rb +21 -4
- data/lib/spamtrap/helper.rb +2 -2
- data/lib/spamtrap/version.rb +1 -1
- data/lib/spamtrap.rb +10 -1
- data/test/controller_test.rb +229 -0
- data/test/test_helper.rb +6 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f0cec3797da20a8ab16dd95113f15c271acb6764478291372e14fc8d3556e0a3
|
|
4
|
+
data.tar.gz: 960f481d33667095c861f91665786ab6e5a60c5a162e9f90fea709b99821b91e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 846a83081e7139d5c6ba56674d3f5506abebc583c9f2fc62501fa8dc30d230f391f3a61a63a8a231456368a09c8ba20635d8af1176356d7574c97d1c4c0a9f9c
|
|
7
|
+
data.tar.gz: fab5e0b02f702ee3f71bb2914d766a6a3ba1fedc90232189844497e5c818927b7cd31ff17eeb0fbf3583494b4e3e54c14e00511055df6a604608a5d850e3c0ad
|
data/lib/spamtrap/controller.rb
CHANGED
|
@@ -7,20 +7,29 @@ module Spamtrap::Controller
|
|
|
7
7
|
|
|
8
8
|
module ActsAsMethods
|
|
9
9
|
def spamtrap(honeypot = 'spamtrap', options = {}, &block)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/spamtrap/helper.rb
CHANGED
|
@@ -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
|
data/lib/spamtrap/version.rb
CHANGED
data/lib/spamtrap.rb
CHANGED
|
@@ -2,11 +2,20 @@ require 'openssl'
|
|
|
2
2
|
|
|
3
3
|
module Spamtrap
|
|
4
4
|
class << self
|
|
5
|
-
|
|
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'
|
data/test/controller_test.rb
CHANGED
|
@@ -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',
|
|
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
|