otori 0.4.0 → 0.6.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 +22 -0
- data/README.md +34 -1
- data/lib/otori/signals.rb +20 -2
- data/lib/otori/validator.rb +1 -0
- data/lib/otori/version.rb +1 -1
- data/lib/otori.rb +7 -3
- 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: 554c128771ba6ee67560679dc82411c949a7950188d9d89c50419d4f2135b288
|
|
4
|
+
data.tar.gz: 562127711e5aa8d21bdde1d65cea1209e26f228d50c9b16a75530dcea7248a78
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79b5ba2de508acc8324892a7ee500a2e5d1252bb82ecda1d7c9f809211f12a35d92a2b7d6c64d638a2003a58215ed252495e5b311be5794b97be0eadfd5d02aa
|
|
7
|
+
data.tar.gz: 7ec7f1393886c5064bbec6819b1e6637a5a6366b511485c2d6ae66dfe8cceebc1ec92ca15336427cae491aa496ae4f688585223663a58ec3b372433afb911700
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `Otori::Signals#pragmatic_rating` and `Otori.signals_rating(params,
|
|
13
|
+
pragmatic: true)`. The raw `human_rating` is unchanged and stays honest:
|
|
14
|
+
desktop users without a touchscreen can trigger at most four of the five
|
|
15
|
+
signals, capping them at `0.8`. The pragmatic rating treats `0.8` and up
|
|
16
|
+
as fully human without influencing lower ratings: values at or below `0.4`
|
|
17
|
+
are returned untouched, and the `0.4` to `0.8` range is stretched linearly
|
|
18
|
+
to `0.4` to `1.0` (so `0.6` becomes `0.7`). Opt in per call; the default
|
|
19
|
+
`Otori.signals_rating(params)` keeps returning the raw rating.
|
|
20
|
+
|
|
21
|
+
## [0.5.0] - 2026-10-13
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Passing `wait: false` to `Otori.reason` / `Otori.caught?` (and the
|
|
26
|
+
Rails/Hanami `honeypot` macros) skips the timing check entirely while
|
|
27
|
+
keeping the filled-field check active. Useful for forms where
|
|
28
|
+
password-manager autofill or sticky-session returns make even a short
|
|
29
|
+
timer prone to false positives. `wait: nil` continues to fall back to
|
|
30
|
+
`config.default_delay`; only an explicit `false` disables timing.
|
|
31
|
+
|
|
10
32
|
## [0.4.0] - 2026-10-13
|
|
11
33
|
|
|
12
34
|
### Added
|
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
|
|
@@ -173,6 +174,14 @@ honeypot "note" do |_req, _res|
|
|
|
173
174
|
end
|
|
174
175
|
```
|
|
175
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
|
+
|
|
176
185
|
To act on the input-signals rating, evaluate it inside `handle`:
|
|
177
186
|
|
|
178
187
|
```ruby
|
|
@@ -243,6 +252,14 @@ class SignUpsController < ApplicationController
|
|
|
243
252
|
end
|
|
244
253
|
```
|
|
245
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
|
+
|
|
246
263
|
When the honeypot is tripped, the request halts with `:no_content` by default.
|
|
247
264
|
To customize the response, pass a block. The block optionally receives the
|
|
248
265
|
catch reason (`:filled` or `:too_fast`), handy for logging or branching:
|
|
@@ -339,6 +356,22 @@ signals.focus?
|
|
|
339
356
|
> password manager submissions, which often only trigger focus plus mouse or
|
|
340
357
|
> touch.
|
|
341
358
|
|
|
359
|
+
### Pragmatic rating
|
|
360
|
+
|
|
361
|
+
Desktop users without a touchscreen can trigger at most four of the five
|
|
362
|
+
signals, capping their raw rating at `0.8`. The raw rating always stays honest,
|
|
363
|
+
but if you want such submissions to count as fully human, ask for the pragmatic
|
|
364
|
+
rating:
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
Otori.signals_rating(params, pragmatic: true)
|
|
368
|
+
signals.pragmatic_rating
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Ratings at or below `0.4` are returned unchanged, the `0.4` to `0.8` range is
|
|
372
|
+
stretched linearly to `0.4` to `1.0`, and anything `0.8` or above counts as
|
|
373
|
+
fully human.
|
|
374
|
+
|
|
342
375
|
## Security considerations
|
|
343
376
|
|
|
344
377
|
This gem provides basic bot protection, but it should not be your only line
|
data/lib/otori/signals.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Otori
|
|
|
16
16
|
raw = JSON.parse(json.to_s)
|
|
17
17
|
raise JSON::ParserError, "expected an object" unless raw.is_a?(Hash)
|
|
18
18
|
|
|
19
|
-
new(KEYS.transform_values { raw[
|
|
19
|
+
new(KEYS.transform_values { raw[it] == true })
|
|
20
20
|
rescue JSON::ParserError
|
|
21
21
|
new
|
|
22
22
|
end
|
|
@@ -25,8 +25,17 @@ module Otori
|
|
|
25
25
|
from_json(json).human_rating
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def self.pragmatic_rating(json)
|
|
29
|
+
from_json(json).pragmatic_rating
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Touchless devices can trigger at most four signals, capping them at 0.8,
|
|
33
|
+
# so this stretches 0.4-0.8 to 0.4-1.0 to let them rate as fully human.
|
|
34
|
+
PRAGMATIC_ANCHOR = 0.4
|
|
35
|
+
PRAGMATIC_CLEAN = 0.8
|
|
36
|
+
|
|
28
37
|
def initialize(flags = {})
|
|
29
|
-
@flags = KEYS.keys.to_h { [
|
|
38
|
+
@flags = KEYS.keys.to_h { [it, flags.fetch(it, false) == true] }
|
|
30
39
|
end
|
|
31
40
|
|
|
32
41
|
KEYS.each_key do |key|
|
|
@@ -37,6 +46,15 @@ module Otori
|
|
|
37
46
|
@flags.values.count(true) / KEYS.size.to_f
|
|
38
47
|
end
|
|
39
48
|
|
|
49
|
+
def pragmatic_rating
|
|
50
|
+
raw = human_rating
|
|
51
|
+
return raw if raw <= PRAGMATIC_ANCHOR
|
|
52
|
+
return 1.0 if raw >= PRAGMATIC_CLEAN
|
|
53
|
+
|
|
54
|
+
span = (1.0 - PRAGMATIC_ANCHOR) / (PRAGMATIC_CLEAN - PRAGMATIC_ANCHOR)
|
|
55
|
+
(PRAGMATIC_ANCHOR + (raw - PRAGMATIC_ANCHOR) * span).round(2)
|
|
56
|
+
end
|
|
57
|
+
|
|
40
58
|
def to_h = @flags.dup
|
|
41
59
|
end
|
|
42
60
|
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
|
@@ -35,7 +35,7 @@ module Otori
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def reason(name, params:, session:, wait: nil)
|
|
38
|
-
wait
|
|
38
|
+
wait = config.default_delay if wait.nil?
|
|
39
39
|
session_key = config.session_key(name)
|
|
40
40
|
stored = session_get(session, session_key)
|
|
41
41
|
filled = Validator.filled?(param_value(params, name))
|
|
@@ -50,11 +50,15 @@ module Otori
|
|
|
50
50
|
caught
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
def signals_rating(params)
|
|
53
|
+
def signals_rating(params, pragmatic: false)
|
|
54
54
|
raw = param_value(params, config.signals_input_name)
|
|
55
55
|
return 0.0 if raw.nil? || raw.to_s.empty?
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
if pragmatic
|
|
58
|
+
Signals.pragmatic_rating(raw.to_s)
|
|
59
|
+
else
|
|
60
|
+
Signals.human_rating(raw.to_s)
|
|
61
|
+
end
|
|
58
62
|
end
|
|
59
63
|
|
|
60
64
|
private
|