otori 0.2.0 → 0.4.0

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: dad6a295086cd2eb7abd60f81e264cde80726e6802deec8e185b3d6ddd2bd082
4
- data.tar.gz: b1fe5bab67f24f249b9b45055fa0a632774b99fa93fe0c156235014c9bdf1416
3
+ metadata.gz: 9139cc5240c7900afc1038f7634429d8cac065d9e22c3645e4891dd9f1fd9103
4
+ data.tar.gz: d0adef9a1384d5123b7e304879fb9691ebc35e77e238b39a6348419ac8bde074
5
5
  SHA512:
6
- metadata.gz: a275bbc1f3a7ce4a3f8ae0b6a09b0835aeeca30713254819f747e3e3b06f0c2e8a875a6ca604a8df396ada100ce9518238e8575ec1a06a3bb06e2bec5abe8b5d
7
- data.tar.gz: 7fee9a413435e3efcf65f5196fdffe168c664935c8e1390f06545cb9f650571e47e031f04f3f9f2649f01a0b62a67229a9e7071199bdc2903c3dfe1543e625f1
6
+ metadata.gz: e03586c88e10498d34371303c0d87b09cb8bf643d99e8e30f5b0b19a6ab2851476f6290c547b74d6cfd7c819b281269ccf76d706bc5e42dd03131fa49d4965cf
7
+ data.tar.gz: 44b8afe56864c015efc19ee2a00ede9167f53bfe471ec7e74ef5ec5d05414522c2835c173e3c5005248447b2241d15c8b1c20e38ed5d4981d986e6d8456ab823
data/CHANGELOG.md CHANGED
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-10-13
11
+
12
+ ### Added
13
+
14
+ - `Otori.reason` returns the specific catch reason as a symbol
15
+ (`:filled` when the honeypot field was filled, `:too_fast` when the
16
+ form was submitted before the configured delay elapsed, `nil` when the
17
+ submission looks clean). This lets callers distinguish genuinely
18
+ suspicious submissions from users who merely autofilled and submitted
19
+ faster than the delay allows.
20
+ - The Rails adapter's `on_caught` block now receives the reason symbol
21
+ as its sole argument, so applications can log, flag, or branch on it
22
+ (`honeypot(:website) { |reason| ... }`). Zero-arg blocks continue to
23
+ work unchanged.
24
+ - The Hanami adapter's `on_caught` block now receives the reason symbol
25
+ as a third argument after `request` and `response`. Two-arg blocks
26
+ continue to work unchanged.
27
+
28
+ ### Changed
29
+
30
+ - `Otori.caught?` is now a thin predicate over `Otori.reason`. Its
31
+ return value and session side effects are unchanged.
32
+
33
+ ## [0.3.0] - 2026-10-12
34
+
35
+ ### Changed
36
+
37
+ - `signals_field` now accepts an `id:` attribute. When omitted, a unique id
38
+ of the form `otori-signals-<hex>` is generated per call so multiple
39
+ signals fields can safely coexist on the same page.
40
+
41
+ ### Fixed
42
+
43
+ - `signals_field` no longer relies on
44
+ `document.currentScript.previousElementSibling` to locate its hidden input.
45
+ The input now carries a unique `id` and the tracking script looks it up via
46
+ `document.getElementById`. The previous approach broke when any DOM mutation
47
+ (Turbo cache restoration, injected elements, browser extensions) landed
48
+ between the input and the script, which could cause the signals JSON to be
49
+ written into a neighbouring field (such as a honeypot) and trigger false
50
+ positives. Callers can supply their own `id:` to override the generated
51
+ value, and the script now bails out silently when the input or its form
52
+ cannot be resolved.
53
+
10
54
  ## [0.2.0] - 2026-09-17
11
55
 
12
56
  ### Added
data/README.md CHANGED
@@ -78,6 +78,20 @@ session. `signals_field` renders a hidden input plus the JavaScript that
78
78
  tracks human input. `caught?` checks the submitted form and returns `true`
79
79
  when the request looks like a bot.
