otori 0.1.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 +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE +21 -0
- data/README.md +319 -0
- data/lib/otori/configuration.rb +20 -0
- data/lib/otori/error.rb +14 -0
- data/lib/otori/form.rb +72 -0
- data/lib/otori/hanami.rb +56 -0
- data/lib/otori/signals.rb +42 -0
- data/lib/otori/validator.rb +20 -0
- data/lib/otori/version.rb +5 -0
- data/lib/otori.rb +79 -0
- metadata +60 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8918f6c816e04317efede4302afa940ac592e92f6a4e8499bd81f9adbee536f0
|
|
4
|
+
data.tar.gz: c2659d173281ef798d54c52bcd2967dab83d971e3eb662db1ba214565824365a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fbfb505eb0362b440bdbd0b86f53395dc4f3c1ff059e6446d2d7c8466c93c27f25e9b05889c62962292315bda17fcd09b5b98c434ed613e8bc5858fdf713ca89
|
|
7
|
+
data.tar.gz: a975548f46af0ec0ceb9d59fb73ef20a175211016156253414676e292f4c08d4b86fa7208aab98ed92ca42b61fc54e97b71f1ddc519dcebeaa61d6fc6b968216
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial scaffolding inspired by the
|
|
13
|
+
[Crystal lucky_honeypot shard](https://codeberg.org/fluck/lucky_honeypot).
|
|
14
|
+
- `Otori::Configuration` with `default_delay`, `disable_delay`, and
|
|
15
|
+
`signals_input_name`, accessed via `Otori.configure`.
|
|
16
|
+
- `Otori::Form.field` and `Otori::Form.signals_field` for
|
|
17
|
+
rendering the invisible honeypot input and the input-signals tracker as
|
|
18
|
+
framework-agnostic HTML strings.
|
|
19
|
+
- `Otori::Signals` for parsing the JSON payload submitted by the
|
|
20
|
+
tracker and computing a `human_rating` between 0 and 1.
|
|
21
|
+
- `Otori::Validator` with `filled?` and `elapsed?` for the two
|
|
22
|
+
core form checks.
|
|
23
|
+
- `Otori.caught?` combining the field and timing checks against a
|
|
24
|
+
session and params hash, with timestamp cleanup on success and reset on
|
|
25
|
+
failure.
|
|
26
|
+
- `Otori.signals_rating` convenience for computing the human rating
|
|
27
|
+
straight from a params hash.
|
|
28
|
+
- `Otori::Hanami::Action`, an optional adapter providing a
|
|
29
|
+
`honeypot` class DSL method that registers a Hanami `before` callback,
|
|
30
|
+
halting with 204 by default or running a user-supplied block.
|
|
31
|
+
- `Otori::Hanami::Helpers`, an optional view-helper module exposing
|
|
32
|
+
`honeypot_field` and `honeypot_signals` for Hanami views.
|
|
33
|
+
- `Otori::Error` and `Otori::MissingSession` exception
|
|
34
|
+
types.
|
|
35
|
+
- Codeberg / Forgejo CI workflow running rspec and rubocop on Ruby 3.4
|
|
36
|
+
and 4.0.
|
|
37
|
+
- README covering quickstart, framework integration for Hanami, Rails,
|
|
38
|
+
and any Rack app, configuration, signals interpretation, and security
|
|
39
|
+
considerations.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wout <hi@wout.codes>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# Otori
|
|
2
|
+
|
|
3
|
+
[](https://codeberg.org/fluck/otori/actions?workflow=ci.yml)
|
|
4
|
+
[](https://codeberg.org/fluck/otori/tags)
|
|
5
|
+
|
|
6
|
+
Invisible captcha spam protection for any Rack-based Ruby app, with an opt-in
|
|
7
|
+
Hanami adapter.
|
|
8
|
+
|
|
9
|
+
_Otori_ (囮) is the Japanese word for "decoy", historically a bird used in
|
|
10
|
+
hunting to draw others in.
|
|
11
|
+
|
|
12
|
+
This is a Ruby companion to the [Crystal lucky_honeypot
|
|
13
|
+
shard](https://codeberg.org/fluck/lucky_honeypot). It combines three classic
|
|
14
|
+
techniques into one gem:
|
|
15
|
+
|
|
16
|
+
1. **Invisible fields**. Bots fill out every field, including ones hidden with CSS.
|
|
17
|
+
2. **Timing checks**. Bots submit forms instantly, humans need more time.
|
|
18
|
+
3. **Input signals**. Bots don't tend to trigger mouse, touch, scroll, keyboard, or focus events.
|
|
19
|
+
|
|
20
|
+
When either of the first two checks fail, the submission is quietly rejected.
|
|
21
|
+
The bot thinks it succeeded and moves on. The third one can be used to reject
|
|
22
|
+
or flag submissions at a chosen _human rating_ threshold.
|
|
23
|
+
|
|
24
|
+
> [!NOTE]
|
|
25
|
+
> The original repository is hosted at
|
|
26
|
+
> [Codeberg](https://codeberg.org/fluck/otori). The [GitHub
|
|
27
|
+
> repo](https://github.com/flucksite/otori) is just a mirror.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
Add this to your Gemfile:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
gem "otori"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then run `bundle install`. Ruby 3.2 or newer is required.
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
The gem ships a framework-agnostic core plus an opt-in Hanami adapter. The core
|
|
42
|
+
API is three calls:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
Otori.field("user[website]", session: request.session)
|
|
46
|
+
Otori.signals_field
|
|
47
|
+
Otori.caught?("user[website]", params: request.params, session: request.session)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`field` renders the invisible input and stores a load timestamp in the
|
|
51
|
+
session. `signals_field` renders a hidden input plus the JavaScript that
|
|
52
|
+
tracks human input. `caught?` checks the submitted form and returns `true`
|
|
53
|
+
when the request looks like a bot.
|
|
54
|
+
|
|
55
|
+
Field names use standard HTML bracket notation, so `"user[website]"` lives
|
|
56
|
+
under `params[:user][:website]` once submitted. Flat names like `"note"` work
|
|
57
|
+
too. Pick whichever fits the surrounding form, the more believable the
|
|
58
|
+
honeypot looks next to the real fields, the better.
|
|
59
|
+
|
|
60
|
+
## Framework integration
|
|
61
|
+
|
|
62
|
+
### Hanami
|
|
63
|
+
|
|
64
|
+
The Hanami adapter is loaded explicitly so the base gem stays dependency-free:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
require "otori/hanami"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Mix the helpers into your views:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# app/views/helpers.rb
|
|
74
|
+
module MyApp
|
|
75
|
+
module Views
|
|
76
|
+
module Helpers
|
|
77
|
+
include Otori::Hanami::Helpers
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
In a form template:
|
|
84
|
+
|
|
85
|
+
```erb
|
|
86
|
+
<%= honeypot_field("user[website]") %>
|
|
87
|
+
<%= honeypot_signals %>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The helpers mark their output safe via `String#html_safe`, which Hanami View
|
|
91
|
+
provides out of the box, so the HTML flows through ERB without escaping.
|
|
92
|
+
|
|
93
|
+
Guard the receiving action with the `honeypot` DSL method:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
# app/actions/sign_ups/create.rb
|
|
97
|
+
module MyApp
|
|
98
|
+
module Actions
|
|
99
|
+
module SignUps
|
|
100
|
+
class Create < MyApp::Action
|
|
101
|
+
include Otori::Hanami::Action
|
|
102
|
+
|
|
103
|
+
honeypot "user[website]"
|
|
104
|
+
|
|
105
|
+
def handle(request, response)
|
|
106
|
+
# ...
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
When the honeypot is tripped, the action halts with `204 No Content` by
|
|
115
|
+
default. To customize the response, pass a block:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
honeypot "user[website]" do |_request, response|
|
|
119
|
+
response.flash[:info] = "Moving on..."
|
|
120
|
+
response.redirect_to "/", status: 303
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Multiple honeypots are supported, each with its own timing and handler:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
honeypot "user[website]", wait: 5
|
|
128
|
+
honeypot "note" do |_req, _res|
|
|
129
|
+
halt 422
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
To act on the input-signals rating, evaluate it inside `handle`:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
def handle(request, response)
|
|
137
|
+
rating = Otori.signals_rating(request.params.to_h)
|
|
138
|
+
halt 204 if rating < 0.4
|
|
139
|
+
|
|
140
|
+
# ...
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Rack (Sinatra, Roda, plain Rack)
|
|
145
|
+
|
|
146
|
+
There is no adapter to require, the core API is enough. In a Sinatra app:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
require "otori"
|
|
150
|
+
|
|
151
|
+
enable :sessions
|
|
152
|
+
|
|
153
|
+
get "/sign_up" do
|
|
154
|
+
erb :sign_up
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
post "/sign_up" do
|
|
158
|
+
halt 204 if Otori.caught?(
|
|
159
|
+
"user[website]",
|
|
160
|
+
params: params,
|
|
161
|
+
session: session
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# ...
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
In the view:
|
|
169
|
+
|
|
170
|
+
```erb
|
|
171
|
+
<%= Otori.field("user[website]", session: session) %>
|
|
172
|
+
<%= Otori.signals_field %>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Rails
|
|
176
|
+
|
|
177
|
+
Rails is well served by [invisible_captcha](https://github.com/markets/invisible_captcha)
|
|
178
|
+
when all you need is a hidden field and a timing check. Use this gem in Rails
|
|
179
|
+
if you want the input-signals rating on top.
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
# app/helpers/application_helper.rb
|
|
183
|
+
module ApplicationHelper
|
|
184
|
+
def honeypot_field(name, **attrs)
|
|
185
|
+
Otori.field(name, session: session, **attrs).html_safe
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def honeypot_signals(**attrs)
|
|
189
|
+
Otori.signals_field(**attrs).html_safe
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
# app/controllers/sign_ups_controller.rb
|
|
196
|
+
class SignUpsController < ApplicationController
|
|
197
|
+
before_action :check_honeypot, only: :create
|
|
198
|
+
|
|
199
|
+
def create
|
|
200
|
+
# ...
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def check_honeypot
|
|
206
|
+
return unless Otori.caught?(
|
|
207
|
+
"user[website]",
|
|
208
|
+
params: params.to_unsafe_h,
|
|
209
|
+
session: session
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
head :no_content
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Configuration
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
Otori.configure do |c|
|
|
221
|
+
# Required delay (in seconds) between page load and form submission.
|
|
222
|
+
c.default_delay = 2.0
|
|
223
|
+
|
|
224
|
+
# Disables the submission delay entirely. Useful in tests.
|
|
225
|
+
c.disable_delay = false
|
|
226
|
+
|
|
227
|
+
# Name of the hidden input that carries the signals payload.
|
|
228
|
+
c.signals_input_name = "honeypot_signals"
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## The invisible field
|
|
233
|
+
|
|
234
|
+
By default the field is rendered with an inline `style` attribute that takes
|
|
235
|
+
it out of the visual flow without breaking accessibility tools. Pass your own
|
|
236
|
+
`class` (or `style`) to opt out of the default style and use your CSS instead:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
Otori.field("user[website]", session: session, class: "visually-hidden")
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Underscored attribute keys are converted to dashes so `data_foo: "bar"`
|
|
243
|
+
renders as `data-foo="bar"`. Any other attribute pair is passed through
|
|
244
|
+
unchanged.
|
|
245
|
+
|
|
246
|
+
> [!NOTE]
|
|
247
|
+
> The field stores a load timestamp in the session under a
|
|
248
|
+
> `honeypot_field_<name>` key. The companion `caught?` call reads and
|
|
249
|
+
> clears it on success, or resets it when the form is rejected.
|
|
250
|
+
|
|
251
|
+
## Detecting input signals
|
|
252
|
+
|
|
253
|
+
`signals_field` renders a hidden input plus a small inline `<script>` that
|
|
254
|
+
listens for the first occurrence of each of five events on the surrounding
|
|
255
|
+
form: `mousemove`, `touchstart`, `keydown`, `focusin`, and a window-level
|
|
256
|
+
`scroll`. On submit, the boolean results are serialized to JSON and posted
|
|
257
|
+
along with the rest of the form.
|
|
258
|
+
|
|
259
|
+
In the action, get the rating directly from the params:
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
Otori.signals_rating(params) # => 0.0 to 1.0
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Or work with the parsed object for more detail:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
signals = Otori::Signals.from_json(params["honeypot_signals"])
|
|
269
|
+
signals.human_rating # 0 (bot) to 1 (human)
|
|
270
|
+
signals.mouse?
|
|
271
|
+
signals.touch?
|
|
272
|
+
signals.scroll?
|
|
273
|
+
signals.keyboard?
|
|
274
|
+
signals.focus?
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
> [!NOTE]
|
|
278
|
+
> The human rating is the fraction of the five signals that fired, so each one
|
|
279
|
+
> contributes `0.2`. A score of `0` is almost certainly a dumb bot, while `0.2`
|
|
280
|
+
> could be a sophisticated bot triggering a single signal (almost always
|
|
281
|
+
> `mouse`), though a human filling out a short form at the top of the page may
|
|
282
|
+
> also land there.
|
|
283
|
+
>
|
|
284
|
+
> `0.4` is a reasonable threshold for flagging entries: it still catches bots
|
|
285
|
+
> that fake one or two signals, but avoids false positives for autofill and
|
|
286
|
+
> password manager submissions, which often only trigger focus plus mouse or
|
|
287
|
+
> touch.
|
|
288
|
+
|
|
289
|
+
## Security considerations
|
|
290
|
+
|
|
291
|
+
This gem provides basic bot protection, but it should not be your only line
|
|
292
|
+
of defense.
|
|
293
|
+
|
|
294
|
+
- It is not foolproof, sophisticated bots can bypass honeypots.
|
|
295
|
+
- Combine this with a rate limiter such as `rack-attack`.
|
|
296
|
+
- For high-value forms, consider adding CAPTCHA or email verification.
|
|
297
|
+
- The submission timestamp is stored in the session. If sessions are
|
|
298
|
+
compromised, an attacker could manipulate timing checks. Make sure your session
|
|
299
|
+
store uses signed and encrypted cookies.
|
|
300
|
+
- The timing check compares wall-clock timestamps, which makes it resilient to
|
|
301
|
+
timing attacks since the check is a simple threshold comparison.
|
|
302
|
+
- This gem does not touch CSRF tokens. Honeypot fields are regular form inputs
|
|
303
|
+
and do not interfere with your framework's CSRF protection.
|
|
304
|
+
|
|
305
|
+
For most use cases (contact forms, newsletter signups), this gem provides solid
|
|
306
|
+
protection with zero user friction. Expect it to catch between 60% and 90% of
|
|
307
|
+
automated form submissions.
|
|
308
|
+
|
|
309
|
+
## Development
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
bundle install
|
|
313
|
+
bundle exec rspec
|
|
314
|
+
bundle exec rubocop
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## License
|
|
318
|
+
|
|
319
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Otori
|
|
4
|
+
class Configuration
|
|
5
|
+
SESSION_KEY_PREFIX = "honeypot_field"
|
|
6
|
+
|
|
7
|
+
attr_accessor :default_delay, :disable_delay, :signals_input_name
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@default_delay = 2.0
|
|
11
|
+
@disable_delay = false
|
|
12
|
+
@signals_input_name = "honeypot_signals"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def session_key(name)
|
|
16
|
+
safe = name.to_s.gsub(/[^a-z0-9_]+/i, "_").gsub(/\A_+|_+\z/, "")
|
|
17
|
+
"#{SESSION_KEY_PREFIX}_#{safe}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/otori/error.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Otori
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class MissingSession < Error
|
|
7
|
+
def initialize
|
|
8
|
+
super(
|
|
9
|
+
"Otori needs a session-like object (responding to []=, []) " \
|
|
10
|
+
"to store the form-load timestamp. Enable sessions in your app."
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/otori/form.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
require_relative "validator"
|
|
6
|
+
|
|
7
|
+
module Otori
|
|
8
|
+
module Form
|
|
9
|
+
extend self
|
|
10
|
+
|
|
11
|
+
HIDDEN_STYLE = "position:absolute;left:-9999px;width:1px;height:1px;pointer-events:none;"
|
|
12
|
+
|
|
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
|
+
def field(name, session:, **attrs)
|
|
28
|
+
raise MissingSession unless session_like?(session)
|
|
29
|
+
|
|
30
|
+
session[Otori.config.session_key(name)] = Validator.monotonic_ms.to_s
|
|
31
|
+
|
|
32
|
+
base = {
|
|
33
|
+
name: name.to_s,
|
|
34
|
+
type: "text",
|
|
35
|
+
"aria-hidden": "true",
|
|
36
|
+
tabindex: "-1",
|
|
37
|
+
autocomplete: "off"
|
|
38
|
+
}
|
|
39
|
+
base[:style] = HIDDEN_STYLE unless attrs.key?(:class) || attrs.key?(:style)
|
|
40
|
+
|
|
41
|
+
tag(:input, base.merge(stringify_keys(attrs)))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def signals_field(**attrs)
|
|
45
|
+
input = tag(:input, {
|
|
46
|
+
name: Otori.config.signals_input_name,
|
|
47
|
+
type: "hidden"
|
|
48
|
+
}.merge(stringify_keys(attrs)))
|
|
49
|
+
|
|
50
|
+
"#{input}<script>#{SIGNALS_SCRIPT}</script>"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def session_like?(object)
|
|
56
|
+
object.respond_to?(:[]) && object.respond_to?(:[]=)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def stringify_keys(attrs)
|
|
60
|
+
attrs.to_h { |key, value| [key.to_s.tr("_", "-"), value] }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def tag(name, attrs)
|
|
64
|
+
pairs = attrs.compact.map do |key, value|
|
|
65
|
+
next CGI.escape_html(key.to_s) if value == true
|
|
66
|
+
|
|
67
|
+
%(#{CGI.escape_html(key.to_s)}="#{CGI.escape_html(value.to_s)}")
|
|
68
|
+
end
|
|
69
|
+
"<#{name} #{pairs.join(" ")}>"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/otori/hanami.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../otori"
|
|
4
|
+
|
|
5
|
+
module Otori
|
|
6
|
+
module Hanami
|
|
7
|
+
module Action
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def honeypot(name, wait: nil, &on_caught)
|
|
14
|
+
field_name = name
|
|
15
|
+
field_wait = wait
|
|
16
|
+
caught_block = on_caught
|
|
17
|
+
|
|
18
|
+
before do |request, response|
|
|
19
|
+
next unless Otori.caught?(
|
|
20
|
+
field_name,
|
|
21
|
+
params: request.params.to_h,
|
|
22
|
+
session: request.session,
|
|
23
|
+
wait: field_wait
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if caught_block
|
|
27
|
+
instance_exec(request, response, &caught_block)
|
|
28
|
+
else
|
|
29
|
+
halt 204
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module Helpers
|
|
37
|
+
def honeypot_field(name, **attrs)
|
|
38
|
+
Otori.field(name, session: _otori_session, **attrs).html_safe
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def honeypot_signals(**attrs)
|
|
42
|
+
Otori.signals_field(**attrs).html_safe
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def _otori_session
|
|
48
|
+
return context.request.session if respond_to?(:context) &&
|
|
49
|
+
context.respond_to?(:request)
|
|
50
|
+
return request.session if respond_to?(:request)
|
|
51
|
+
|
|
52
|
+
raise Otori::MissingSession
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Otori
|
|
6
|
+
class Signals
|
|
7
|
+
KEYS = {
|
|
8
|
+
mouse: "m",
|
|
9
|
+
touch: "t",
|
|
10
|
+
scroll: "s",
|
|
11
|
+
keyboard: "k",
|
|
12
|
+
focus: "f"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def self.from_json(json)
|
|
16
|
+
raw = JSON.parse(json.to_s)
|
|
17
|
+
raise JSON::ParserError, "expected an object" unless raw.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
new(KEYS.transform_values { raw[_1] == true })
|
|
20
|
+
rescue JSON::ParserError
|
|
21
|
+
new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.human_rating(json)
|
|
25
|
+
from_json(json).human_rating
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(flags = {})
|
|
29
|
+
@flags = KEYS.keys.to_h { [_1, flags.fetch(_1, false) == true] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
KEYS.each_key do |key|
|
|
33
|
+
define_method("#{key}?") { @flags.fetch(key) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def human_rating
|
|
37
|
+
@flags.values.count(true) / KEYS.size.to_f
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h = @flags.dup
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Otori
|
|
4
|
+
module Validator
|
|
5
|
+
extend self
|
|
6
|
+
|
|
7
|
+
def filled?(value)
|
|
8
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def elapsed?(timestamp_ms, wait_seconds, now: monotonic_ms)
|
|
12
|
+
return true if Otori.config.disable_delay
|
|
13
|
+
return false if timestamp_ms.nil?
|
|
14
|
+
|
|
15
|
+
(now - timestamp_ms.to_i) >= (wait_seconds.to_f * 1000)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def monotonic_ms = (Time.now.to_f * 1000).to_i
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/otori.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "otori/version"
|
|
4
|
+
require_relative "otori/error"
|
|
5
|
+
require_relative "otori/configuration"
|
|
6
|
+
require_relative "otori/signals"
|
|
7
|
+
require_relative "otori/validator"
|
|
8
|
+
require_relative "otori/form"
|
|
9
|
+
|
|
10
|
+
module Otori
|
|
11
|
+
class << self
|
|
12
|
+
def config
|
|
13
|
+
@config ||= Configuration.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def configure
|
|
17
|
+
yield config
|
|
18
|
+
config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reset_config!
|
|
22
|
+
@config = Configuration.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def field(name, session:, **attrs)
|
|
26
|
+
Form.field(name, session: session, **attrs)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def signals_field(**attrs)
|
|
30
|
+
Form.signals_field(**attrs)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def caught?(name, params:, session:, wait: nil)
|
|
34
|
+
wait ||= config.default_delay
|
|
35
|
+
session_key = config.session_key(name)
|
|
36
|
+
stored = session_get(session, session_key)
|
|
37
|
+
filled = Validator.filled?(param_value(params, name))
|
|
38
|
+
elapsed = Validator.elapsed?(stored&.to_i, wait)
|
|
39
|
+
|
|
40
|
+
if !filled && elapsed
|
|
41
|
+
session_delete(session, session_key)
|
|
42
|
+
false
|
|
43
|
+
else
|
|
44
|
+
session[session_key] = Validator.monotonic_ms.to_s
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def signals_rating(params)
|
|
50
|
+
raw = param_value(params, config.signals_input_name)
|
|
51
|
+
return 0.0 if raw.nil? || raw.to_s.empty?
|
|
52
|
+
|
|
53
|
+
Signals.human_rating(raw.to_s)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def param_value(params, name)
|
|
59
|
+
param_keys(name).reduce(params) do |scope, key|
|
|
60
|
+
break nil unless scope.respond_to?(:[])
|
|
61
|
+
|
|
62
|
+
scope[key] || scope[key.to_sym]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def param_keys(name)
|
|
67
|
+
keys = name.to_s.scan(/[^\[\]]+/)
|
|
68
|
+
keys.empty? ? [name.to_s] : keys
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def session_get(session, key)
|
|
72
|
+
session[key] || session[key.to_sym]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def session_delete(session, key)
|
|
76
|
+
session.delete(key) if session.respond_to?(:delete)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: otori
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Wout
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
Drop-in honeypot spam protection for any Rack-based app. Combines an
|
|
14
|
+
invisible form field, a submission-timing check, and a JavaScript input
|
|
15
|
+
signals tracker (mouse, touch, scroll, keyboard, focus) into a single
|
|
16
|
+
framework-agnostic gem, with an opt-in Hanami adapter. Ruby companion to
|
|
17
|
+
the Crystal lucky_honeypot shard.
|
|
18
|
+
email:
|
|
19
|
+
- hi@wout.codes
|
|
20
|
+
executables: []
|
|
21
|
+
extensions: []
|
|
22
|
+
extra_rdoc_files: []
|
|
23
|
+
files:
|
|
24
|
+
- CHANGELOG.md
|
|
25
|
+
- LICENSE
|
|
26
|
+
- README.md
|
|
27
|
+
- lib/otori.rb
|
|
28
|
+
- lib/otori/configuration.rb
|
|
29
|
+
- lib/otori/error.rb
|
|
30
|
+
- lib/otori/form.rb
|
|
31
|
+
- lib/otori/hanami.rb
|
|
32
|
+
- lib/otori/signals.rb
|
|
33
|
+
- lib/otori/validator.rb
|
|
34
|
+
- lib/otori/version.rb
|
|
35
|
+
homepage: https://codeberg.org/fluck/otori
|
|
36
|
+
licenses:
|
|
37
|
+
- MIT
|
|
38
|
+
metadata:
|
|
39
|
+
source_code_uri: https://codeberg.org/fluck/otori
|
|
40
|
+
bug_tracker_uri: https://codeberg.org/fluck/otori/issues
|
|
41
|
+
changelog_uri: https://codeberg.org/fluck/otori/src/branch/main/CHANGELOG.md
|
|
42
|
+
rubygems_mfa_required: 'true'
|
|
43
|
+
rdoc_options: []
|
|
44
|
+
require_paths:
|
|
45
|
+
- lib
|
|
46
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
47
|
+
requirements:
|
|
48
|
+
- - ">="
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: '3.2'
|
|
51
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '0'
|
|
56
|
+
requirements: []
|
|
57
|
+
rubygems_version: 4.0.11
|
|
58
|
+
specification_version: 4
|
|
59
|
+
summary: Invisible honeypot spam protection for Rack apps, with a Hanami adapter.
|
|
60
|
+
test_files: []
|