Packages

Invisible honeypot spam protection for any Plug-based Elixir app.

Current section

Files

Jump to
honeytrap README.md
Raw

README.md

# Honeytrap
[![CI](https://codeberg.org/fluck/honeytrap/actions/workflows/ci.yml/badge.svg)](https://codeberg.org/fluck/honeytrap/actions?workflow=ci.yml)
[![Version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fcodeberg.org%2Fapi%2Fv1%2Frepos%2Ffluck%2Fhoneytrap%2Ftags&query=%24%5B0%5D.name&label=version)](https://codeberg.org/fluck/honeytrap/tags)
Invisible honeypot spam protection for any Plug-based Elixir app.
Bots fill every form field they can find. Honeytrap adds a hidden field that
real users never touch. It also measures how fast the form was submitted and
collects a few passive interaction signals. If any of those look wrong, the
request is flagged. You decide what to do with the flag.
## Install
Add `honeytrap` to your deps in `mix.exs`:
```elixir
def deps do
[
{:honeytrap, "~> 0.2.0"}
]
end
```
## Install the signals script
The signals collection script ships with the package and must run once per
page, from your assets bundle. Never load it through an inline `<script>` tag:
such tags do not execute when a LiveView patches the page.
The script listens at the document level and fills every
`data-honeytrap-signals` input just before its form submits. It works for plain
controller forms and LiveView forms alike, no extra setup per form.
### With esbuild
This is the Phoenix default. Add one alias flag to your existing esbuild args
(you likely have the rest already):
```elixir
# config/config.exs
config :esbuild,
version: "0.17.11",
args: [
# ...your existing args...
"--alias:honeytrap=" <>
Path.expand("../deps/honeytrap/assets/js/honeytrap.js", __DIR__)
]
```
```js
// assets/js/app.js
import 'honeytrap'
```
### With BunBundle
Use the `$/` root alias instead, no config needed:
```js
// assets/js/app.js
import '$/deps/honeytrap/assets/js/honeytrap.js'
```
## Usage
### 1. Arm the trap
Set a timestamp on the session when you render the form. Do this in your
controller action.
```elixir
def new(conn, _params) do
conn = Honeytrap.arm(conn, :website)
render(conn, :new)
end
```
### 2. Render the fields
Add the honeypot field and the signals field to your form template.
```heex
<form action={~p"/contact"} method="post">
<%= Honeytrap.field(:website) %>
<%= Honeytrap.signals_field() %>
<label>Message <textarea name="message"></textarea></label>
<button type="submit">Send</button>
</form>
```
> [!NOTE]
> The honeypot field is hidden with inline styles by default. Pass a `:class`
> or `:style` to use your own hiding.
For nested params, pass a path instead of a flat name. The path is also what
you give the Plug or `check/3`, so render and check always agree:
```heex
<.form for={@form} phx-submit="save">
<%= Honeytrap.field("user[website]") %>
<%= Honeytrap.signals_field() %>
</.form>
```
renders `<input name="user[website]">`, which lands in
`params["user"]["website"]`.
### 3. Plug the check into your pipeline
```elixir
pipeline :submissions do
plug Honeytrap.Plug, fields: [:website]
end
```
On matching requests the plug puts a result in `conn.assigns.honeytrap`:
```elixir
%{
bot: true,
reasons: [filled: :website, filled: :phone],
human_rating: 0.2
}
```
Each field contributes at most one reason, the first that applies.
### 4. Act on the result
Honeytrap does not halt the pipeline. Your action decides what to do.
That way you keep an escape hatch for false positives.
```elixir
def create(conn, params) do
if conn.assigns.honeytrap.bot do
render(conn, :thanks)
else
Contact.deliver(params)
render(conn, :thanks)
end
end
```
## LiveView forms
LiveView events never pass through the Plug pipeline, so call `Honeytrap.check/3`
from your `handle_event` callbacks instead. A check is one-shot: re-arm after
every check, or resubmissions keep riding the old timestamp and the delay
check silently weakens.
```elixir
@honeytrap_field "user[website]"
def mount(_params, _session, socket) do
{:ok, arm_honeytrap(socket)}
end
def handle_event("save", params, socket) do
if honeytrap_bot?(socket, params) do
{:noreply, put_flash(socket, :error, "That looked automated")}
else
# ... real save logic ...
{:noreply, arm_honeytrap(socket)}
end
end
defp arm_honeytrap(socket) do
assign(socket, :honeytrap_armed, Honeytrap.arm_field(@honeytrap_field))
end
defp honeytrap_bot?(socket, params) do
Honeytrap.check(params, socket.assigns.honeytrap_armed, fields: [@honeytrap_field]).bot
end
```
## Hybrid forms
For a LiveView form that submits to a controller via `phx-trigger-action`, the
POST goes through your pipeline as usual. But a LiveView cannot write to the
Plug session, so arm from the controller that renders the page, not from
`mount`:
```elixir
def new(conn, _params) do
conn = Honeytrap.arm(conn, :website)
live_render(conn, MyLive, session: %{})
end
```
The Plug then consumes the session timestamp on the POST, same as a plain
controller form.
## Configuration
Pass options to the plug directly:
- `:fields` (required). List of honeypot field names to check. Set per plug
call, since different forms use different fields.
These options can also be set globally via `Application` env under the
`:honeytrap` key. Per-plug values override the global default.
- `:default_delay` (default `2.0`). Minimum seconds between arm and submit.
- `:disable_delay` (default `false`). Skip the elapsed time check.
- `:minimum_human_rating` (default `nil`). If set, flags submissions below this
score.
- `:signals_input_name` (default `"honeytrap_signals"`). Name of the signals
input.
Example app config:
```elixir
# config/config.exs
config :honeytrap, default_delay: 3.0
```
## How it works
Three independent checks:
1. **Filled honeypot**. Real users cannot see the field. Bots often fill it
anyway.
2. **Elapsed time**. Real users take at least a second or two. Instant submits
are suspicious.
3. **Human signals**. A tiny script watches for mouse, touch, scroll, keyboard,
and focus events on the form. The result is packed into a hidden JSON blob.
You can act on the rating or ignore it.
The Plug stores its arming timestamps in the session, LiveView callers keep
them in socket assigns. No cookies beyond what you already have.
## License
MIT.