Skip to content

Evaluation Order

What runs, in what order, when Firewall::evaluate() is called. This is the single most misunderstood thing in the library, and most "why didn't my allow rule win?" questions are answered by one line of it.

flowchart TD
    START(["evaluate()"]) --> CLI{"CLI SAPI,<br/>and mode is not exception?"}
    CLI -->|yes| ALLOW1(["allowed — nothing evaluated"])
    CLI -->|no| DISABLED{"mode is disabled?"}
    DISABLED -->|yes| ALLOW2(["allowed — nothing evaluated"])
    DISABLED -->|no| SUB{"POST to the<br/>challenge path?"}

    SUB -->|yes| SUBLIST{"on the<br/>block list?"}
    SUBLIST -->|yes| BLOCKED1(["blocked"])
    SUBLIST -->|no| SOLVE["verify the solution,<br/>mint or refuse a pass token"]
    SOLVE --> DONE(["handled"])

    SUB -->|no| BUCKET1["allow bucket<br/>(sorted by weight)"]
    BUCKET1 -->|match| ALLOW3(["allowed — nothing below runs"])
    BUCKET1 -->|no match| LIST{"on the<br/>block list?"}

    LIST -->|yes| BLOCKED2(["blocked — offense recorded"])
    LIST -->|no| MRK["mark bucket<br/>(annotates, does not act)"]
    MRK --> REC["record bucket<br/>(writes, does not refuse)"]
    REC --> BUCKET2["challenge bucket<br/>(sorted by weight)"]

    BUCKET2 -->|"match, holds a pass token<br/>for that provider"| RDR
    BUCKET2 -->|"match, no token"| CHALLENGE(["interstitial served"])
    BUCKET2 -->|"no match"| RDR["redirect bucket<br/>(sorted by weight)"]
    RDR -->|match| SENT(["sent elsewhere — 302"])
    RDR -->|no match| BUCKET3["block bucket<br/>(sorted by weight)"]

    BUCKET3 -->|match| BLOCKED3(["blocked — offense recorded"])
    BUCKET3 -->|no match| ALLOW4(["allowed"])

The order, in words

Step Notes
1 CLI short-circuit Returns immediately under PHP_SAPI === 'cli' for every mode except exception. Drush, WP-CLI, Artisan and cron have no visitor to protect.
2 mode: disabled Nothing is evaluated. A panic file can put you here without a deploy.
3 Challenge submission A POST to challenge.path is intercepted before any bucket, so an unrelated rule can never trap a visitor in a challenge loop. The block list is still enforced first — a client that already earned a ban does not get to solve its way out.
4 Allow bucket First bucket. A match ends evaluation.
5 Durable block list Storage-backed repeat-offender state, from earlier requests.
6 Mark bucket response: mark annotates the request and changes nothing else.
7 Record bucket response: record writes the client to the block list and lets this request through.
8 Challenge bucket A valid pass token for that rule's provider skips it.
9 Redirect bucket response: redirect sends the visitor to metadata.redirect_to.
10 Block bucket A match refuses the request, and records it unless metadata.record: false.
11 Allowed Nothing objected.

The three things people get wrong

Weights sort within a bucket, not across them

weight orders rules inside one bucket. It has no effect between buckets, because the bucket order is fixed. An allow rule with the worst weight in the file still beats a block rule with the best:

ALLOWED  GET /
  allowed by        allow-last

Configured but not reached:
  block     block-first    Kanopi\Firewall\Plugins\IpAddress    weight -9999

So if an allow rule appears not to be winning, it almost certainly did not match — check the CIDR, and check that the client IP is what you think it is. firewall-check --explain prints which rules were evaluated and which were never reached.

response: allow also beats the durable block list

The allow bucket runs at step 4 and the block list at step 5, so a client that is on the block list and matches an allow rule is let through. That is a bypass, and it is what an allow rule is for — but it means an allow rule is not a safe place for a broad range:

# This lets a banned client back in for as long as it is in the range.
- plugin: "Kanopi\\Firewall\\Plugins\\IpAddress"
  response: allow
  config: ['203.0.113.0/24']

A pass token skips the challenge bucket, not the block bucket

Solving a challenge attests "I am human". It does not attest "I am allowed everywhere", so block rules still run afterwards. A token is also only worth the provider it was earned against — a math token does not satisfy a turnstile rule.

Refusing and recording are separate

A block does two things: it refuses this request, and it writes the client to the durable block list so later ones are refused too. Those were one action until 2.26.0, and each half is wanted without the other.

Refuses this request Writes to the block list
response: block
response: block + metadata.record: false
response: record
response: redirect sends elsewhere only with metadata.record: true
response: mark
metadata.mode: log

response: record serves the request normally and blocks the next one. That is what a honeypot needs: refusing the fetch tells a scanner exactly which URL is wired, which is the one thing a honeypot must not do. It runs after the block list and before the terminal buckets, so an allow rule still wins and a client already blocked is refused rather than re-recorded.

response: redirect is terminal like a block, and runs before it: the terminal buckets go gentlest first, so a rule offering the visitor somewhere to go beats one that would simply refuse them. It records nothing by default — a redirect is a signpost, not a ban, and somebody sent to a notice page who came back to find themselves blocked instead would have no way to understand why. The defaults are opposite on purpose: a block records unless told not to, a redirect records only when told to.

response: mark does neither. It annotates the request — firewall.mark.<name>, plus firewall.marks listing everything raised — and leaves the decision to the application. A comment form can then show a CAPTCHA only to requests the firewall found suspicious rather than to everybody. It runs before anything terminal, so a request that is also blocked is still marked: a signal that only appeared on requests nobody refused would be one you could not correlate with anything.

The attribute reaches only code holding the same Request

A Symfony or Laravel integration passes its own Request and sees it. A settings.php bootstrap that later builds a fresh one does not. metadata.mark_header also sets a header, and the RequestMarked decision event is the channel that always arrives.

metadata.record: false refuses and leaves nothing behind. A deliberate, temporary refusal of everybody is not evidence that anybody misbehaved — recording them means lifting it leaves a block list full of customers, each on an escalating ban.

Both log at warning. A recorded client saw an ordinary response and left an ordinary line in the access log, so that log entry is the only evidence the rule fired — and the next request being refused makes no sense without it.

What the modes change

Mode Buckets evaluated Storage written Request continues
block yes yes no — response sent, process exits
exception yes yes no — throws, your framework renders
log yes no yes — every decision is reported and none is enforced
disabled no no yes

log is the one worth reading twice: it evaluates everything and enforces nothing, including the durable block list. A listed client is logged and let through, and the ban is neither enforced nor extended.

An individual rule can observe on its own with metadata.mode: log — it matches, logs at warning with enforced: false, and is then treated as no match, so everything after it still runs. That is almost always the better tool than putting the whole site in log.

Seeing it for a real request

$ firewall-check --config=firewall.yml --ip=203.0.113.9 --url=/checkout --explain

It prints the buckets in the order above, which rule matched, and which rules were configured but never reached.