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
data/lib/spamtrap/helper.rb
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Spamtrap
|
|
4
|
+
# Raised by spamtrap_nonce_fields when there's no request to bind the nonce to
|
|
5
|
+
# (mailers, ApplicationController.render outside a request, etc.).
|
|
6
|
+
class NoRequestError < StandardError; end
|
|
7
|
+
|
|
4
8
|
module FormBuilderMutation
|
|
5
9
|
include Spamtrap::Crypto
|
|
6
10
|
|
|
@@ -9,12 +13,15 @@ module Spamtrap
|
|
|
9
13
|
text_area check_box hidden_field file_field date_field time_field
|
|
10
14
|
datetime_local_field month_field week_field search_field color_field
|
|
11
15
|
range_field select collection_select grouped_collection_select label
|
|
16
|
+
radio_button phone_field datetime_field time_zone_select weekday_select
|
|
17
|
+
collection_check_boxes collection_radio_buttons date_select time_select
|
|
18
|
+
datetime_select rich_text_area
|
|
12
19
|
].freeze
|
|
13
20
|
|
|
14
21
|
MUTABLE_FIELDS.each do |m|
|
|
15
22
|
define_method(m) do |field, *args, &blk|
|
|
16
|
-
if @
|
|
17
|
-
encrypted_field =
|
|
23
|
+
if @spamtrap_timestamp
|
|
24
|
+
encrypted_field = spamtrap_token_for(field)
|
|
18
25
|
|
|
19
26
|
opts =
|
|
20
27
|
if m == :check_box
|
|
@@ -25,49 +32,102 @@ module Spamtrap
|
|
|
25
32
|
|
|
26
33
|
model_value = object.respond_to?(field) ? object.public_send(field) : nil
|
|
27
34
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
html =
|
|
36
|
+
case m
|
|
37
|
+
when :check_box
|
|
38
|
+
spamtrap_apply_model_value(m, opts, model_value)
|
|
39
|
+
opts[:id] = field_id(field) if Spamtrap.stable_ids && !opts.key?(:id)
|
|
40
|
+
super(encrypted_field, opts, *args, &blk)
|
|
41
|
+
when :radio_button
|
|
42
|
+
tag_value = args.first
|
|
43
|
+
spamtrap_apply_model_value(m, opts, model_value, tag_value)
|
|
44
|
+
opts[:id] = spamtrap_radio_id(field, tag_value) if Spamtrap.stable_ids && !opts.key?(:id)
|
|
45
|
+
super(encrypted_field, *args, opts, &blk)
|
|
46
|
+
when :select, :collection_select, :grouped_collection_select, :weekday_select
|
|
47
|
+
# :selected belongs in the inner options hash, not html_options (the last hash).
|
|
48
|
+
# After popping html_options into opts, args.last is the options hash (if present).
|
|
49
|
+
if args.last.is_a?(Hash)
|
|
50
|
+
sel_opts = args.pop.dup
|
|
51
|
+
spamtrap_apply_model_value(m, sel_opts, model_value)
|
|
52
|
+
args.push(sel_opts)
|
|
53
|
+
else
|
|
54
|
+
spamtrap_apply_model_value(m, opts, model_value)
|
|
55
|
+
end
|
|
56
|
+
opts[:id] = field_id(field) if Spamtrap.stable_ids && !opts.key?(:id)
|
|
57
|
+
super(encrypted_field, *args, opts, &blk)
|
|
58
|
+
when :time_zone_select
|
|
59
|
+
# Unlike the others above, priority_zones sits before options; pad it back in
|
|
60
|
+
# when the caller omitted it entirely so opts lands as options, not priority_zones.
|
|
61
|
+
if args.last.is_a?(Hash)
|
|
62
|
+
sel_opts = args.pop.dup
|
|
63
|
+
spamtrap_apply_model_value(m, sel_opts, model_value)
|
|
64
|
+
args.push(sel_opts)
|
|
65
|
+
else
|
|
66
|
+
args.push(nil) if args.empty?
|
|
67
|
+
spamtrap_apply_model_value(m, opts, model_value)
|
|
68
|
+
end
|
|
69
|
+
opts[:id] = field_id(field) if Spamtrap.stable_ids && !opts.key?(:id)
|
|
70
|
+
super(encrypted_field, *args, opts, &blk)
|
|
71
|
+
when :collection_check_boxes, :collection_radio_buttons
|
|
72
|
+
# Rails computes each collection item's own <input> id internally; left opaque.
|
|
73
|
+
if args.last.is_a?(Hash)
|
|
74
|
+
sel_opts = args.pop.dup
|
|
75
|
+
spamtrap_apply_model_value(m, sel_opts, model_value)
|
|
76
|
+
args.push(sel_opts)
|
|
77
|
+
else
|
|
78
|
+
spamtrap_apply_model_value(m, opts, model_value)
|
|
79
|
+
end
|
|
80
|
+
super(encrypted_field, *args, opts, &blk)
|
|
81
|
+
when :date_select, :time_select, :datetime_select
|
|
82
|
+
# Rails derives each <select>'s id from its multiparameter suffix (e.g. "(1i)"); left opaque.
|
|
83
|
+
if args.last.is_a?(Hash)
|
|
84
|
+
sel_opts = args.pop.dup
|
|
85
|
+
spamtrap_apply_model_value(m, sel_opts, model_value)
|
|
86
|
+
args.push(sel_opts)
|
|
87
|
+
else
|
|
88
|
+
spamtrap_apply_model_value(m, opts, model_value)
|
|
89
|
+
end
|
|
90
|
+
super(encrypted_field, *args, opts, &blk)
|
|
91
|
+
when :label
|
|
92
|
+
# Preserve the human-readable label text using the real field name
|
|
93
|
+
# before substituting the encrypted form. Mirrors Rails' own
|
|
94
|
+
# resolution order:
|
|
95
|
+
# 1. explicit text argument — kept as-is
|
|
96
|
+
# 2. activerecord.attributes.Model.field (ActiveRecord models)
|
|
97
|
+
# 3. helpers.label.object_name.field (plain objects / form_with)
|
|
98
|
+
# 4. humanize fallback
|
|
99
|
+
# Discard any explicit nil that a subclass may have passed through
|
|
100
|
+
# (e.g. `super(method, nil, options)`) so it does not survive as a
|
|
101
|
+
# spurious positional argument in the final super call.
|
|
102
|
+
args.compact!
|
|
103
|
+
unless args.first.is_a?(String)
|
|
104
|
+
human_text =
|
|
105
|
+
if object.respond_to?(:to_model)
|
|
106
|
+
object.class.human_attribute_name(field.to_s, default: field.to_s.humanize)
|
|
107
|
+
else
|
|
108
|
+
I18n.t(field, scope: [:helpers, :label, object_name], default: field.to_s.humanize)
|
|
109
|
+
end
|
|
110
|
+
args.unshift(human_text)
|
|
111
|
+
end
|
|
112
|
+
opts[:for] = field_id(field) if Spamtrap.stable_ids && !opts.key?(:for)
|
|
113
|
+
super(encrypted_field, *args, opts, &blk)
|
|
114
|
+
when :rich_text_area
|
|
115
|
+
# Action Text may not be loaded, so FormBuilder won't define this method at all;
|
|
116
|
+
# `super` would then raise a confusing "super: no superclass method" instead of
|
|
117
|
+
# a normal NoMethodError naming the method the caller actually tried to call.
|
|
118
|
+
spamtrap_apply_model_value(m, opts, model_value)
|
|
119
|
+
if defined?(super)
|
|
120
|
+
super(encrypted_field, *args, opts, &blk)
|
|
121
|
+
else
|
|
122
|
+
raise NoMethodError, "undefined method `#{m}' for an instance of #{self.class}"
|
|
123
|
+
end
|
|
40
124
|
else
|
|
41
|
-
opts
|
|
125
|
+
spamtrap_apply_model_value(m, opts, model_value)
|
|
126
|
+
opts[:id] = field_id(field) if Spamtrap.stable_ids && !opts.key?(:id)
|
|
127
|
+
super(encrypted_field, *args, opts, &blk)
|
|
42
128
|
end
|
|
43
129
|
|
|
44
|
-
|
|
45
|
-
when :label
|
|
46
|
-
# Preserve the human-readable label text using the real field name
|
|
47
|
-
# before substituting the encrypted form. Mirrors Rails' own
|
|
48
|
-
# resolution order:
|
|
49
|
-
# 1. explicit text argument — kept as-is
|
|
50
|
-
# 2. activerecord.attributes.Model.field (ActiveRecord models)
|
|
51
|
-
# 3. helpers.label.object_name.field (plain objects / form_with)
|
|
52
|
-
# 4. humanize fallback
|
|
53
|
-
# Discard any explicit nil that a subclass may have passed through
|
|
54
|
-
# (e.g. `super(method, nil, options)`) so it does not survive as a
|
|
55
|
-
# spurious positional argument in the final super call.
|
|
56
|
-
args.compact!
|
|
57
|
-
unless args.first.is_a?(String)
|
|
58
|
-
human_text =
|
|
59
|
-
if object.respond_to?(:to_model)
|
|
60
|
-
object.class.human_attribute_name(field.to_s, default: field.to_s.humanize)
|
|
61
|
-
else
|
|
62
|
-
I18n.t(field, scope: [:helpers, :label, object_name], default: field.to_s.humanize)
|
|
63
|
-
end
|
|
64
|
-
args.unshift(human_text)
|
|
65
|
-
end
|
|
66
|
-
super(encrypted_field, *args, opts, &blk)
|
|
67
|
-
else
|
|
68
|
-
opts[:value] = model_value unless opts.key?(:value)
|
|
69
|
-
super(encrypted_field, *args, opts, &blk)
|
|
70
|
-
end
|
|
130
|
+
spamtrap_error_wrap(html, field)
|
|
71
131
|
else
|
|
72
132
|
super(field, *args, &blk)
|
|
73
133
|
end
|
|
@@ -76,51 +136,174 @@ module Spamtrap
|
|
|
76
136
|
|
|
77
137
|
def initialize(object_name, object, template, options)
|
|
78
138
|
super
|
|
79
|
-
@
|
|
139
|
+
@spamtrap_options = options[:spamtrap] if options[:spamtrap].is_a?(Hash)
|
|
140
|
+
@spamtrap_timestamp = options[:spamtrap_timestamp] if options[:spamtrap_timestamp]
|
|
141
|
+
@spamtrap_tokens = options[:spamtrap_tokens] if options[:spamtrap_tokens]
|
|
142
|
+
# A fields_for child inherits its parent's already-computed context (may legitimately be nil).
|
|
143
|
+
@spamtrap_context = options[:spamtrap_context] if options.key?(:spamtrap_context)
|
|
144
|
+
# Mint the shared timestamp up front so mutation works no matter where f.spamtrap
|
|
145
|
+
# is called in the form (or if it's never called at all).
|
|
146
|
+
@spamtrap_timestamp ||= Time.now.to_i if @spamtrap_options && @spamtrap_options[:mutate]
|
|
80
147
|
end
|
|
81
148
|
|
|
82
149
|
def fields_for(record_name, record_object = nil, fields_options = {}, &block)
|
|
83
|
-
if @
|
|
150
|
+
if @spamtrap_timestamp
|
|
151
|
+
# Children share the parent's token cache: indexless `items[][name]` arrays only split
|
|
152
|
+
# into separate records when the same field name repeats as the same key. spamtrap_context
|
|
153
|
+
# is computed here (not left lazy) so a child never needs its own request.
|
|
154
|
+
shared = { spamtrap_timestamp: @spamtrap_timestamp, spamtrap_tokens: (@spamtrap_tokens ||= {}), spamtrap_context: spamtrap_context }
|
|
84
155
|
if record_object.is_a?(Hash) && record_object.extractable_options?
|
|
85
|
-
record_object = record_object.merge(
|
|
156
|
+
record_object = record_object.merge(shared)
|
|
86
157
|
else
|
|
87
|
-
fields_options = fields_options.merge(
|
|
158
|
+
fields_options = fields_options.merge(shared)
|
|
88
159
|
end
|
|
89
160
|
end
|
|
90
161
|
super(record_name, record_object, fields_options, &block)
|
|
91
162
|
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
# One token per field name per render (shared with fields_for children), or a fresh IV per
|
|
167
|
+
# call would make <label for> and <input id> mismatch and split indexless array records.
|
|
168
|
+
def spamtrap_token_for(field)
|
|
169
|
+
@spamtrap_tokens ||= {}
|
|
170
|
+
@spamtrap_tokens[field.to_s] ||= spamtrap_encrypt_field(field.to_s, spamtrap_aad(@spamtrap_timestamp, spamtrap_context))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Memoized per builder (nil is a legitimate result when Spamtrap.token_context is unset),
|
|
174
|
+
# so every token this builder mints — mutation, nonce, js proof — binds to the same context.
|
|
175
|
+
def spamtrap_context
|
|
176
|
+
return @spamtrap_context if defined?(@spamtrap_context)
|
|
177
|
+
|
|
178
|
+
@spamtrap_context =
|
|
179
|
+
if Spamtrap.token_context
|
|
180
|
+
unless @template.respond_to?(:request) && @template.request
|
|
181
|
+
raise Spamtrap::NoRequestError, 'Spamtrap.token_context needs a request; render the form inside a request or unset Spamtrap.token_context'
|
|
182
|
+
end
|
|
183
|
+
Spamtrap.token_context_for(@template.request)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Fills in the key each helper reads its pre-filled state from (:value, :checked,
|
|
188
|
+
# :selected...), unless the caller already gave one explicitly.
|
|
189
|
+
def spamtrap_apply_model_value(m, target, model_value, tag_value = nil)
|
|
190
|
+
# Some tags (Rails 8.1 date/time fields, time_zone_select, weekday_select) read the
|
|
191
|
+
# model value eagerly via the encrypted method name; this tells Rails to return nil
|
|
192
|
+
# instead of raising. Rails deletes the key, so it never reaches the HTML.
|
|
193
|
+
target[:allow_method_names_outside_object] = true unless target.key?(:allow_method_names_outside_object)
|
|
194
|
+
|
|
195
|
+
case m
|
|
196
|
+
when :check_box
|
|
197
|
+
target[:checked] = !!model_value unless target.key?(:checked)
|
|
198
|
+
when :radio_button
|
|
199
|
+
target[:checked] = (model_value.to_s == tag_value.to_s) unless target.key?(:checked)
|
|
200
|
+
when :select, :collection_select, :grouped_collection_select, :weekday_select
|
|
201
|
+
target[:selected] = model_value unless target.key?(:selected)
|
|
202
|
+
when :time_zone_select
|
|
203
|
+
# Unlike the others, TimeZoneSelect falls back to :default, not :selected.
|
|
204
|
+
target[:default] = model_value unless target.key?(:default)
|
|
205
|
+
when :collection_check_boxes, :collection_radio_buttons
|
|
206
|
+
target[:checked] = model_value unless target.key?(:checked)
|
|
207
|
+
when :date_select, :time_select, :datetime_select
|
|
208
|
+
target[:selected] = model_value unless target.key?(:selected) || target.key?(:default) || model_value.nil?
|
|
209
|
+
else
|
|
210
|
+
target[:value] = model_value unless target.key?(:value)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Mirrors ActionView::Helpers::Tags::Base#sanitized_value so a radio button's id is
|
|
215
|
+
# identical to what an unmutated render would produce, just rooted at the real field name.
|
|
216
|
+
def spamtrap_radio_id(field, tag_value)
|
|
217
|
+
suffix = tag_value.to_s.gsub(/[\s.]/, '_').gsub(/[^-[[:word:]]]/, '').downcase
|
|
218
|
+
field_id(field, suffix)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Rails looks up object.errors[method] using the (encrypted) method name it was given,
|
|
222
|
+
# which never has errors for a mutated field, so field_with_errors silently stops
|
|
223
|
+
# applying. Replicate it here using the real field name instead.
|
|
224
|
+
def spamtrap_error_wrap(html, field)
|
|
225
|
+
return html unless object.respond_to?(:errors) && object.errors[field].present?
|
|
226
|
+
|
|
227
|
+
tag_stub = Struct.new(:object, :method_name, :error_message).new(object, field.to_s, object.errors[field])
|
|
228
|
+
@template.instance_exec(html, tag_stub, &ActionView::Base.field_error_proc)
|
|
229
|
+
end
|
|
92
230
|
end
|
|
93
231
|
end
|
|
94
232
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
233
|
+
# The f.spamtrap helper. Mixed into ActionView::Helpers::FormBuilder by Spamtrap.install!
|
|
234
|
+
# (via the Railtie in a Rails app) rather than by reopening the class at require time.
|
|
235
|
+
module Spamtrap::FormBuilderHelper
|
|
98
236
|
def spamtrap(parameter = 'spamtrap', options = {})
|
|
99
|
-
mutate
|
|
100
|
-
nonce
|
|
101
|
-
options.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
salt_field = @template.hidden_field_tag(
|
|
106
|
-
:spamtrap_mutation_salt, @spamtrap_salt.unpack1('H*')
|
|
107
|
-
)
|
|
108
|
-
end
|
|
237
|
+
mutate = options.key?(:mutate) ? options.delete(:mutate) : spamtrap_option_default(:mutate)
|
|
238
|
+
nonce = options.key?(:nonce) ? options.delete(:nonce) : spamtrap_option_default(:nonce)
|
|
239
|
+
bind_ip = options.key?(:nonce_bind_ip) ? options.delete(:nonce_bind_ip) : spamtrap_option_default(:nonce_bind_ip)
|
|
240
|
+
styles = Array(options.key?(:styles) ? options.delete(:styles) : spamtrap_option_default(:styles))
|
|
241
|
+
js_proof = options.key?(:js_proof) ? options.delete(:js_proof) : spamtrap_option_default(:js_proof)
|
|
242
|
+
options.reverse_merge!(class: 'spamtrap', tabindex: -1, autocomplete: 'off', 'aria-hidden' => true, style: 'display:none')
|
|
109
243
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
244
|
+
# One timestamp per render, shared by mutation tokens, the nonce HMAC, and js_proof, so the
|
|
245
|
+
# hidden field is emitted once no matter which subset of these features is on. Reuse the
|
|
246
|
+
# timestamp already minted at builder construction (spamtrap: { mutate: true }) instead of a fresh one.
|
|
247
|
+
timestamp = @spamtrap_timestamp || (Time.now.to_i if mutate || nonce || js_proof)
|
|
248
|
+
@spamtrap_timestamp = timestamp if mutate
|
|
249
|
+
|
|
250
|
+
spamtrap_honeypot_tags(parameter, styles, options) +
|
|
251
|
+
((mutate || nonce || js_proof) ? @template.hidden_field_tag(:spamtrap_timestamp, timestamp) : ''.html_safe) +
|
|
252
|
+
(nonce ? spamtrap_nonce_fields(timestamp, parameter.to_s, bind_ip) : ''.html_safe) +
|
|
253
|
+
(js_proof ? spamtrap_js_tags(timestamp, parameter.to_s) : ''.html_safe)
|
|
113
254
|
end
|
|
114
255
|
|
|
115
256
|
private
|
|
116
257
|
|
|
117
|
-
def
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
258
|
+
def spamtrap_option_default(key)
|
|
259
|
+
if @spamtrap_options&.key?(key)
|
|
260
|
+
@spamtrap_options[key]
|
|
261
|
+
else
|
|
262
|
+
# :styles has no same-named Spamtrap global; it maps to honeypot_styles instead.
|
|
263
|
+
Spamtrap.public_send(key == :styles ? :honeypot_styles : key)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# One decoy per requested style, all sharing the same hiding options; each decoy's own name
|
|
268
|
+
# is mutated (see spamtrap_token_for) exactly like the classic textarea's is.
|
|
269
|
+
def spamtrap_honeypot_tags(parameter, styles, options)
|
|
270
|
+
field_names = Spamtrap.honeypot_fields(parameter)
|
|
271
|
+
|
|
272
|
+
styles.map do |style|
|
|
273
|
+
raise ArgumentError, "unknown spamtrap honeypot style: #{style.inspect}" unless field_names.key?(style)
|
|
274
|
+
|
|
275
|
+
name = @spamtrap_timestamp ? spamtrap_token_for(field_names[style]) : field_names[style]
|
|
122
276
|
|
|
123
|
-
|
|
277
|
+
case style
|
|
278
|
+
when :textarea then @template.text_area_tag(name, nil, options)
|
|
279
|
+
when :text then @template.text_field_tag(name, nil, options)
|
|
280
|
+
when :checkbox then @template.check_box_tag(name, '1', false, options)
|
|
281
|
+
end
|
|
282
|
+
end.inject(:+)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def spamtrap_nonce_fields(timestamp, honeypot, bind_ip)
|
|
286
|
+
unless @template.respond_to?(:request) && @template.request
|
|
287
|
+
raise Spamtrap::NoRequestError, 'Spamtrap nonce fields need a request; render the form inside a request or pass nonce: false'
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
ip = @template.request.remote_ip
|
|
291
|
+
nonce_id = SecureRandom.hex(16)
|
|
292
|
+
nonce = spamtrap_nonce_digest(timestamp, ip, honeypot, nonce_id, bind_ip: bind_ip, context: spamtrap_context)
|
|
293
|
+
|
|
294
|
+
@template.hidden_field_tag(:spamtrap_nonce_id, nonce_id) +
|
|
124
295
|
@template.hidden_field_tag(:spamtrap_nonce, nonce)
|
|
125
296
|
end
|
|
297
|
+
|
|
298
|
+
# A hidden field only an executing script fills in, plus the inline script that fills it.
|
|
299
|
+
# javascript_tag(nonce: true) attaches a CSP nonce automatically when the app configures one.
|
|
300
|
+
def spamtrap_js_tags(timestamp, honeypot)
|
|
301
|
+
id = "spamtrap_js_#{SecureRandom.hex(4)}"
|
|
302
|
+
digest = spamtrap_js_digest(timestamp, honeypot, context: spamtrap_context)
|
|
303
|
+
# Embedded reversed so a naive regex scrape of the page source won't find it; obfuscation, not security.
|
|
304
|
+
script = %{(function(){var i=document.getElementById("#{id}");if(i){i.value="#{digest.reverse}".split("").reverse().join("");}})();}
|
|
305
|
+
|
|
306
|
+
@template.hidden_field_tag(:spamtrap_js, '', id: id) +
|
|
307
|
+
@template.javascript_tag(script, nonce: true)
|
|
308
|
+
end
|
|
126
309
|
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module Spamtrap
|
|
2
|
+
# Rails logs params before any before_action runs, so mutated field names reach the log still
|
|
3
|
+
# encrypted and never match the app's filter_parameters. The Railtie appends BLOCK to
|
|
4
|
+
# filter_parameters; it decrypts each name and applies the app's own filters to the real one.
|
|
5
|
+
module ParameterFilter
|
|
6
|
+
# base64url of IV + at least one name byte + tag, then a date/time select's (Ni) suffix.
|
|
7
|
+
TOKEN = /\A([A-Za-z0-9_-]{39,})(\(\d+[a-z]\))?\z/
|
|
8
|
+
|
|
9
|
+
# Token key => parent paths, built once per params hash; weak so logged params aren't retained.
|
|
10
|
+
PARENTS = ObjectSpace::WeakMap.new
|
|
11
|
+
|
|
12
|
+
# Rails only calls blocks for leaf values no key filter matched, passing a copy of the
|
|
13
|
+
# value to mutate and the top-level params.
|
|
14
|
+
BLOCK = lambda do |key, value, params|
|
|
15
|
+
# Cheapest checks first: this runs for every leaf param of every request Rails filters.
|
|
16
|
+
next unless Spamtrap.filter_parameters && params.respond_to?(:key?)
|
|
17
|
+
next unless params.key?('spamtrap_timestamp') || params.key?(:spamtrap_timestamp)
|
|
18
|
+
next unless value.is_a?(String) && (token = TOKEN.match(key.to_s))
|
|
19
|
+
|
|
20
|
+
masked = Spamtrap::ParameterFilter.mask(token, value, params)
|
|
21
|
+
value.replace(masked) if masked
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# What the app's filters turn value into at the field's real path, or nil if they leave it.
|
|
26
|
+
# Only leaf names are mutated, so the containers above the token give the real path. A token
|
|
27
|
+
# shared by fields_for children has several parents; masking if any path would is the safe side.
|
|
28
|
+
def mask(token, value, params)
|
|
29
|
+
filter = app_filter
|
|
30
|
+
parents = (PARENTS[params] ||= parent_paths(params)).fetch(token[0], [[]])
|
|
31
|
+
Spamtrap::Crypto.unverified_field_names(token[1]).each do |name|
|
|
32
|
+
parents.each do |parent|
|
|
33
|
+
path = [*parent, "#{name}#{token[2]}"]
|
|
34
|
+
result = path.inject(filter.filter(nest(path, value.dup))) { |node, key| node.is_a?(Hash) ? node[key] : node }
|
|
35
|
+
return result.to_s unless result == value
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The app's filters without BLOCK itself, rebuilt when the list is reassigned or grows.
|
|
42
|
+
def app_filter
|
|
43
|
+
filters = Rails.application.config.filter_parameters
|
|
44
|
+
signature = [filters.object_id, filters.size]
|
|
45
|
+
return @app_filter if @app_filter_signature == signature
|
|
46
|
+
|
|
47
|
+
# Filter before signature, so a concurrent reader never pairs the new signature with the old filter.
|
|
48
|
+
@app_filter = ActiveSupport::ParameterFilter.new(filters.reject { |f| f.equal?(BLOCK) })
|
|
49
|
+
@app_filter_signature = signature
|
|
50
|
+
@app_filter
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Array positions are left out of a path, as Rails leaves them out of dotted filter matching.
|
|
56
|
+
def parent_paths(params)
|
|
57
|
+
paths = Hash.new { |hash, key| hash[key] = [] }
|
|
58
|
+
visit = lambda do |node, path|
|
|
59
|
+
if node.is_a?(Array)
|
|
60
|
+
node.each { |element| visit.call(element, path) }
|
|
61
|
+
elsif node.is_a?(Hash)
|
|
62
|
+
node.each do |key, child|
|
|
63
|
+
paths[key.to_s] |= [path] if TOKEN.match?(key.to_s)
|
|
64
|
+
visit.call(child, [*path, key.to_s])
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
visit.call(params, [])
|
|
69
|
+
paths
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def nest(path, value)
|
|
73
|
+
path.reverse.inject(value) { |inner, key| { key => inner } }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Spamtrap
|
|
2
|
+
class Railtie < Rails::Railtie
|
|
3
|
+
# on_load so the patches land after app initializers, not at require time.
|
|
4
|
+
initializer 'spamtrap.install' do
|
|
5
|
+
ActiveSupport.on_load(:action_controller_base) { Spamtrap.install_controller! }
|
|
6
|
+
ActiveSupport.on_load(:action_controller_api) { Spamtrap.install_controller! }
|
|
7
|
+
ActiveSupport.on_load(:action_view) { Spamtrap.install_form_builder! }
|
|
8
|
+
# A FormBuilder built before any view renders (e.g. in ActionView::TestCase) would
|
|
9
|
+
# miss the on_load hook, so patch now if the form helpers are already loaded.
|
|
10
|
+
Spamtrap.install_form_builder! if Spamtrap.form_builder_loaded?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# After the app's initializers, so one that assigns filter_parameters can't drop the block.
|
|
14
|
+
# Always installed; Spamtrap.filter_parameters switches it at request time.
|
|
15
|
+
initializer 'spamtrap.filter_parameters', after: :load_config_initializers do |app|
|
|
16
|
+
filters = app.config.filter_parameters
|
|
17
|
+
filters << Spamtrap::ParameterFilter::BLOCK unless filters.include?(Spamtrap::ParameterFilter::BLOCK)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require 'securerandom'
|
|
2
|
+
require 'spamtrap/crypto'
|
|
3
|
+
require 'spamtrap/controller'
|
|
4
|
+
|
|
5
|
+
# Not required by lib/spamtrap.rb: apps opt in with `require 'spamtrap/test_helper'`
|
|
6
|
+
# and `include Spamtrap::TestHelper` in their own test cases.
|
|
7
|
+
module Spamtrap
|
|
8
|
+
module TestHelper
|
|
9
|
+
# Exposes Crypto's private methods so tests can mint/verify tokens without reimplementing them.
|
|
10
|
+
class TokenHelper
|
|
11
|
+
include Spamtrap::Crypto
|
|
12
|
+
public :spamtrap_encrypt_field, :spamtrap_decrypt_field, :spamtrap_nonce_digest, :spamtrap_js_digest, :spamtrap_aad
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def spamtrap_token(name, timestamp, context: nil)
|
|
16
|
+
helper = TokenHelper.new
|
|
17
|
+
helper.spamtrap_encrypt_field(name.to_s, helper.spamtrap_aad(timestamp, context))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def spamtrap_decrypt(token, timestamp, context: nil)
|
|
21
|
+
helper = TokenHelper.new
|
|
22
|
+
helper.spamtrap_decrypt_field(token, helper.spamtrap_aad(timestamp, context))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# at defaults 5s in the past so Spamtrap.min_fill_time (default 1s) doesn't trap these tokens.
|
|
26
|
+
def spamtrap_nonce_params(honeypot:, ip:, at: Time.now.to_i - 5, nonce_id: SecureRandom.hex(16), bind_ip: Spamtrap.nonce_bind_ip, context: nil)
|
|
27
|
+
{
|
|
28
|
+
spamtrap_timestamp: at,
|
|
29
|
+
spamtrap_nonce_id: nonce_id,
|
|
30
|
+
spamtrap_nonce: TokenHelper.new.spamtrap_nonce_digest(at, ip, honeypot, nonce_id, bind_ip: bind_ip, context: context)
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The value the inline js_proof script would write into spamtrap_js for a page rendered at `at`.
|
|
35
|
+
def spamtrap_js_param(honeypot:, at:, context: nil)
|
|
36
|
+
{ spamtrap_js: TokenHelper.new.spamtrap_js_digest(at, honeypot, context: context) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Builds the params hash an app's own controller test merges into its `post` params:
|
|
40
|
+
# the honeypot field, plus whatever spamtrap_timestamp/nonce/js_proof fields the declared
|
|
41
|
+
# options require, so the request clears the whole gauntlet in one call. `fields:` is
|
|
42
|
+
# merged in as-is, or through spamtrap_mutate first when `mutate:` is truthy.
|
|
43
|
+
# at defaults 5s in the past so Spamtrap.min_fill_time (default 1s) doesn't trap these tokens.
|
|
44
|
+
def spamtrap_params(honeypot:, ip: '0.0.0.0', nonce: false, mutate: false, js_proof: false, at: Time.now.to_i - 5, bind_ip: Spamtrap.nonce_bind_ip, fields: nil, context: nil)
|
|
45
|
+
params = {}
|
|
46
|
+
if nonce
|
|
47
|
+
params.merge!(spamtrap_nonce_params(honeypot: honeypot, ip: ip, at: at, bind_ip: bind_ip, context: context))
|
|
48
|
+
elsif mutate
|
|
49
|
+
params[:spamtrap_timestamp] = at
|
|
50
|
+
end
|
|
51
|
+
if js_proof
|
|
52
|
+
params[:spamtrap_timestamp] ||= at
|
|
53
|
+
params.merge!(spamtrap_js_param(honeypot: honeypot, at: at, context: context))
|
|
54
|
+
end
|
|
55
|
+
params[honeypot] = ''
|
|
56
|
+
params.merge!(mutate ? spamtrap_mutate(fields, at: at, context: context) : fields) if fields
|
|
57
|
+
params
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Encrypts every field name in `hash` exactly the way the form builder renders them, so
|
|
61
|
+
# the result round-trips through spamtrap_remap_hash under a strict action. A key whose
|
|
62
|
+
# value is a Hash, or an Array of Hashes, is a container (object/fields_for name) and
|
|
63
|
+
# stays plaintext with its value recursed into; every other key, including one whose
|
|
64
|
+
# value is an Array of scalars, is a field and gets encrypted as a whole. A trailing
|
|
65
|
+
# multi-parameter suffix like "(1i)" is preserved after the token. Top-level keys in
|
|
66
|
+
# FRAMEWORK_PARAMS/CAPTCHA_PARAMS are left alone. Accepts string or symbol keys, returns
|
|
67
|
+
# string keys.
|
|
68
|
+
def spamtrap_mutate(hash, at:, context: nil)
|
|
69
|
+
spamtrap_mutate_hash(hash, at, {}, top_level: true, context: context)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# tokens: one token per field name, as the form builder does, so array records stay separate.
|
|
75
|
+
def spamtrap_mutate_hash(hash, at, tokens, top_level: false, context: nil)
|
|
76
|
+
allowlist = Spamtrap::Controller::FRAMEWORK_PARAMS + Spamtrap::Controller::CAPTCHA_PARAMS
|
|
77
|
+
|
|
78
|
+
hash.each_with_object({}) do |(key, value), memo|
|
|
79
|
+
key_str = key.to_s
|
|
80
|
+
|
|
81
|
+
if top_level && allowlist.include?(key_str)
|
|
82
|
+
memo[key_str] = value
|
|
83
|
+
elsif spamtrap_mutate_container?(value)
|
|
84
|
+
memo[key_str] = spamtrap_mutate_container(value, at, tokens, context: context)
|
|
85
|
+
else
|
|
86
|
+
base, suffix = key_str.match(/\A(.+?)(\(\d+[a-z]\))\z/)&.captures
|
|
87
|
+
name = base || key_str
|
|
88
|
+
memo["#{tokens[name] ||= spamtrap_token(name, at, context: context)}#{suffix}"] = value
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def spamtrap_mutate_container?(value)
|
|
94
|
+
value.is_a?(Hash) || (value.is_a?(Array) && value.any? { |el| el.is_a?(Hash) })
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def spamtrap_mutate_container(value, at, tokens, context: nil)
|
|
98
|
+
return spamtrap_mutate_hash(value, at, tokens, context: context) if value.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
value.map { |el| el.is_a?(Hash) ? spamtrap_mutate_hash(el, at, tokens, context: context) : el }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/spamtrap/version.rb
CHANGED