Limen.Trap (limen v0.2.1)

Copy Markdown View Source

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.

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)
end

A 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

check_form(source, params, opts \\ [])

@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

  • :instance or :otp_app - the instance checking the form, as for Limen.Plug. Defaults, for a Plug.Conn, to the instance that made the decision for the request.
  • :mode - :dry_run or :enforce, overriding the instance's mode.
  • :facts - for connect info, facts about the connection, as for Limen.put_facts/2. A Plug.Conn carries its own.

Returns {:trapped, decision} when the submission must not be acted on (never in dry-run mode), and {:ok, decision} otherwise.

form_fields(instance, opts \\ [])

@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 inline style attribute.

href(instance, opts \\ [])

@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.

link(instance, opts \\ [])

@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 inline style attribute, for pages whose Content-Security-Policy forbids inline styles.

paths(instance)

@spec paths(atom() | Limen.Instance.t()) :: [String.t()]

The trap paths of instance.

robots(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