otori 0.3.0 → 0.5.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: be91dcf0d80421c3347a3d75e9e7aade7a7479f8e7b100c6d628f394016c2d37
4
- data.tar.gz: d8bd1c71cb521c22f5f9797612cd83105bef897584d1a92d3004acaad7625b73
3
+ metadata.gz: 7d06214bc12e9ac02bb0989a000bbf68987a12cc15d0a3c178a1fffcfd92503f
4
+ data.tar.gz: 474f1fb57b296c594d22fb4c00126658c9209fe0f8c10a323b0f34c014893e26
5
5
  SHA512:
6
- metadata.gz: 7dbcb90604352965d8f951c2caf025978deafb6e8b23772cafb953a0fba20a2f3a292567ffa48441a0fb82a9794a4c5abb3d82297cfc824effa11bbc8c65bf25
7
- data.tar.gz: 2c0df32a81ca7fdce8234e91d52b10379e8931b597732f54de8393c0460409ecd50a5bbe01551a876dd12711fd4ee6db63a12197128db0ad53fb59a157ec18c6
6
+ metadata.gz: f58ff908079e04df4436fcf0266e22cc1453c020a295f4e2fe6f0317664efd587ec7e2dbf45c6492035823ec212414a86939aab81d7176fb595bf65fba4d4fcc
7
+ data.tar.gz: 686f43334060ddc3067be606224c9b07b5a1e1fefc6519faa3e75cc1fc572f4967909d60ea08b32f35c53053408b6dfd79dd71e7c29067387e49c94fb4899e93
data/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-10-13
11
+
12
+ ### Added
13
+
14
+ - Passing `wait: false` to `Otori.reason` / `Otori.caught?` (and the
15
+ Rails/Hanami `honeypot` macros) skips the timing check entirely while
16
+ keeping the filled-field check active. Useful for forms where
17
+ password-manager autofill or sticky-session returns make even a short
18
+ timer prone to false positives. `wait: nil` continues to fall back to
19
+ `config.default_delay`; only an explicit `false` disables timing.
20
+
21
+ ## [0.4.0] - 2026-10-13
22
+
23
+ ### Added
24
+
25
+ - `Otori.reason` returns the specific catch reason as a symbol
26
+ (`:filled` when the honeypot field was filled, `:too_fast` when the
27
+ form was submitted before the configured delay elapsed, `nil` when the
28
+ submission looks clean). This lets callers distinguish genuinely
29
+ suspicious submissions from users who merely autofilled and submitted
30
+ faster than the delay allows.
31
+ - The Rails adapter's `on_caught` block now receives the reason symbol
32
+ as its sole argument, so applications can log, flag, or branch on it
33
+ (`honeypot(:website) { |reason| ... }`). Zero-arg blocks continue to
34
+ work unchanged.
35
+ - The Hanami adapter's `on_caught` block now receives the reason symbol
36
+ as a third argument after `request` and `response`. Two-arg blocks
37
+ continue to work unchanged.
38
+
39
+ ### Changed
40
+
41
+ - `Otori.caught?` is now a thin predicate over `Otori.reason`. Its
42
+ return value and session side effects are unchanged.
43
+
10
44
  ## [0.3.0] - 2026-10-12
11
45
 
12
46
  ### Changed
data/README.md CHANGED
@@ -18,7 +18,8 @@ techniques into one gem:
18
18
 
19
19
  1. **Invisible fields**. Bots fill out every field, including ones hidden with CSS.
20
20
  2. **Timing checks**. Bots submit forms instantly, humans need more time.
21
- 3. **Input signals**. Bots don't tend to trigger mouse, touch, scroll, keyboard, or focus events.
21
+ 3. **Input signals**. Bots don't tend to trigger mouse, touch, scroll,
22
+ keyboard, or focus events.
22
23
 
23
24
  When either of the first two checks fail, the submission is quietly rejected.
24
25
  The bot thinks it succeeded and moves on. The third one can be used to reject
