spamtrap 0.3.5 → 0.4.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/generators/spamtrap/install/install_generator.rb +15 -0
- data/lib/generators/spamtrap/install/templates/spamtrap.rb +78 -0
- data/lib/spamtrap/controller.rb +237 -43
- data/lib/spamtrap/crypto.rb +86 -19
- data/lib/spamtrap/helper.rb +251 -68
- data/lib/spamtrap/parameter_filter.rb +77 -0
- data/lib/spamtrap/railtie.rb +20 -0
- data/lib/spamtrap/test_helper.rb +103 -0
- data/lib/spamtrap/version.rb +1 -1
- data/lib/spamtrap.rb +174 -1
- metadata +101 -14
- data/test/controller_test.rb +0 -473
- data/test/form_builder_mutation_test.rb +0 -174
- data/test/test_helper.rb +0 -33
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1cc68536b7186bc9fac9249043e7bd3d0713b77070fab4611c08a8ba5fb1bfc1
|
|
4
|
+
data.tar.gz: 91c9ae78401af26537bc45c7c1ea17fac4245c264e1a6ee421fb6a9fd86af2d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5985f1e8600a0f2d361b94ede13e567ea8b09d8e9ff3d7b4b0c2be99987d50d85bf5271dcea1bf3a0b5cc128a0fc066d6395b8ebf0be53dbc25970333e8ddab0
|
|
7
|
+
data.tar.gz: 5adec9413ed191d8de06866db16da6b68b65ead9509749a535fe416964540da1751037f44e52a688461cd828cbe82826d4714c40d08c06fdafa8f68e0b2f07da
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require 'rails/generators'
|
|
2
|
+
|
|
3
|
+
module Spamtrap
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path('templates', __dir__)
|
|
7
|
+
|
|
8
|
+
desc 'Creates a Spamtrap initializer documenting every configuration option and its default.'
|
|
9
|
+
|
|
10
|
+
def create_initializer
|
|
11
|
+
template 'spamtrap.rb', 'config/initializers/spamtrap.rb'
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Spamtrap global configuration. Every option can be overridden per controller action or
|
|
2
|
+
# per render; an explicit value there always wins, including `false` over a global `true`.
|
|
3
|
+
# See https://github.com/cedric/spamtrap/ for the full guide.
|
|
4
|
+
|
|
5
|
+
# Honeypot
|
|
6
|
+
|
|
7
|
+
# Decoys f.spamtrap renders: any of :textarea, :text, :checkbox.
|
|
8
|
+
# Spamtrap.honeypot_styles = [:textarea]
|
|
9
|
+
|
|
10
|
+
# Require a hidden field that only an executing script fills in; excludes no-JS users.
|
|
11
|
+
# Spamtrap.js_proof = false
|
|
12
|
+
|
|
13
|
+
# Nonce
|
|
14
|
+
|
|
15
|
+
# false, true, or :single_use.
|
|
16
|
+
# Spamtrap.nonce = false
|
|
17
|
+
|
|
18
|
+
# Seconds; also caps mutation token age unless mutation_timeout is set.
|
|
19
|
+
# Spamtrap.nonce_timeout = 1800
|
|
20
|
+
|
|
21
|
+
# Seconds a submitted timestamp may sit in the future.
|
|
22
|
+
# Spamtrap.nonce_skew = 60
|
|
23
|
+
|
|
24
|
+
# true (full IP), :prefix (/24 IPv4 or /48 IPv6), or false (not bound).
|
|
25
|
+
# Spamtrap.nonce_bind_ip = true
|
|
26
|
+
|
|
27
|
+
# Where nonce: :single_use records seen nonce ids; defaults to the app's cache.
|
|
28
|
+
# Spamtrap.nonce_store = Rails.cache
|
|
29
|
+
|
|
30
|
+
# Mutation
|
|
31
|
+
|
|
32
|
+
# false, true/:strict (traps plaintext keys), or :lenient (remap only).
|
|
33
|
+
# Spamtrap.mutate = false
|
|
34
|
+
|
|
35
|
+
# Seconds; defaults to nonce_timeout.
|
|
36
|
+
# Spamtrap.mutation_timeout = nil
|
|
37
|
+
|
|
38
|
+
# Extra top-level param names a strict action accepts unencrypted.
|
|
39
|
+
# Spamtrap.allowed_params = []
|
|
40
|
+
|
|
41
|
+
# Whether mutated fields keep an id/for derived from the real field name.
|
|
42
|
+
# Spamtrap.stable_ids = true
|
|
43
|
+
|
|
44
|
+
# Apply config.filter_parameters to mutated field names, which the log otherwise shows unfiltered.
|
|
45
|
+
# Spamtrap.filter_parameters = true
|
|
46
|
+
|
|
47
|
+
# Fill time
|
|
48
|
+
|
|
49
|
+
# Minimum seconds between render and submission a human needs; false or 0 disables.
|
|
50
|
+
# Spamtrap.min_fill_time = 1
|
|
51
|
+
|
|
52
|
+
# Responses and callbacks
|
|
53
|
+
|
|
54
|
+
# How a trapped request responds: :head, :no_content, :unprocessable, :redirect_back,
|
|
55
|
+
# a callable, or a Hash keyed by trap reason.
|
|
56
|
+
# Spamtrap.trap_response = :head
|
|
57
|
+
|
|
58
|
+
# Optional callback invoked whenever the spamtrap fires.
|
|
59
|
+
# Spamtrap.on_trap = ->(reason:, request:) { }
|
|
60
|
+
|
|
61
|
+
# App-defined heuristic run after every other check; truthy result traps as :content.
|
|
62
|
+
# Spamtrap.suspicious_if = nil
|
|
63
|
+
|
|
64
|
+
# Keys
|
|
65
|
+
|
|
66
|
+
# The current HKDF source secret; defaults to the app's own so no setup is needed to opt in.
|
|
67
|
+
# Spamtrap.secret_key_base = nil
|
|
68
|
+
|
|
69
|
+
# Set during a rotation window so tokens minted under the old secret still verify/decrypt.
|
|
70
|
+
# Spamtrap.previous_secret_key_base = nil
|
|
71
|
+
|
|
72
|
+
# Binds tokens to a value derived from the request (e.g. host), for multi-tenant apps; off by default.
|
|
73
|
+
# Spamtrap.token_context = nil
|
|
74
|
+
|
|
75
|
+
# Testing
|
|
76
|
+
|
|
77
|
+
# Global kill switch; false skips every spamtrap check. Handy for test environments.
|
|
78
|
+
# Spamtrap.enabled = true
|
data/lib/spamtrap/controller.rb
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
module Spamtrap::Controller
|
|
2
2
|
include Spamtrap::Crypto
|
|
3
3
|
|
|
4
|
+
# Fields Rails/browsers/forms add that a strict action must not treat as attacker-controlled plaintext.
|
|
5
|
+
FRAMEWORK_PARAMS = %w[
|
|
6
|
+
authenticity_token commit button _method utf8
|
|
7
|
+
spamtrap_timestamp spamtrap_nonce spamtrap_nonce_id spamtrap_js
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
# Captcha widgets inject their own plaintext param the app can't encrypt: Google
|
|
11
|
+
# reCAPTCHA v2/v3, hCaptcha, and Cloudflare Turnstile.
|
|
12
|
+
CAPTCHA_PARAMS = %w[g-recaptcha-response h-captcha-response cf-turnstile-response].freeze
|
|
13
|
+
|
|
4
14
|
def self.included(base)
|
|
5
15
|
base.extend ActsAsMethods
|
|
6
16
|
end
|
|
@@ -9,79 +19,263 @@ module Spamtrap::Controller
|
|
|
9
19
|
def spamtrap(honeypot = 'spamtrap', options = {}, &block)
|
|
10
20
|
# Capture explicit per-call values; use sentinel so globals are read
|
|
11
21
|
# at request time rather than at class definition time.
|
|
12
|
-
nonce_opt
|
|
13
|
-
timeout_opt
|
|
14
|
-
mutate_opt
|
|
15
|
-
on_trap_opt
|
|
22
|
+
nonce_opt = options.key?(:nonce) ? options.delete(:nonce) : :global
|
|
23
|
+
timeout_opt = options.key?(:nonce_timeout) ? options.delete(:nonce_timeout) : :global
|
|
24
|
+
mutate_opt = options.key?(:mutate) ? options.delete(:mutate) : :global
|
|
25
|
+
on_trap_opt = options.key?(:on_trap) ? options.delete(:on_trap) : :global
|
|
26
|
+
trap_response_opt = options.key?(:trap_response) ? options.delete(:trap_response) : :global
|
|
27
|
+
nonce_bind_ip_opt = options.key?(:nonce_bind_ip) ? options.delete(:nonce_bind_ip) : :global
|
|
28
|
+
min_fill_time_opt = options.key?(:min_fill_time) ? options.delete(:min_fill_time) : :global
|
|
29
|
+
js_proof_opt = options.key?(:js_proof) ? options.delete(:js_proof) : :global
|
|
30
|
+
suspicious_if_opt = options.key?(:suspicious_if) ? options.delete(:suspicious_if) : :global
|
|
16
31
|
|
|
17
32
|
before_action(options) do |controller|
|
|
33
|
+
next unless Spamtrap.enabled
|
|
34
|
+
|
|
18
35
|
controller.instance_eval(&block) if block_given?
|
|
19
36
|
controller.instance_eval do
|
|
20
|
-
nonce_enabled
|
|
21
|
-
nonce_timeout
|
|
22
|
-
mutate_enabled
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
nonce_enabled = nonce_opt == :global ? Spamtrap.nonce : nonce_opt
|
|
38
|
+
nonce_timeout = timeout_opt == :global ? Spamtrap.nonce_timeout : timeout_opt
|
|
39
|
+
mutate_enabled = mutate_opt == :global ? Spamtrap.mutate : mutate_opt
|
|
40
|
+
bind_ip = nonce_bind_ip_opt == :global ? Spamtrap.nonce_bind_ip : nonce_bind_ip_opt
|
|
41
|
+
min_fill_time = min_fill_time_opt == :global ? Spamtrap.min_fill_time : min_fill_time_opt
|
|
42
|
+
js_proof_enabled = js_proof_opt == :global ? Spamtrap.js_proof : js_proof_opt
|
|
43
|
+
suspicious_hook = suspicious_if_opt == :global ? Spamtrap.suspicious_if : suspicious_if_opt
|
|
44
|
+
# true and :strict both trap plaintext keys; :lenient only remaps.
|
|
45
|
+
strict = mutate_enabled && mutate_enabled != :lenient
|
|
46
|
+
context = Spamtrap.token_context_for(request)
|
|
47
|
+
|
|
48
|
+
remap = spamtrap_remap_params(Spamtrap.mutation_timeout, context) if mutate_enabled
|
|
49
|
+
|
|
50
|
+
if Spamtrap.honeypot_fields(honeypot).each_value.any? { |field| params[field].present? }
|
|
51
|
+
spamtrap_trap(:honeypot, honeypot, on_trap_opt, trap_response_opt)
|
|
52
|
+
# :expired traps in lenient mode too, or a remapped-but-stale token would otherwise be accepted.
|
|
53
|
+
elsif mutate_enabled && remap[0] == :expired
|
|
54
|
+
spamtrap_trap(:mutation_expired, honeypot, on_trap_opt, trap_response_opt)
|
|
55
|
+
elsif strict && (reason = spamtrap_strict_violation(remap, honeypot))
|
|
56
|
+
spamtrap_trap(reason, honeypot, on_trap_opt, trap_response_opt)
|
|
57
|
+
elsif nonce_enabled && (reason = spamtrap_nonce_failure(nonce_timeout, honeypot, nonce_enabled, bind_ip: bind_ip, min_fill_time: min_fill_time, context: context))
|
|
58
|
+
spamtrap_trap(reason, honeypot, on_trap_opt, trap_response_opt)
|
|
59
|
+
# Mutation-only forms: the nonce path above already applied the fill time.
|
|
60
|
+
elsif !nonce_enabled && mutate_enabled && spamtrap_too_fast?(min_fill_time)
|
|
61
|
+
spamtrap_trap(:too_fast, honeypot, on_trap_opt, trap_response_opt)
|
|
62
|
+
elsif js_proof_enabled && (reason = spamtrap_js_proof_failure(honeypot, context: context))
|
|
63
|
+
spamtrap_trap(reason, honeypot, on_trap_opt, trap_response_opt)
|
|
64
|
+
elsif suspicious_hook && spamtrap_content_suspicious?(suspicious_hook)
|
|
65
|
+
spamtrap_trap(:content, honeypot, on_trap_opt, trap_response_opt)
|
|
34
66
|
end
|
|
35
67
|
end
|
|
36
68
|
end
|
|
37
69
|
end
|
|
38
70
|
end
|
|
39
71
|
|
|
40
|
-
def
|
|
72
|
+
def spamtrap_nonce_failure(timeout, honeypot, mode, bind_ip: Spamtrap.nonce_bind_ip, min_fill_time: Spamtrap.min_fill_time, context: nil)
|
|
41
73
|
timestamp = params[:spamtrap_timestamp].to_i
|
|
42
|
-
nonce
|
|
43
|
-
|
|
44
|
-
return
|
|
74
|
+
nonce = params[:spamtrap_nonce].to_s
|
|
75
|
+
nonce_id = params[:spamtrap_nonce_id].to_s
|
|
76
|
+
return :nonce_missing if timestamp.zero? || nonce.blank? || nonce_id.blank?
|
|
45
77
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
78
|
+
now = Time.now.to_i
|
|
79
|
+
# nonce_id doubles as a cache key under single-use mode, so bound its shape.
|
|
80
|
+
return :nonce_invalid unless nonce_id.match?(/\A[0-9a-f]{32}\z/)
|
|
81
|
+
return :nonce_invalid if timestamp - now > Spamtrap.nonce_skew
|
|
82
|
+
return :nonce_expired if now - timestamp > timeout.to_i # checked before the HMAC so a genuine expired token reports as expired
|
|
50
83
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
84
|
+
expected = spamtrap_nonce_digest(timestamp, request.remote_ip, honeypot, nonce_id, bind_ip: bind_ip, context: context)
|
|
85
|
+
unless ActiveSupport::SecurityUtils.secure_compare(nonce, expected)
|
|
86
|
+
# Retry with the previous secret during a rotation window before giving up.
|
|
87
|
+
previous_secret = Spamtrap.previous_secret_key_base
|
|
88
|
+
previous = previous_secret && spamtrap_nonce_digest(timestamp, request.remote_ip, honeypot, nonce_id, bind_ip: bind_ip, secret: previous_secret, context: context)
|
|
89
|
+
return :nonce_invalid unless previous && ActiveSupport::SecurityUtils.secure_compare(nonce, previous)
|
|
90
|
+
end
|
|
54
91
|
|
|
55
|
-
|
|
56
|
-
|
|
92
|
+
# After the HMAC (so a forged timestamp is :nonce_invalid) but before single-use records
|
|
93
|
+
# the id, or a human trapped as too fast could never resubmit the same page.
|
|
94
|
+
return :too_fast if spamtrap_too_fast?(min_fill_time)
|
|
95
|
+
|
|
96
|
+
if mode == :single_use
|
|
97
|
+
key = "spamtrap:nonce:#{nonce_id}"
|
|
98
|
+
stored = Spamtrap.nonce_store.write(key, 1, unless_exist: true, expires_in: timeout.to_i + Spamtrap.nonce_skew)
|
|
99
|
+
if Spamtrap.nonce_store.is_a?(ActiveSupport::Cache::NullStore)
|
|
100
|
+
Spamtrap::Controller.warn_null_store_once
|
|
101
|
+
elsif !stored
|
|
102
|
+
return :nonce_replayed
|
|
103
|
+
end
|
|
104
|
+
end
|
|
57
105
|
|
|
58
|
-
spamtrap_remap_hash(params, salt_bytes)
|
|
59
|
-
rescue ArgumentError
|
|
60
106
|
nil
|
|
61
107
|
end
|
|
62
108
|
|
|
63
|
-
|
|
109
|
+
# Warned once per process: a NullStore accepts every write, so it can never catch a replay.
|
|
110
|
+
def self.warn_null_store_once
|
|
111
|
+
return if @null_store_warned
|
|
112
|
+
@null_store_warned = true
|
|
113
|
+
Rails.logger.warn 'Spamtrap: nonce: :single_use needs a real Spamtrap.nonce_store; NullStore cannot detect replays.'
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def spamtrap_too_fast?(min_fill_time)
|
|
117
|
+
return false unless min_fill_time && min_fill_time.to_i.positive?
|
|
118
|
+
|
|
119
|
+
ts = params[:spamtrap_timestamp].to_i
|
|
120
|
+
ts.positive? && Time.now.to_i - ts < min_fill_time.to_i
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns [:missing, []], [:expired, leftovers], or [:ok, leftovers], where leftovers is
|
|
124
|
+
# an array of [key, depth] for every submitted key that did not decrypt (depth 0 == top
|
|
125
|
+
# level of params), letting spamtrap_strict_violation tell :missing apart from a real
|
|
126
|
+
# plaintext violation. :expired still remaps every key (no additional staleness bound on
|
|
127
|
+
# the decrypt itself) so on_trap/trap_response can re-render the form with the user's
|
|
128
|
+
# input; the action never runs, so an old token can only help show that input back, and
|
|
129
|
+
# key rotation bounds how far back it can reach.
|
|
130
|
+
def spamtrap_remap_params(timeout, context = nil)
|
|
131
|
+
ts = params[:spamtrap_timestamp].to_i
|
|
132
|
+
return [:missing, []] if ts.zero?
|
|
133
|
+
|
|
134
|
+
now = Time.now.to_i
|
|
135
|
+
status = now - ts > timeout.to_i || ts - now > Spamtrap.nonce_skew ? :expired : :ok
|
|
136
|
+
[status, spamtrap_remap_hash(params, spamtrap_aad(ts, context))]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def spamtrap_remap_hash(hash, aad, depth = 0)
|
|
140
|
+
leftovers = []
|
|
64
141
|
hash.each_key.to_a.each do |key|
|
|
65
|
-
|
|
142
|
+
key_str = key.to_s
|
|
143
|
+
# Rails date/time selects post <token>(1i)/(2i)/(3i); decrypt the base, keep the suffix.
|
|
144
|
+
base, suffix = key_str.match(/\A(.+?)(\(\d+[a-z]\))\z/)&.captures
|
|
145
|
+
real = spamtrap_decrypt_field(base || key_str, aad)
|
|
66
146
|
if real
|
|
67
|
-
|
|
68
|
-
|
|
147
|
+
renamed = suffix ? "#{real}#{suffix}" : real
|
|
148
|
+
hash[renamed] = hash.delete(key)
|
|
149
|
+
key = renamed
|
|
69
150
|
end
|
|
70
151
|
child = hash[key]
|
|
71
|
-
|
|
152
|
+
# A nesting container (object name, or array of them) is structure, not a submitted
|
|
153
|
+
# field name, so it's never itself flagged even when its own key didn't decrypt.
|
|
154
|
+
container = child.is_a?(ActionController::Parameters) ||
|
|
155
|
+
(child.is_a?(Array) && child.any? { |el| el.is_a?(ActionController::Parameters) })
|
|
156
|
+
leftovers << [key.to_s, depth] if real.nil? && !container
|
|
157
|
+
case child
|
|
158
|
+
when ActionController::Parameters
|
|
159
|
+
leftovers.concat(spamtrap_remap_hash(child, aad, depth + 1))
|
|
160
|
+
when Array
|
|
161
|
+
child.each { |el| leftovers.concat(spamtrap_remap_hash(el, aad, depth + 1)) if el.is_a?(ActionController::Parameters) }
|
|
162
|
+
end
|
|
72
163
|
end
|
|
164
|
+
leftovers
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# nil means no violation. :mutation_expired means no timestamp was submitted at all, so
|
|
168
|
+
# blaming a specific field with :plaintext_field would mislead (the :expired case is
|
|
169
|
+
# already handled by the caller before this runs). Otherwise: any leftover below the top
|
|
170
|
+
# level always traps (plaintext smuggled into an otherwise-encrypted nested object); a
|
|
171
|
+
# top-level leftover only traps if it isn't allowlisted.
|
|
172
|
+
def spamtrap_strict_violation(remap, honeypot)
|
|
173
|
+
status, leftovers = remap
|
|
174
|
+
return :mutation_expired if status == :missing
|
|
175
|
+
return :plaintext_field if leftovers.any? { |_key, depth| depth > 0 }
|
|
176
|
+
|
|
177
|
+
# controller/action/id/format are Rails' own routing params, always present and always plaintext.
|
|
178
|
+
allowed = FRAMEWORK_PARAMS + CAPTCHA_PARAMS + Spamtrap.honeypot_fields(honeypot).values +
|
|
179
|
+
request.path_parameters.keys.map(&:to_s) + Spamtrap.allowed_params
|
|
180
|
+
:plaintext_field if leftovers.any? { |key, _depth| !allowed.include?(key) }
|
|
73
181
|
end
|
|
74
182
|
|
|
75
|
-
|
|
183
|
+
# ts.zero? means no timestamp was submitted at all, so there's nothing to bind spamtrap_js to.
|
|
184
|
+
# The token is public in the page source, so without nonce or mutation the timestamp is the
|
|
185
|
+
# only thing that expires it; bound its age like a nonce.
|
|
186
|
+
def spamtrap_js_proof_failure(honeypot, context: nil)
|
|
187
|
+
ts = params[:spamtrap_timestamp].to_i
|
|
188
|
+
js = params[:spamtrap_js].to_s
|
|
189
|
+
return :no_js if ts.zero? || js.blank?
|
|
190
|
+
|
|
191
|
+
now = Time.now.to_i
|
|
192
|
+
return :no_js if now - ts > Spamtrap.nonce_timeout.to_i || ts - now > Spamtrap.nonce_skew
|
|
193
|
+
|
|
194
|
+
expected = spamtrap_js_digest(ts, honeypot, context: context)
|
|
195
|
+
return nil if ActiveSupport::SecurityUtils.secure_compare(js, expected)
|
|
196
|
+
|
|
197
|
+
# Retry with the previous secret during a rotation window before giving up.
|
|
198
|
+
previous_secret = Spamtrap.previous_secret_key_base
|
|
199
|
+
previous = previous_secret && spamtrap_js_digest(ts, honeypot, secret: previous_secret, context: context)
|
|
200
|
+
return nil if previous && ActiveSupport::SecurityUtils.secure_compare(js, previous)
|
|
201
|
+
|
|
202
|
+
:no_js
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A raising hook is treated as not suspicious (fail open); a bug in an app's own
|
|
206
|
+
# keyword regex must not end up trapping every visitor.
|
|
207
|
+
def spamtrap_content_suspicious?(callable)
|
|
208
|
+
!!spamtrap_call(callable, { params: params, request: request, controller: self }, positional: params)
|
|
209
|
+
rescue StandardError => e
|
|
210
|
+
Rails.logger.error "Spamtrap suspicious_if callback raised: #{e.class}: #{e.message}"
|
|
211
|
+
false
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Logs, invokes on_trap, then applies the trap response unless the callback already
|
|
215
|
+
# rendered/redirected (performed? short-circuits so a custom on_trap response wins).
|
|
216
|
+
def spamtrap_trap(reason, honeypot, on_trap_opt, trap_response_opt)
|
|
217
|
+
Rails.logger.warn "Spamtrap #{reason} from #{request.remote_ip}."
|
|
218
|
+
payload = { reason: reason, honeypot: honeypot.to_s, controller: controller_path, action: action_name, ip: request.remote_ip, request: request }
|
|
219
|
+
ActiveSupport::Notifications.instrument('trap.spamtrap', payload) { }
|
|
220
|
+
spamtrap_invoke_on_trap(reason, on_trap_opt, honeypot)
|
|
221
|
+
return if performed?
|
|
222
|
+
|
|
223
|
+
setting = trap_response_opt == :global ? Spamtrap.trap_response : trap_response_opt
|
|
224
|
+
spamtrap_render_trap(reason, setting)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# setting: a Hash keyed by reason (falls back to :default, then :head), a callable
|
|
228
|
+
# (controller, reason), or one of :head/:no_content/:unprocessable/:redirect_back.
|
|
229
|
+
def spamtrap_render_trap(reason, setting)
|
|
230
|
+
if setting.is_a?(Hash)
|
|
231
|
+
spamtrap_render_trap(reason, setting[reason] || setting[:default] || :head)
|
|
232
|
+
elsif setting.respond_to?(:call)
|
|
233
|
+
setting.call(self, reason)
|
|
234
|
+
head 200 unless performed? # callable didn't render/redirect, so fall back
|
|
235
|
+
elsif setting == :head
|
|
236
|
+
head 200
|
|
237
|
+
elsif setting == :no_content
|
|
238
|
+
head 204
|
|
239
|
+
elsif setting == :unprocessable
|
|
240
|
+
head 422
|
|
241
|
+
elsif setting == :redirect_back
|
|
242
|
+
redirect_back_or_to('/', status: :see_other)
|
|
243
|
+
else
|
|
244
|
+
raise ArgumentError, "Unknown Spamtrap.trap_response: #{setting.inspect}"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def spamtrap_invoke_on_trap(reason, on_trap_opt, honeypot)
|
|
76
249
|
callback = on_trap_opt == :global ? Spamtrap.on_trap : on_trap_opt
|
|
77
250
|
return unless callback.respond_to?(:call)
|
|
78
|
-
|
|
251
|
+
|
|
252
|
+
payload = { reason: reason, request: request, controller: self, honeypot: honeypot.to_s, params: params }
|
|
253
|
+
spamtrap_call(callback, payload, positional: payload)
|
|
79
254
|
rescue StandardError => e
|
|
80
255
|
Rails.logger.error "Spamtrap on_trap callback raised: #{e.class}: #{e.message}"
|
|
81
256
|
end
|
|
82
257
|
|
|
83
|
-
|
|
258
|
+
# Dispatches a user-supplied callable per its declared parameters, shared by on_trap and
|
|
259
|
+
# suspicious_if: kwrest gets the whole payload, keywords get a matching slice, a bare
|
|
260
|
+
# positional gets `positional` (the payload for on_trap, params for suspicious_if).
|
|
261
|
+
def spamtrap_call(callable, payload, positional:)
|
|
262
|
+
# Procs report :parameters directly; other callables (methods, etc.) need it via #method(:call).
|
|
263
|
+
parameters = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
|
|
84
264
|
|
|
85
|
-
|
|
265
|
+
if parameters.any? { |type, _| type == :keyrest }
|
|
266
|
+
callable.call(**payload)
|
|
267
|
+
elsif parameters.any? { |type, _| type == :key || type == :keyreq }
|
|
268
|
+
names = parameters.select { |type, _| type == :key || type == :keyreq }.map { |_, name| name }
|
|
269
|
+
callable.call(**payload.slice(*names))
|
|
270
|
+
elsif parameters.any? { |type, _| type == :req || type == :opt || type == :rest }
|
|
271
|
+
callable.call(positional)
|
|
272
|
+
else
|
|
273
|
+
callable.call
|
|
274
|
+
end
|
|
275
|
+
end
|
|
86
276
|
|
|
87
|
-
|
|
277
|
+
private :spamtrap_nonce_failure, :spamtrap_too_fast?, :spamtrap_remap_params, :spamtrap_remap_hash,
|
|
278
|
+
:spamtrap_strict_violation, :spamtrap_trap, :spamtrap_render_trap, :spamtrap_invoke_on_trap,
|
|
279
|
+
:spamtrap_call, :spamtrap_js_proof_failure, :spamtrap_content_suspicious?
|
|
280
|
+
|
|
281
|
+
end
|
data/lib/spamtrap/crypto.rb
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
require 'base64'
|
|
2
|
+
require 'securerandom'
|
|
3
|
+
require 'ipaddr'
|
|
2
4
|
|
|
3
5
|
module Spamtrap
|
|
4
6
|
module Crypto
|
|
@@ -7,40 +9,105 @@ module Spamtrap
|
|
|
7
9
|
NONCE_LEN = 12
|
|
8
10
|
TAG_LEN = 16
|
|
9
11
|
|
|
12
|
+
class << self
|
|
13
|
+
# HKDF-derives {mutation:, nonce:} keys, memoised per secret at module level so each
|
|
14
|
+
# request doesn't re-derive them; previous_secret_key_base is what keeps a rotation from
|
|
15
|
+
# orphaning in-flight forms.
|
|
16
|
+
def keys_for(secret)
|
|
17
|
+
@keys_cache ||= {}
|
|
18
|
+
@keys_cache[secret] ||= begin
|
|
19
|
+
derived = {
|
|
20
|
+
mutation: OpenSSL::KDF.hkdf(secret, salt: 'spamtrap-mutation', info: '', length: KEY_LEN, hash: 'SHA256'),
|
|
21
|
+
nonce: OpenSSL::KDF.hkdf(secret, salt: 'spamtrap-nonce', info: '', length: 32, hash: 'SHA256')
|
|
22
|
+
}
|
|
23
|
+
# Cap at the two most recent secrets so a test suite rotating secrets can't grow this unboundedly.
|
|
24
|
+
@keys_cache.delete(@keys_cache.keys.first) while @keys_cache.size > 1
|
|
25
|
+
derived
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The name a mutated token decrypts to under each secret, skipping the tag and AAD check
|
|
30
|
+
# because Rails filters params before token_context can be computed. Unverified: fit only
|
|
31
|
+
# for deciding whether to mask a logged value, never for reading params.
|
|
32
|
+
def unverified_field_names(token)
|
|
33
|
+
raw = Base64.urlsafe_decode64(token)
|
|
34
|
+
return [] unless raw.bytesize > NONCE_LEN + TAG_LEN
|
|
35
|
+
|
|
36
|
+
[Spamtrap.secret_key_base, Spamtrap.previous_secret_key_base].compact.filter_map do |secret|
|
|
37
|
+
cipher = OpenSSL::Cipher.new(CIPHER).decrypt
|
|
38
|
+
cipher.key = keys_for(secret)[:mutation]
|
|
39
|
+
cipher.iv = raw[0, NONCE_LEN]
|
|
40
|
+
name = cipher.update(raw[NONCE_LEN...-TAG_LEN]).force_encoding(Encoding::UTF_8)
|
|
41
|
+
name if name.valid_encoding? # a wrong secret yields random bytes, rarely valid UTF-8
|
|
42
|
+
end
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
10
48
|
private
|
|
11
49
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
salt: 'spamtrap-mutation',
|
|
16
|
-
info: '',
|
|
17
|
-
length: KEY_LEN,
|
|
18
|
-
hash: 'SHA256'
|
|
19
|
-
)
|
|
50
|
+
# Appended only when context is non-nil, so the default AAD/message shape is unchanged.
|
|
51
|
+
def spamtrap_aad(timestamp, context)
|
|
52
|
+
context.nil? ? timestamp.to_s : "#{timestamp}:#{context}"
|
|
20
53
|
end
|
|
21
54
|
|
|
22
|
-
|
|
55
|
+
# aad binds the token to the render timestamp so it can't be replayed under a different one.
|
|
56
|
+
def spamtrap_encrypt_field(field_name, aad)
|
|
23
57
|
cipher = OpenSSL::Cipher.new(CIPHER)
|
|
24
58
|
cipher.encrypt
|
|
25
|
-
cipher.key =
|
|
26
|
-
cipher.iv =
|
|
59
|
+
cipher.key = Spamtrap::Crypto.keys_for(Spamtrap.secret_key_base)[:mutation]
|
|
60
|
+
cipher.iv = iv = SecureRandom.bytes(NONCE_LEN)
|
|
61
|
+
cipher.auth_data = aad.to_s
|
|
27
62
|
ct = cipher.update(field_name.to_s) + cipher.final
|
|
28
|
-
Base64.urlsafe_encode64(ct + cipher.auth_tag(TAG_LEN), padding: false)
|
|
63
|
+
Base64.urlsafe_encode64(iv + ct + cipher.auth_tag(TAG_LEN), padding: false)
|
|
29
64
|
end
|
|
30
65
|
|
|
31
|
-
|
|
66
|
+
# Tries the current secret, then the previous one (rotation window), before giving up.
|
|
67
|
+
def spamtrap_decrypt_field(token, aad)
|
|
32
68
|
raw = Base64.urlsafe_decode64(token)
|
|
33
|
-
return nil
|
|
34
|
-
|
|
69
|
+
return nil unless raw.bytesize > NONCE_LEN + TAG_LEN
|
|
70
|
+
|
|
71
|
+
[Spamtrap.secret_key_base, Spamtrap.previous_secret_key_base].compact.each do |secret|
|
|
72
|
+
result = spamtrap_decrypt_raw(raw, aad, secret)
|
|
73
|
+
return result if result
|
|
74
|
+
end
|
|
75
|
+
nil
|
|
76
|
+
rescue ArgumentError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def spamtrap_decrypt_raw(raw, aad, secret)
|
|
81
|
+
iv = raw[0, NONCE_LEN]
|
|
82
|
+
ct = raw[NONCE_LEN...-TAG_LEN]
|
|
35
83
|
tag = raw[-TAG_LEN..]
|
|
36
84
|
cipher = OpenSSL::Cipher.new(CIPHER)
|
|
37
85
|
cipher.decrypt
|
|
38
|
-
cipher.key
|
|
39
|
-
cipher.iv
|
|
40
|
-
cipher.
|
|
86
|
+
cipher.key = Spamtrap::Crypto.keys_for(secret)[:mutation]
|
|
87
|
+
cipher.iv = iv
|
|
88
|
+
cipher.auth_data = aad.to_s
|
|
89
|
+
cipher.auth_tag = tag
|
|
41
90
|
(cipher.update(ct) + cipher.final).to_sym
|
|
42
|
-
rescue OpenSSL::Cipher::CipherError
|
|
91
|
+
rescue OpenSSL::Cipher::CipherError
|
|
43
92
|
nil
|
|
44
93
|
end
|
|
94
|
+
|
|
95
|
+
# Value the inline script writes into spamtrap_js. Visible in the page source by design:
|
|
96
|
+
# it proves a script ran, not that the client is honest.
|
|
97
|
+
def spamtrap_js_digest(timestamp, honeypot, secret: Spamtrap.secret_key_base, context: nil)
|
|
98
|
+
key = Spamtrap::Crypto.keys_for(secret)[:nonce]
|
|
99
|
+
message = "js:v1:#{timestamp}:#{honeypot}"
|
|
100
|
+
message += ":#{context}" unless context.nil? # appended only when present so the default digest is unchanged
|
|
101
|
+
OpenSSL::HMAC.hexdigest('SHA256', key, message)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# v1: versions the message for future key rotation; honeypot scopes a token to one form.
|
|
105
|
+
# secret defaults to current but the caller retries with previous_secret_key_base on mismatch.
|
|
106
|
+
def spamtrap_nonce_digest(timestamp, ip, honeypot, nonce_id, bind_ip: Spamtrap.nonce_bind_ip, secret: Spamtrap.secret_key_base, context: nil)
|
|
107
|
+
key = Spamtrap::Crypto.keys_for(secret)[:nonce]
|
|
108
|
+
message = "v1:#{timestamp}:#{Spamtrap.normalize_ip(ip, bind_ip)}:#{honeypot}:#{nonce_id}"
|
|
109
|
+
message += ":#{context}" unless context.nil? # appended only when present so the default digest is unchanged
|
|
110
|
+
OpenSSL::HMAC.hexdigest('SHA256', key, message)
|
|
111
|
+
end
|
|
45
112
|
end
|
|
46
113
|
end
|