Limen.Policy (limen v0.2.1)

Copy Markdown View Source

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
end

A policy compiles to plain functions: evaluating a request is one call into the policy module, which checks its rules with local calls.

Evaluation

  1. trust rules are checked before anything else, bans included: the first that matches allows the request at the :trust stage.
  2. limit rules are checked next, for every request the policy covers, including clients holding a valid pass. The first exceeded limit throttles the request.
  3. allow, deny and maze rules are checked in the order they are written; the first that matches settles the request.
  4. Every score rule that matches adds its weight (which may be negative) to the score.
  5. The first decide clause whose condition holds picks the action. With no decide block, 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 (see Limen.put_facts/2); the socket checks of Limen.Socket and Limen.LiveView honour 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, :ja4 and :user_agent), headers, lists and request fields.
  • limit name, key: dimension, rate: n, per: window, burst: b - a hard GCRA limit (see Limen.State.Gcra) of n requests per window (:second, :minute, :hour or milliseconds) per dimension, allowing b extra requests at once (default 0). Exceeding it throttles with Retry-After.
  • allow name, when: condition - allow and stop.
  • deny name, when: condition - deny and stop. With ban: seconds, the prefix is also banned.
  • maze name, when: condition - send the client to the maze (see Limen.Maze) and stop. With ban: seconds, the prefix is also flagged, so every later request goes to the maze too.
  • score name, weight, when: condition - add weight to the score.

Conditions

Conditions are Elixir expressions that can use these helpers:

  • signal(key) - a signal value, see Limen.Signal. Unknown keys are compile errors.
  • fact(key) - a fact the application stated about the request, such as fact(:signed_in), or nil; see Limen.put_facts/2.
  • header(name), missing_header(name), has_header(name).
  • shape_flag(flag) - whether Limen.Signal.HttpShape raised flag.
  • rate(dimension, per: window) - requests the policy saw in the sliding :second, :minute or :hour from this client's :prefix, :client_ip, :ja4, or from everyone (:global).
  • value in list(name) - membership in a Limen.Lists list.
  • 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
end

An 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 to Limen.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_run or :enforce for 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

result()

@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

allow(name, opts)

(macro)

Allows the request when condition holds, skipping scoring.

check_trust(policy, ctx)

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

decide(list)

(macro)

Maps the total score to an action.

deny(name, opts)

(macro)

Denies the request when condition holds, skipping scoring.

describe(policy)

@spec describe(module()) :: String.t()

Renders policy for humans.

evaluate(policy, ctx)

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

limit(name, opts)

(macro)

Declares a hard rate limit.

maze(name, opts)

(macro)

Sends the client to the maze when condition holds, skipping scoring.

score(name, weight, opts)

(macro)

Adds weight to the score when condition holds.

trust(name, opts)

(macro)

Allows the request when condition holds, before bans, limits and every other rule.