A compiled DSL for bot policies.
defmodule MyApp.BotPolicy do
use Limen.Policy
limit :flood, key: :prefix, rate: 50, per: :second, burst: 100
allow :office, when: signal(:client_ip) in list(:office_networks)
deny :known_bad_ja4, when: signal(:ja4) in list(:bad_ja4), ban: 3_600
score :no_accept_language, 20, when: missing_header("accept-language")
score :datacenter_asn, 30, when: signal(:asn_kind) == :hosting
score :spoofed_browser, 40, when: shape_flag(:no_client_hints)
score :burst, 40, when: rate(:prefix, per: :second) > 20
decide do
score >= 80 -> :deny
score >= 40 -> {:challenge, difficulty: difficulty_for(score)}
true -> :allow
end
endA policy compiles to plain functions: evaluating a request is one call into the policy module, which checks its rules with local calls.
Evaluation
trustrules are checked before anything else, bans included: the first that matches allows the request at the:truststage.limitrules are checked next, for every request the policy covers, including clients holding a valid pass. The first exceeded limit throttles the request.allow,denyandmazerules are checked in the order they are written; the first that matches settles the request.- Every
scorerule that matches adds its weight (which may be negative) to the score. - The first
decideclause whose condition holds picks the action. With nodecideblock, requests scoring 60 or more are challenged.
A rule whose condition raises does not match; the error is recorded in the decision.
Rules
trust name, when: condition- allow and skip everything else: bans, limits, passes and every other rule. For clients the application vouches for, such as signed-in users (seeLimen.put_facts/2); the socket checks ofLimen.SocketandLimen.LiveViewhonour them too, given the policy. Trust rules run before any signal is collected, so their conditions can only use facts, the client identity (signal(:client_ip),:prefix,:ja4and:user_agent), headers, lists and request fields.limit name, key: dimension, rate: n, per: window, burst: b- a hard GCRA limit (seeLimen.State.Gcra) ofnrequests per window (:second,:minute,:houror milliseconds) perdimension, allowingbextra requests at once (default0). Exceeding it throttles withRetry-After.allow name, when: condition- allow and stop.deny name, when: condition- deny and stop. Withban: seconds, the prefix is also banned.maze name, when: condition- send the client to the maze (seeLimen.Maze) and stop. Withban: seconds, the prefix is also flagged, so every later request goes to the maze too.score name, weight, when: condition- addweightto the score.
Conditions
Conditions are Elixir expressions that can use these helpers:
signal(key)- a signal value, seeLimen.Signal. Unknown keys are compile errors.fact(key)- a fact the application stated about the request, such asfact(:signed_in), ornil; seeLimen.put_facts/2.header(name),missing_header(name),has_header(name).shape_flag(flag)- whetherLimen.Signal.HttpShaperaisedflag.rate(dimension, per: window)- requests the policy saw in the sliding:second,:minuteor:hourfrom this client's:prefix,:client_ip,:ja4, or from everyone (:global).value in list(name)- membership in aLimen.Listslist.path(),method(),host(),query().
A signal that was not collected, or has no value for this request, is
nil. Comparing it with <, <=, > or >= is false, so
signal(:asset_ratio) > 0.8 does not hold for a client that has fetched no
pages yet.
param(name)- a tunable parameter, see "Parameters" below.
In decide, score is the total score and difficulty_for(score) (or
difficulty_for(score, opts), see Limen.Policy.Runtime.difficulty_for/2)
maps it to a challenge difficulty. Actions are those of Limen.Decision,
with options: :deny, {:deny, ban: 600}, {:challenge, difficulty: 18},
{:throttle, retry_after: 30}, {:tarpit, delay: 5_000}, :maze,
{:maze, ban: 86_400}.
Parameters
Weights and thresholds are code, but tuning them should not need a
deploy. Declare parameters with their defaults, and use param(name) in
conditions, as a score weight, and in decide:
use Limen.Policy, params: [hosting_weight: 25, challenge_at: 40]
score :hosting_asn, param(:hosting_weight), when: signal(:asn_kind) == :hosting
decide do
score >= param(:challenge_at) -> {:challenge, difficulty: difficulty_for(score)}
true -> :allow
endAn instance's :params option (see Limen.Config) overrides the
defaults, at startup or at runtime with Limen.update_config/3:
config :my_app, Limen, params: [challenge_at: 50]Parameters are named per instance: policies of the same instance that
declare the same name share its configured value. Reading one costs a
map lookup in the instance's configuration. Every value used is recorded:
in a rule's observed values (param(:hosting_weight) = 25, and its weight
in the match), and in the decision's clause_observed for decide. A
weight whose configured value is not an integer falls back to its
default. Using a parameter the policy does not declare is a compile
error.
Options
:signals- signal modules the policy can use. Defaults toLimen.Signal.defaults/0. Only the values of these signals (and the client identity) can be referenced, and only the modules providing a value the policy refers to are collected.:mode-:dry_runor:enforcefor routes using this policy, unless the route itself sets a mode. Defaults to the global mode.:params- the policy's parameters and their defaults, see "Parameters".
Explanations
For every rule that matched, the decision records the source of its
condition and the value of every helper it used, so Limen.Decision.explain/1
shows exactly why a request was treated the way it was. describe/1
renders the policy itself.
Summary
Functions
Allows the request when condition holds, skipping scoring.
Checks the trust rules of policy against ctx: the match of the first
that holds, or the errors of those that raised.
Maps the total score to an action.
Denies the request when condition holds, skipping scoring.
Renders policy for humans.
Evaluates the allow, deny and score rules of policy and its decide
block against ctx.
Declares a hard rate limit.
Sends the client to the maze when condition holds, skipping scoring.
Adds weight to the score when condition holds.
Allows the request when condition holds, before bans, limits and every
other rule.
Types
@type result() :: %{ action: Limen.Decision.action(), params: map(), stage: :rule | :decide, score: integer(), matches: [Limen.Decision.Match.t()], clause: String.t() | nil, clause_observed: [{String.t(), term()}], errors: [term()] }
Functions
Allows the request when condition holds, skipping scoring.
@spec check_trust(module(), Limen.Context.t()) :: {:trusted, Limen.Decision.Match.t()} | {:untrusted, [term()]}
Checks the trust rules of policy against ctx: the match of the first
that holds, or the errors of those that raised.
Maps the total score to an action.
Denies the request when condition holds, skipping scoring.
Renders policy for humans.
@spec evaluate(module(), Limen.Context.t()) :: result()
Evaluates the allow, deny and score rules of policy and its decide
block against ctx.
Trust rules and limits are checked separately, see check_trust/2 and
Limen.Policy.Runtime.check_limits/2.
Declares a hard rate limit.
Sends the client to the maze when condition holds, skipping scoring.
Adds weight to the score when condition holds.
Allows the request when condition holds, before bans, limits and every
other rule.