spamtrap 0.4.2 → 0.4.4
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/templates/spamtrap.rb +3 -0
- data/lib/spamtrap/crypto.rb +33 -1
- data/lib/spamtrap/parameter_filter.rb +89 -0
- data/lib/spamtrap/railtie.rb +7 -0
- data/lib/spamtrap/version.rb +1 -1
- data/lib/spamtrap.rb +8 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c77cdc3c296d9d7add4208780853b77a22cc7e3df72c2fe86dc38371394f8d42
|
|
4
|
+
data.tar.gz: e1edf13f46c1e64c38bca0a1b1d31f5a7dd41419f8f45cba7b29a580e1761f5d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 34d2069dd9f92c0c36e56a0b20940c1cae6382cc94cd0c6b54c6609136f58a5ca3e3a26d469111a0527dfed0d5ccfa82d776ae1c2b6464fa91c88898f0d87d49
|
|
7
|
+
data.tar.gz: 249c2fa24da70d78a0a697194cad3a0c0e493b3285199e608ce3fb3667e6cb665078bf27e684cf0d9a18482b76c6501a8f913c688c15465266af37ac0164b9a2
|
|
@@ -41,6 +41,9 @@
|
|
|
41
41
|
# Whether mutated fields keep an id/for derived from the real field name.
|
|
42
42
|
# Spamtrap.stable_ids = true
|
|
43
43
|
|
|
44
|
+
# Apply config.filter_parameters to mutated field names, which the log otherwise shows unfiltered.
|
|
45
|
+
# Spamtrap.filter_parameters = true
|
|
46
|
+
|
|
44
47
|
# Fill time
|
|
45
48
|
|
|
46
49
|
# Minimum seconds between render and submission a human needs; false or 0 disables.
|
data/lib/spamtrap/crypto.rb
CHANGED
|
@@ -9,6 +9,8 @@ module Spamtrap
|
|
|
9
9
|
NONCE_LEN = 12
|
|
10
10
|
TAG_LEN = 16
|
|
11
11
|
|
|
12
|
+
TOKEN_DRAWS = 8 # see spamtrap_encrypt_field
|
|
13
|
+
|
|
12
14
|
class << self
|
|
13
15
|
# HKDF-derives {mutation:, nonce:} keys, memoised per secret at module level so each
|
|
14
16
|
# request doesn't re-derive them; previous_secret_key_base is what keeps a rotation from
|
|
@@ -25,6 +27,24 @@ module Spamtrap
|
|
|
25
27
|
derived
|
|
26
28
|
end
|
|
27
29
|
end
|
|
30
|
+
|
|
31
|
+
# The name a mutated token decrypts to under each secret, skipping the tag and AAD check
|
|
32
|
+
# because Rails filters params before token_context can be computed. Unverified: fit only
|
|
33
|
+
# for deciding whether to mask a logged value, never for reading params.
|
|
34
|
+
def unverified_field_names(token)
|
|
35
|
+
raw = Base64.urlsafe_decode64(token)
|
|
36
|
+
return [] unless raw.bytesize > NONCE_LEN + TAG_LEN
|
|
37
|
+
|
|
38
|
+
[Spamtrap.secret_key_base, Spamtrap.previous_secret_key_base].compact.filter_map do |secret|
|
|
39
|
+
cipher = OpenSSL::Cipher.new(CIPHER).decrypt
|
|
40
|
+
cipher.key = keys_for(secret)[:mutation]
|
|
41
|
+
cipher.iv = raw[0, NONCE_LEN]
|
|
42
|
+
name = cipher.update(raw[NONCE_LEN...-TAG_LEN]).force_encoding(Encoding::UTF_8)
|
|
43
|
+
name if name.valid_encoding? # a wrong secret yields random bytes, rarely valid UTF-8
|
|
44
|
+
end
|
|
45
|
+
rescue ArgumentError
|
|
46
|
+
[]
|
|
47
|
+
end
|
|
28
48
|
end
|
|
29
49
|
|
|
30
50
|
private
|
|
@@ -34,8 +54,20 @@ module Spamtrap
|
|
|
34
54
|
context.nil? ? timestamp.to_s : "#{timestamp}:#{context}"
|
|
35
55
|
end
|
|
36
56
|
|
|
37
|
-
#
|
|
57
|
+
# A token the app's filter_parameters match as it stands (one containing otp or cvv, say) is
|
|
58
|
+
# masked in the log before the real name is checked: 0.38% of draws per field under Rails'
|
|
59
|
+
# generated list. So draw again. Capped because a filter matching every token would never stop.
|
|
38
60
|
def spamtrap_encrypt_field(field_name, aad)
|
|
61
|
+
token = nil
|
|
62
|
+
TOKEN_DRAWS.times do
|
|
63
|
+
token = spamtrap_encrypt_field_once(field_name, aad)
|
|
64
|
+
break unless defined?(Spamtrap::ParameterFilter) && Spamtrap::ParameterFilter.masks_token?(token)
|
|
65
|
+
end
|
|
66
|
+
token
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# aad binds the token to the render timestamp so it can't be replayed under a different one.
|
|
70
|
+
def spamtrap_encrypt_field_once(field_name, aad)
|
|
39
71
|
cipher = OpenSSL::Cipher.new(CIPHER)
|
|
40
72
|
cipher.encrypt
|
|
41
73
|
cipher.key = Spamtrap::Crypto.keys_for(Spamtrap.secret_key_base)[:mutation]
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
# Stands in for a field's value when asking whether the app's filters would mask a token.
|
|
25
|
+
PROBE = 'spamtrap'.freeze
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Whether the app's filters mask a value by the token itself. Rails checks key filters before
|
|
29
|
+
# calling any block, so such a token hides its field whatever the real name. Leaf only, so a
|
|
30
|
+
# dotted filter such as "comment.otp" goes unchecked; it can match only a token's first letters.
|
|
31
|
+
def masks_token?(token)
|
|
32
|
+
return false unless Spamtrap.filter_parameters && defined?(Rails.application) && Rails.application
|
|
33
|
+
|
|
34
|
+
app_filter.filter(token => PROBE)[token] != PROBE
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# What the app's filters turn value into at the field's real path, or nil if they leave it.
|
|
38
|
+
# Only leaf names are mutated, so the containers above the token give the real path. A token
|
|
39
|
+
# shared by fields_for children has several parents; masking if any path would is the safe side.
|
|
40
|
+
def mask(token, value, params)
|
|
41
|
+
filter = app_filter
|
|
42
|
+
parents = (PARENTS[params] ||= parent_paths(params)).fetch(token[0], [[]])
|
|
43
|
+
Spamtrap::Crypto.unverified_field_names(token[1]).each do |name|
|
|
44
|
+
parents.each do |parent|
|
|
45
|
+
path = [*parent, "#{name}#{token[2]}"]
|
|
46
|
+
result = path.inject(filter.filter(nest(path, value.dup))) { |node, key| node.is_a?(Hash) ? node[key] : node }
|
|
47
|
+
return result.to_s unless result == value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The app's filters without BLOCK itself, rebuilt when the list is reassigned or grows.
|
|
54
|
+
def app_filter
|
|
55
|
+
filters = Rails.application.config.filter_parameters
|
|
56
|
+
signature = [filters.object_id, filters.size]
|
|
57
|
+
return @app_filter if @app_filter_signature == signature
|
|
58
|
+
|
|
59
|
+
# Filter before signature, so a concurrent reader never pairs the new signature with the old filter.
|
|
60
|
+
@app_filter = ActiveSupport::ParameterFilter.new(filters.reject { |f| f.equal?(BLOCK) })
|
|
61
|
+
@app_filter_signature = signature
|
|
62
|
+
@app_filter
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Array positions are left out of a path, as Rails leaves them out of dotted filter matching.
|
|
68
|
+
def parent_paths(params)
|
|
69
|
+
paths = Hash.new { |hash, key| hash[key] = [] }
|
|
70
|
+
visit = lambda do |node, path|
|
|
71
|
+
if node.is_a?(Array)
|
|
72
|
+
node.each { |element| visit.call(element, path) }
|
|
73
|
+
elsif node.is_a?(Hash)
|
|
74
|
+
node.each do |key, child|
|
|
75
|
+
paths[key.to_s] |= [path] if TOKEN.match?(key.to_s)
|
|
76
|
+
visit.call(child, [*path, key.to_s])
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
visit.call(params, [])
|
|
81
|
+
paths
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def nest(path, value)
|
|
85
|
+
path.reverse.inject(value) { |inner, key| { key => inner } }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/spamtrap/railtie.rb
CHANGED
|
@@ -9,5 +9,12 @@ module Spamtrap
|
|
|
9
9
|
# miss the on_load hook, so patch now if the form helpers are already loaded.
|
|
10
10
|
Spamtrap.install_form_builder! if Spamtrap.form_builder_loaded?
|
|
11
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
|
|
12
19
|
end
|
|
13
20
|
end
|
data/lib/spamtrap/version.rb
CHANGED
data/lib/spamtrap.rb
CHANGED
|
@@ -63,6 +63,13 @@ module Spamtrap
|
|
|
63
63
|
@enabled.nil? ? true : @enabled
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
attr_writer :filter_parameters
|
|
67
|
+
|
|
68
|
+
# Apply the app's filter_parameters to mutated field names in the log. Default true.
|
|
69
|
+
def filter_parameters
|
|
70
|
+
@filter_parameters.nil? ? true : @filter_parameters
|
|
71
|
+
end
|
|
72
|
+
|
|
66
73
|
attr_writer :nonce_bind_ip
|
|
67
74
|
|
|
68
75
|
# true binds the nonce to the full client IP (default), :prefix to its /24 or /48
|
|
@@ -185,5 +192,6 @@ module Spamtrap
|
|
|
185
192
|
require 'spamtrap/crypto'
|
|
186
193
|
require 'spamtrap/controller'
|
|
187
194
|
require 'spamtrap/helper'
|
|
195
|
+
require 'spamtrap/parameter_filter'
|
|
188
196
|
require 'spamtrap/railtie' if defined?(Rails::Railtie)
|
|
189
197
|
end
|
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.4.
|
|
4
|
+
version: 0.4.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cedric Howe
|
|
@@ -124,6 +124,7 @@ files:
|
|
|
124
124
|
- lib/spamtrap/controller.rb
|
|
125
125
|
- lib/spamtrap/crypto.rb
|
|
126
126
|
- lib/spamtrap/helper.rb
|
|
127
|
+
- lib/spamtrap/parameter_filter.rb
|
|
127
128
|
- lib/spamtrap/railtie.rb
|
|
128
129
|
- lib/spamtrap/test_helper.rb
|
|
129
130
|
- lib/spamtrap/version.rb
|
|
@@ -138,14 +139,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
138
139
|
requirements:
|
|
139
140
|
- - ">="
|
|
140
141
|
- !ruby/object:Gem::Version
|
|
141
|
-
version: 3.
|
|
142
|
+
version: 3.4.0
|
|
142
143
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
143
144
|
requirements:
|
|
144
145
|
- - ">="
|
|
145
146
|
- !ruby/object:Gem::Version
|
|
146
147
|
version: 1.3.6
|
|
147
148
|
requirements: []
|
|
148
|
-
rubygems_version: 4.0.
|
|
149
|
+
rubygems_version: 4.0.22
|
|
149
150
|
specification_version: 4
|
|
150
151
|
summary: Simple spamtrap for spambots.
|
|
151
152
|
test_files: []
|