Honeypots: places only automated clients go, and what happens when they do.
People follow the links they can see and fill in the fields they are
shown. Scrapers follow every link in the markup and fill in every field of
a form. Limen offers two kinds of traps built on that difference, and a
client that falls into either is flagged: its prefix is banned with
action: :maze for the trap's :ban seconds, and it gets the endless,
slow pages of Limen.Maze instead of your application from then on. It is
never told it was caught.
Link traps
Configure one or more trap paths, which Limen answers before your application sees them:
config :my_app, Limen, trap: [paths: ["/archive/directory"]]then make sure only a scraper can get there: disallow the paths in
robots.txt (robots/1), the file where a site tells
crawlers what not to fetch, so well-behaved crawlers stay away, and put a
link to them that people never see in your layout (link/2):
<footer>
...
{Limen.Trap.link(:my_app)}
</footer>A request under a trap path settles at the :trap stage: its decision
records the trap, the prefix is flagged, and the client is served a maze
page whose links all lead further into the trap. Crawlers verified by
reverse DNS (see Limen.Signal.Fcrdns) are never flagged, and neither are
requests claiming to be a crawler whose verification is still pending.
Form traps
In a form people fill in (sign up, contact, comments), add a decoy field
and a signed timestamp with form_fields/2, and check the submission with
check_form/3:
<.form for={@form} phx-submit="save">
{Limen.Trap.form_fields(:my_app)}
...
</.form>
case Limen.Trap.check_form(conn, params, otp_app: :my_app) do
{:ok, _decision} -> create_account(params)
{:trapped, _decision} -> pretend_it_worked(conn)
endA submission is trapped when the decoy field is filled in, when it came
faster than :min_fill_time after the form was rendered, or when its
timestamp is missing or forged. Answer trapped submissions as if they
succeeded, so the client learns nothing.
Dry-run
In dry-run mode traps work exactly the same, except for the final action:
the decision is recorded with enforced: false, the flag is recorded as a
dry-run ban (reported, never enforced), requests under trap paths continue
to your application, and check_form/3 returns {:ok, decision}.
See the :trap options in Limen.Config, and the honeypots guide.
Summary
Functions
Checks a form submission for the tells of form_fields/2.
The hidden fields of a form trap: a decoy field people never see, and a signed timestamp of when the form was rendered.
A URL under a trap path, for links only automated clients follow.
A link to a trap that people never see.
The trap paths of instance.
The robots.txt lines keeping well-behaved crawlers out of the traps of
instance, one Disallow line per trap path.
Functions
@spec check_form(Plug.Conn.t() | map(), map(), keyword()) :: {:ok, Limen.Decision.t()} | {:trapped, Limen.Decision.t()}
Checks a form submission for the tells of form_fields/2.
source is the Plug.Conn of the submission, or, from a LiveView, its
connect info (see Limen.LiveView.check_form/3). params are the
submitted fields; the trap's fields are read from the top level.
Every check is a decision at the :trap stage, emitted and sampled like
any other. A trapped submission also flags the client prefix for the
maze.
Options
:instanceor:otp_app- the instance checking the form, as forLimen.Plug. Defaults, for aPlug.Conn, to the instance that made the decision for the request.:mode-:dry_runor:enforce, overriding the instance's mode.:facts- for connect info, facts about the connection, as forLimen.put_facts/2. APlug.Conncarries its own.
Returns {:trapped, decision} when the submission must not be acted on
(never in dry-run mode), and {:ok, decision} otherwise.
@spec form_fields(atom() | Limen.Instance.t(), keyword()) :: {:safe, iodata()}
The hidden fields of a form trap: a decoy field people never see, and a signed timestamp of when the form was rendered.
Returns {:safe, iodata} to put inside a form. Check submissions with
check_form/3.
Options
:class- a CSS class hiding the decoy field, used instead of the inlinestyleattribute.
@spec href(atom() | Limen.Instance.t(), keyword()) :: String.t()
A URL under a trap path, for links only automated clients follow.
It reads like any other URL of the maze and is the same every time for an instance (it is derived from the instance's secret).
Options
:path- the trap path to link into. Defaults to the first one.
@spec link(atom() | Limen.Instance.t(), keyword()) :: {:safe, iodata()}
A link to a trap that people never see.
The link is kept off screen, out of the tab order (tabindex="-1"), away
from assistive technologies (aria-hidden) and away from crawlers that
honour rel="nofollow". Returns {:safe, iodata}, which Phoenix templates
render as is; elsewhere, use the iodata.
Options
:path- the trap path to link into. Defaults to the first one.:text- the link text. Defaults to a phrase of the instance's maze.:class- a CSS class hiding the link, used instead of the inlinestyleattribute, for pages whose Content-Security-Policy forbids inline styles.
@spec paths(atom() | Limen.Instance.t()) :: [String.t()]
The trap paths of instance.
@spec robots(atom() | Limen.Instance.t()) :: String.t()
The robots.txt lines keeping well-behaved crawlers out of the traps of
instance, one Disallow line per trap path.
get "/robots.txt", fn conn ->
text(conn, "User-agent: *\n" <> Limen.Trap.robots(:my_app))
end