{"id":51849916,"url":"https://github.com/flucksite/otori","last_synced_at":"2026-07-23T17:30:42.698Z","repository":{"id":367894338,"uuid":"1246581027","full_name":"flucksite/otori","owner":"flucksite","description":"Invisible captcha spam protection for any Rack-based Ruby app, with a built-in Hanami adapter. ","archived":false,"fork":false,"pushed_at":"2026-06-28T05:55:27.000Z","size":26,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-28T07:19:38.703Z","etag":null,"topics":["hanami","honeypot","rack","ruby","security","signals"],"latest_commit_sha":null,"homepage":"https://codeberg.org/fluck/otori","language":"Ruby","has_issues":false,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/flucksite.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-22T10:36:27.000Z","updated_at":"2026-06-28T05:55:30.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/flucksite/otori","commit_stats":null,"previous_names":["flucksite/otori"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/flucksite/otori","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/flucksite%2Fotori","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/flucksite%2Fotori/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/flucksite%2Fotori/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/flucksite%2Fotori/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/flucksite","download_url":"https://codeload.github.com/flucksite/otori/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/flucksite%2Fotori/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35811417,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-07-20T02:08:10.276Z","status":"online","status_checked_at":"2026-07-23T02:00:06.683Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["hanami","honeypot","rack","ruby","security","signals"],"created_at":"2026-07-23T17:30:41.904Z","updated_at":"2026-07-23T17:30:42.686Z","avatar_url":"https://github.com/flucksite.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Otori\n\n[![CI](https://codeberg.org/fluck/otori/actions/workflows/ci.yml/badge.svg)](https://codeberg.org/fluck/otori/actions?workflow=ci.yml)\n[![Version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fcodeberg.org%2Fapi%2Fv1%2Frepos%2Ffluck%2Fotori%2Ftags\u0026query=%24%5B0%5D.name\u0026label=version)](https://codeberg.org/fluck/otori/tags)\n\nInvisible honeypot spam protection for Hanami, Sinatra, Roda, Rails, and any\nother Rack-based Ruby app. Think of it as a framework-agnostic\n[invisible_captcha](https://github.com/markets/invisible_captcha) with a\nfirst-class Hanami adapter and an extra layer of JavaScript-based input\ndetection.\n\n_Otori_ (囮) is the Japanese word for \"decoy\", historically a bird used in\nhunting to draw others in.\n\nThis is a Ruby companion to the [Crystal lucky_honeypot\nshard](https://codeberg.org/fluck/lucky_honeypot). It combines three classic\ntechniques into one gem:\n\n1. **Invisible fields**. Bots fill out every field, including ones hidden with CSS.\n2. **Timing checks**. Bots submit forms instantly, humans need more time.\n3. **Input signals**. Bots don't tend to trigger mouse, touch, scroll, keyboard, or focus events.\n\nWhen either of the first two checks fail, the submission is quietly rejected.\nThe bot thinks it succeeded and moves on. The third one can be used to reject\nor flag submissions at a chosen _human rating_ threshold.\n\n\u003e [!NOTE]\n\u003e The original repository is hosted at\n\u003e [Codeberg](https://codeberg.org/fluck/otori). The [GitHub\n\u003e repo](https://github.com/flucksite/otori) is just a mirror.\n\n## How it compares\n\n### otori\n\n- Frameworks: Hanami, Sinatra, Roda, Rails, any Rack app\n- Hidden field + timing check + JS input signals\n- Active (new in 2026)\n\n### [invisible_captcha](https://github.com/markets/invisible_captcha)\n\n- Frameworks: Rails only\n- Hidden field + timing check\n- Actively maintained\n\n### [rack-honeypot](https://github.com/sunlightlabs/rack-honeypot)\n\n- Frameworks: any Rack app\n- Hidden field only\n- Dormant since 2016\n\nIf you're on Rails and don't need the JS signals layer, `invisible_captcha` is\nthe more battle-tested choice. Otori exists for everything else.\n\n## Installation\n\nAdd this to your Gemfile:\n\n```ruby\ngem \"otori\"\n```\n\nThen run `bundle install`. Ruby 3.2 or newer is required.\n\n## Quickstart\n\nThe gem ships a framework-agnostic core plus an opt-in Hanami adapter. The core\nAPI is three calls:\n\n```ruby\nOtori.field(\"user[website]\", session: request.session)\nOtori.signals_field\nOtori.caught?(\"user[website]\", params: request.params, session: request.session)\n```\n\n`field` renders the invisible input and stores a load timestamp in the\nsession. `signals_field` renders a hidden input plus the JavaScript that\ntracks human input. `caught?` checks the submitted form and returns `true`\nwhen the request looks like a bot.\n\nField names use standard HTML bracket notation, so `\"user[website]\"` lives\nunder `params[:user][:website]` once submitted. Flat names like `\"note\"` work\ntoo. Pick whichever fits the surrounding form, the more believable the\nhoneypot looks next to the real fields, the better.\n\n## Framework integration\n\n### Hanami\n\nThe Hanami adapter is loaded explicitly so the base gem stays dependency-free:\n\n```ruby\nrequire \"otori/hanami\"\n```\n\nMix the helpers into your views:\n\n```ruby\n# app/views/helpers.rb\nmodule MyApp\n  module Views\n    module Helpers\n      include Otori::Hanami::Helpers\n    end\n  end\nend\n```\n\nIn a form template:\n\n```erb\n\u003c%= honeypot_field(\"user[website]\") %\u003e\n\u003c%= honeypot_signals %\u003e\n```\n\nThe helpers mark their output safe via `String#html_safe`, which Hanami View\nprovides out of the box, so the HTML flows through ERB without escaping.\n\nGuard the receiving action with the `honeypot` DSL method:\n\n```ruby\n# app/actions/sign_ups/create.rb\nmodule MyApp\n  module Actions\n    module SignUps\n      class Create \u003c MyApp::Action\n        include Otori::Hanami::Action\n\n        honeypot \"user[website]\"\n\n        def handle(request, response)\n          # ...\n        end\n      end\n    end\n  end\nend\n```\n\nWhen the honeypot is tripped, the action halts with `204 No Content` by\ndefault. To customize the response, pass a block:\n\n```ruby\nhoneypot \"user[website]\" do |_request, response|\n  response.flash[:info] = \"Moving on...\"\n  response.redirect_to \"/\", status: 303\nend\n```\n\nMultiple honeypots are supported, each with its own timing and handler:\n\n```ruby\nhoneypot \"user[website]\", wait: 5\nhoneypot \"note\" do |_req, _res|\n  halt 422\nend\n```\n\nTo act on the input-signals rating, evaluate it inside `handle`:\n\n```ruby\ndef handle(request, response)\n  rating = Otori.signals_rating(request.params.to_h)\n  halt 204 if rating \u003c 0.4\n\n  # ...\nend\n```\n\n### Rack (Sinatra, Roda, plain Rack)\n\nThere is no adapter to require, the core API is enough. In a Sinatra app:\n\n```ruby\nrequire \"otori\"\n\nenable :sessions\n\nget \"/sign_up\" do\n  erb :sign_up\nend\n\npost \"/sign_up\" do\n  halt 204 if Otori.caught?(\n    \"user[website]\",\n    params: params,\n    session: session\n  )\n\n  # ...\nend\n```\n\nIn the view:\n\n```erb\n\u003c%= Otori.field(\"user[website]\", session: session) %\u003e\n\u003c%= Otori.signals_field %\u003e\n```\n\n### Rails\n\nRails is well served by [invisible_captcha](https://github.com/markets/invisible_captcha)\nwhen all you need is a hidden field and a timing check. Use this gem in Rails\nif you want the input-signals rating on top.\n\n```ruby\n# app/helpers/application_helper.rb\nmodule ApplicationHelper\n  def honeypot_field(name, **attrs)\n    Otori.field(name, session: session, **attrs).html_safe\n  end\n\n  def honeypot_signals(**attrs)\n    Otori.signals_field(**attrs).html_safe\n  end\nend\n```\n\n```ruby\n# app/controllers/sign_ups_controller.rb\nclass SignUpsController \u003c ApplicationController\n  before_action :check_honeypot, only: :create\n\n  def create\n    # ...\n  end\n\n  private\n\n  def check_honeypot\n    return unless Otori.caught?(\n      \"user[website]\",\n      params: params.to_unsafe_h,\n      session: session\n    )\n\n    head :no_content\n  end\nend\n```\n\n## Configuration\n\n```ruby\nOtori.configure do |c|\n  # Required delay (in seconds) between page load and form submission.\n  c.default_delay = 2.0\n\n  # Disables the submission delay entirely. Useful in tests.\n  c.disable_delay = false\n\n  # Name of the hidden input that carries the signals payload.\n  c.signals_input_name = \"honeypot_signals\"\nend\n```\n\n## The invisible field\n\nBy default the field is rendered with an inline `style` attribute that takes\nit out of the visual flow without breaking accessibility tools. Pass your own\n`class` (or `style`) to opt out of the default style and use your CSS instead:\n\n```ruby\nOtori.field(\"user[website]\", session: session, class: \"visually-hidden\")\n```\n\nUnderscored attribute keys are converted to dashes so `data_foo: \"bar\"`\nrenders as `data-foo=\"bar\"`. Any other attribute pair is passed through\nunchanged.\n\n\u003e [!NOTE]\n\u003e The field stores a load timestamp in the session under a\n\u003e `honeypot_field_\u003cname\u003e` key. The companion `caught?` call reads and\n\u003e clears it on success, or resets it when the form is rejected.\n\n## Detecting input signals\n\n`signals_field` renders a hidden input plus a small inline `\u003cscript\u003e` that\nlistens for the first occurrence of each of five events on the surrounding\nform: `mousemove`, `touchstart`, `keydown`, `focusin`, and a window-level\n`scroll`. On submit, the boolean results are serialized to JSON and posted\nalong with the rest of the form.\n\nIn the action, get the rating directly from the params:\n\n```ruby\nOtori.signals_rating(params)        # =\u003e 0.0 to 1.0\n```\n\nOr work with the parsed object for more detail:\n\n```ruby\nsignals = Otori::Signals.from_json(params[\"honeypot_signals\"])\nsignals.human_rating  # 0 (bot) to 1 (human)\nsignals.mouse?\nsignals.touch?\nsignals.scroll?\nsignals.keyboard?\nsignals.focus?\n```\n\n\u003e [!NOTE]\n\u003e The human rating is the fraction of the five signals that fired, so each one\n\u003e contributes `0.2`. A score of `0` is almost certainly a dumb bot, while `0.2`\n\u003e could be a sophisticated bot triggering a single signal (almost always\n\u003e `mouse`), though a human filling out a short form at the top of the page may\n\u003e also land there.\n\u003e\n\u003e `0.4` is a reasonable threshold for flagging entries: it still catches bots\n\u003e that fake one or two signals, but avoids false positives for autofill and\n\u003e password manager submissions, which often only trigger focus plus mouse or\n\u003e touch.\n\n## Security considerations\n\nThis gem provides basic bot protection, but it should not be your only line\nof defense.\n\n- It is not foolproof, sophisticated bots can bypass honeypots.\n- Combine this with a rate limiter such as `rack-attack`.\n- For high-value forms, consider adding CAPTCHA or email verification.\n- The submission timestamp is stored in the session. If sessions are\n  compromised, an attacker could manipulate timing checks. Make sure your session\n  store uses signed and encrypted cookies.\n- The timing check compares wall-clock timestamps, which makes it resilient to\n  timing attacks since the check is a simple threshold comparison.\n- This gem does not touch CSRF tokens. Honeypot fields are regular form inputs\n  and do not interfere with your framework's CSRF protection.\n\nFor most use cases (contact forms, newsletter signups), this gem provides solid\nprotection with zero user friction. Expect it to catch between 60% and 90% of\nautomated form submissions.\n\n## Development\n\n```bash\nbundle install\nbundle exec rspec\nbundle exec rubocop\n```\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fflucksite%2Fotori","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fflucksite%2Fotori","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fflucksite%2Fotori/lists"}