80
80
 
81
+ If you want to know _why_ a submission was caught (for logging, flagging,
82
+ or scoring), use `Otori.reason`:
83
+
84
+ ```ruby
85
+ Otori.reason("user[website]", params: request.params, session: request.session)
86
+ # => :filled when the honeypot field was filled
87
+ # => :too_fast when the form was submitted before the delay elapsed
88
+ # => nil when the submission looks clean
89
+ ```
90
+
91
+ `caught?` is implemented as `!reason(...).nil?`. Both methods perform the
92
+ same session timestamp bookkeeping, so pick one and stick with it per
93
+ request.
94
+
81
95
  Field names use standard HTML bracket notation, so `"user[website]"` lives
82
96
  under `params[:user][:website]` once submitted. Flat names like `"note"` work
83
97
  too. Pick whichever fits the surrounding form, the more believable the
@@ -138,10 +152,13 @@ end
138
152
  ```
139
153
 
140
154
  When the honeypot is tripped, the action halts with `204 No Content` by
141
- default. To customize the response, pass a block:
155
+ default. To customize the response, pass a block. The block optionally
156
+ receives the catch reason (`:filled` or `:too_fast`) as a third argument,
157
+ handy for logging or branching:
142
158
 
143
159
  ```ruby
144
- honeypot "user[website]" do |_request, response|
160
+ honeypot "user[website]" do |_request, response, reason|
161
+ SecurityLog.record!(reason: reason)
145
162
  response.flash[:info] = "Moving on..."
146
163
  response.redirect_to "/", status: 303
147
164
  end
@@ -227,10 +244,12 @@ end
227
244
  ```
228
245
 
229
246
  When the honeypot is tripped, the request halts with `:no_content` by default.
230
- To customize the response, pass a block:
247
+ To customize the response, pass a block. The block optionally receives the
248
+ catch reason (`:filled` or `:too_fast`), handy for logging or branching:
231
249
 
232
250
  ```ruby
233
- honeypot :website, only: :create do
251
+ honeypot :website, only: :create do |reason|
252
+ SecurityLog.record!(reason: reason)
234
253
  redirect_to root_path, notice: "Moving on..."
235
254
  end
