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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a31cf3397317c7e4fadc471f44ce713dac53bea0701ba50b24a8259a77ed4b58
4
- data.tar.gz: 3c3f3f0119ae22e79a3e327ae5aa1c65b915b906c2bb8b5e3621e536d62ccab2
3
+ metadata.gz: c77cdc3c296d9d7add4208780853b77a22cc7e3df72c2fe86dc38371394f8d42
4
+ data.tar.gz: e1edf13f46c1e64c38bca0a1b1d31f5a7dd41419f8f45cba7b29a580e1761f5d
5
5
  SHA512:
6
- metadata.gz: 9abc563f9854fb5759371ac2973b3071b3cc86c4cab3af2566aed9b491079a2870b1df82e1dd5cf561f107a88786a818c42d294678e454cce86522cdabfe4ae0
7
- data.tar.gz: 6d4b395ebb6d81a2a002919935001a77a7febdf3353fa5556de2ccd29883ecfe5f29e21b63de448ff53ccfb0233b85708547892cd089123a843775a9aace449d
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.
@@ -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
- # aad binds the token to the render timestamp so it can't be replayed under a different one.
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  module Spamtrap
2
2
 
3
- VERSION = '0.4.2'
3
+ VERSION = '0.4.4'
4
4
 
5
5
  end
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.2
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.1.0
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.20
149
+ rubygems_version: 4.0.22
149
150
  specification_version: 4
150
151
  summary: Simple spamtrap for spambots.
151
152
  test_files: []