@@ -78,6 +79,20 @@ session. `signals_field` renders a hidden input plus the JavaScript that
78
79
  tracks human input. `caught?` checks the submitted form and returns `true`
79
80
  when the request looks like a bot.
80
81
 
82
+ If you want to know _why_ a submission was caught (for logging, flagging,
83
+ or scoring), use `Otori.reason`:
84
+
85
+ ```ruby
86
+ Otori.reason("user[website]", params: request.params, session: request.session)
87
+ # => :filled when the honeypot field was filled
88
+ # => :too_fast when the form was submitted before the delay elapsed
89
+ # => nil when the submission looks clean
90
+ ```
91
+
92
+ `caught?` is implemented as `!reason(...).nil?`. Both methods perform the
93
+ same session timestamp bookkeeping, so pick one and stick with it per
94
+ request.
95
+
81
96
  Field names use standard HTML bracket notation, so `"user[website]"` lives
82
97
  under `params[:user][:website]` once submitted. Flat names like `"note"` work
83
98
  too. Pick whichever fits the surrounding form, the more believable the
@@ -138,10 +153,13 @@ end
138
153
  ```
139
154
 
140
155
  When the honeypot is tripped, the action halts with `204 No Content` by
141
- default. To customize the response, pass a block:
156
+ default. To customize the response, pass a block. The block optionally
157
+ receives the catch reason (`:filled` or `:too_fast`) as a third argument,
158
+ handy for logging or branching:
142
159
 
143
160
  ```ruby
144
- honeypot "user[website]" do |_request, response|
161
+ honeypot "user[website]" do |_request, response, reason|
162
+ SecurityLog.record!(reason: reason)
145
163
  response.flash[:info] = "Moving on..."
146
164
  response.redirect_to "/", status: 303
147
165
  end
@@ -156,6 +174,14 @@ honeypot "note" do |_req, _res|
156
174
  end
157
175
  ```
158
176
 
177
+ Pass `wait: false` to skip the timing check entirely while keeping the
178
+ filled-field check active. Useful on login or password-reset forms where
179
+ password-manager autofill routinely beats any reasonable timer:
180
+
181
+ ```ruby
182
+ honeypot "user[website]", wait: false
183
+ ```
184
+
159
185
  To act on the input-signals rating, evaluate it inside `handle`:
160
186
 
161
187
  ```ruby
@@ -226,11 +252,21 @@ class SignUpsController < ApplicationController
226
252
  end
227
253
  ```
228
254
 
255
+ Pass `wait: false` to skip the timing check entirely while keeping the
256
+ filled-field check active. Useful on forms where password-manager autofill
257
+ beats any reasonable timer (login, password reset):
258
+
259
+ ```ruby
260
+ honeypot :username, wait: false, only: :create
261
+ ```
262
+
229
263
  When the honeypot is tripped, the request halts with `:no_content` by default.
230
- To customize the response, pass a block:
264
+ To customize the response, pass a block. The block optionally receives the
265
+ catch reason (`:filled` or `:too_fast`), handy for logging or branching:
231
266
 
232
267
  ```ruby
233
- honeypot :website, only: :create do
268
+ honeypot :website, only: :create do |reason|
269
+ SecurityLog.record!(reason: reason)
234
270
  redirect_to root_path, notice: "Moving on..."
235
271
  end
236
272
  ```
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
@@ -10,6 +10,7 @@ module Otori
10
10
 
11
11
  def elapsed?(timestamp_ms, wait_seconds, now: monotonic_ms)
12
12
  return true if Otori.config.disable_delay
13
+ return true if wait_seconds == false
13
14
  return false if timestamp_ms.nil?
14
15
 
15
16
  (now - timestamp_ms.to_i) >= (wait_seconds.to_f * 1000)
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.3.0"
4
+ VERSION = "0.5.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
- wait ||= config.default_delay
34
+ !reason(name, params:, session:, wait:).nil?
35
+ end
36
+
37
+ def reason(name, params:, session:, wait: nil)
38
+ wait = config.default_delay if wait.nil?
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.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wout