236
255
  ```
data/lib/otori/form.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cgi"
4
+ require "securerandom"
4
5
 
5
6
  require_relative "validator"
6
7
 
@@ -10,20 +11,6 @@ module Otori
10
11
 
11
12
  HIDDEN_STYLE = "position:absolute;left:-9999px;width:1px;height:1px;pointer-events:none;"
12
13
 
13
- SIGNALS_SCRIPT = <<~JS
14
- (() => {
15
- const s = { m: false, t: false, s: false, k: false, f: false };
16
- const input = document.currentScript.previousElementSibling;
17
- const form = input.form;
18
- form.addEventListener('mousemove', () => s.m = true, { once: true });
19
- form.addEventListener('touchstart', () => s.t = true, { once: true });
20
- form.addEventListener('keydown', () => s.k = true, { once: true });
21
- form.addEventListener('focusin', () => s.f = true, { once: true });
22
- window.addEventListener('scroll', () => s.s = true, { once: true });
23
- form.addEventListener('submit', () => input.value = JSON.stringify(s));
24
- })();
25
- JS
26
-
27
14
  def field(name, session:, **attrs)
28
15
  raise MissingSession unless session_like?(session)
29
16
 
@@ -42,16 +29,41 @@ module Otori
42
29
  end
43
30
 
44
31
  def signals_field(**attrs)
32
+ stringified = stringify_keys(attrs)
33
+ id = stringified["id"] || generate_signals_id
34
+
45
35
  input = tag(:input, {
46
- name: Otori.config.signals_input_name,
47
- type: "hidden"
48
- }.merge(stringify_keys(attrs)))
36
+ "id" => id,
37
+ "name" => Otori.config.signals_input_name,
38
+ "type" => "hidden"
39
+ }.merge(stringified))
49
40
 
50
- "#{input}<script>#{SIGNALS_SCRIPT}</script>"
41
+ "#{input}<script>#{signals_script(id)}</script>"
51
42
  end
52
43
 
53
44
  private
54
45
 
46
+ def generate_signals_id
47
+ "otori-signals-#{SecureRandom.hex(6)}"
48
+ end
49
+
50
+ def signals_script(id)
51
+ <<~JS
52
+ (() => {
53
+ const input = document.getElementById(#{id.to_json});
54
+ if (!input || !input.form) return;
55
+ const form = input.form;
56
+ const s = { m: false, t: false, s: false, k: false, f: false };
57
+ form.addEventListener('mousemove', () => s.m = true, { once: true });
58
+ form.addEventListener('touchstart', () => s.t = true, { once: true });
59
+ form.addEventListener('keydown', () => s.k = true, { once: true });
60
+ form.addEventListener('focusin', () => s.f = true, { once: true });
61
+ window.addEventListener('scroll', () => s.s = true, { once: true });
62
+ form.addEventListener('submit', () => input.value = JSON.stringify(s));
63
+ })();
64
+ JS
65
+ end
66
+
55
67
  def session_like?(object)
56
68
  object.respond_to?(:[]) && object.respond_to?(:[]=)
57
69
  end
data/lib/otori/hanami.rb CHANGED
@@ -16,15 +16,16 @@ module Otori
16
16
  caught_block = on_caught
17
17
 
18
18
  before do |request, response|
19
- next unless Otori.caught?(
19
+ reason = Otori.reason(
20
20
  field_name,
21
21
  params: request.params.to_h,
22
22
  session: request.session,
23
23
  wait: field_wait
24
24
  )
25
+ next unless reason
25
26
 
26
27
  if caught_block
27
- instance_exec(request, response, &caught_block)
28
+ instance_exec(request, response, reason, &caught_block)
28
29
  else
29
30
  halt 204
30
31
  end
data/lib/otori/rails.rb CHANGED
@@ -11,15 +11,11 @@ module Otori
11
11
  filter_options[:except] = except if except
12
12
 
13
13
  before_action(**filter_options) do
14
- next unless Otori.caught?(
15
- name,
16
- params:,
17
- session:,
18
- wait:
19
- )
14
+ reason = Otori.reason(name, params:, session:, wait:)
15
+ next unless reason
20
16
 
21
17
  if on_caught
22
- instance_exec(&on_caught)
18
+ instance_exec(reason, &on_caught)
23
19
  else
24
20
  head :no_content
25
21
  end
data/lib/otori/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Otori
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/otori.rb CHANGED
@@ -31,19 +31,23 @@ module Otori
31
31
  end
32
32
 
33
33
  def caught?(name, params:, session:, wait: nil)
34
+ !reason(name, params:, session:, wait:).nil?
35
+ end
36
+
37
+ def reason(name, params:, session:, wait: nil)
34
38
  wait ||= config.default_delay
35
39
  session_key = config.session_key(name)
36
40
  stored = session_get(session, session_key)
37
41
  filled = Validator.filled?(param_value(params, name))
38
42
  elapsed = Validator.elapsed?(stored&.to_i, wait)
39
43
 
40
- if !filled && elapsed
41
- session_delete(session, session_key)
42
- false
43
- else
44
+ caught = catch_reason(filled:, elapsed:)
45
+ if caught
44
46
  session[session_key] = Validator.monotonic_ms.to_s
45
- true
47
+ else
48
+ session_delete(session, session_key)
46
49
  end
50
+ caught
47
51
  end
48
52
 
49
53
  def signals_rating(params)
@@ -55,6 +59,13 @@ module Otori
55
59
 
56
60
  private
57
61
 
62
+ def catch_reason(filled:, elapsed:)
63
+ return :filled if filled
64
+ return :too_fast unless elapsed
65
+
66
+ nil
67
+ end
68
+
58
69
  def param_value(params, name)
59
70
  param_keys(name).reduce(params) do |scope, key|
60
71
  break nil unless scope.respond_to?(:[])
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otori
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wout