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 +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +41 -5
- data/lib/otori/hanami.rb +3 -2
- data/lib/otori/rails.rb +3 -7
- data/lib/otori/validator.rb +1 -0
- data/lib/otori/version.rb +1 -1
- data/lib/otori.rb +17 -6
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d06214bc12e9ac02bb0989a000bbf68987a12cc15d0a3c178a1fffcfd92503f
|
|
4
|
+
data.tar.gz: 474f1fb57b296c594d22fb4c00126658c9209fe0f8c10a323b0f34c014893e26
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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/validator.rb
CHANGED
|
@@ -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
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
false
|
|
43
|
-
else
|
|
44
|
+
caught = catch_reason(filled:, elapsed:)
|
|
45
|
+
if caught
|
|
44
46
|
session[session_key] = Validator.monotonic_ms.to_s
|
|
45
|
-
|
|
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?(:[])
|