Troubleshooting¶
Organised by the sentence you would actually type, not by subsystem. Each entry is the symptom, the one command that confirms it, and the fix.
Almost all of these are answered by firewall-check --explain, which evaluates a request against a throwaway store — so asking about an address can never ban it.
"My allow rule isn't working"¶
Confirm it:
Read the Plugins evaluated, in order list. If the allow rule is not in it, it did not match. If it is in it and the request was still blocked, that is a different problem — see below.
The usual cause is the opposite of what people expect: weights do not decide this.
Buckets are consulted in a fixed order — allow, then challenge, then block — and weight only sorts rules within one bucket. An allow rule with the worst weight in the file still beats a block rule with the best:
ALLOWED GET /
client 198.51.100.7
allowed by allow-last
Configured but not reached:
block block-first Kanopi\Firewall\Plugins\IpAddress weight -9999
So "my allow rule is losing to a block rule" is almost never true. What is usually true is that the allow rule did not match the request — a CIDR that does not contain the address, or a client IP that is not what you think it is (see Everything is blocked).
"Legitimate traffic is being blocked"¶
Confirm it:
The verdict names the rule: blocked by crs-paranoia-2. If your rules have no metadata.name, give them one — every log line about a rule is otherwise identified only by its class, and two rules of the same class are indistinguishable.
The fix, in order of how much you should reach for it:
-
Put that one rule in observe mode rather than turning the firewall down. The rule evaluates, logs at
warningwithenforced: false, and is then treated as no match:Everything else keeps enforcing. Leave it a week, count what it would have blocked, then tune or remove it.
-
Add a narrow allow rule with a negative weight, if a specific client needs to get through regardless.
-
global.mode: logturns off enforcement for the whole site. That is the blunt instrument, and it is the right one during an incident — but if you are reaching for it because of one rule, use the first option.
"One visitor's challenge pass has to go"¶
A pass is stateless and signed, so it is accepted until it expires. Rotating challenge.secret withdraws it — along with everybody else's.
Find it. The Challenge solution accepted log line carries pass_nonce and pass_expires, so grep it by address rather than trying to get the token out of somebody's browser.
Take it away:
$ firewall-challenge firewall.yml --revoke-nonce=af2f1008… --reason="abusing the pass"
$ firewall-challenge firewall.yml --status=af2f1008…
This needs challenge.revocable: true; without it the record is written and never read, and the command says so. To withdraw every pass issued before a moment — a long TTL configured by mistake and noticed a week later — set challenge.passes_valid_from instead, which costs no storage at all. See Withdrawing a pass.
Revoking a pass is not blocking an address: it withdraws an exemption rather than refusing a client. If the visitor should be refused outright, that is firewall-block.
"I locked myself out"¶
Confirm it:
That reads the real block list, which is the point — unlike firewall-check, which deliberately uses a throwaway.
Fix it:
$ firewall-block firewall.yml --lift=203.0.113.9
$ firewall-block firewall.yml --lift=203.0.113.0/24 --dry-run # check first if it is a range
Being blocked repeatedly after lifting means a rule is still matching you. Find it with firewall-check --explain; lifting a block does not stop the rule that caused it.
Bans get longer, not just repeated
blocking_escalation lengthens each subsequent ban for the same client. A block you lifted an hour ago coming back for six hours is that working as configured — see Global Settings.
"The challenge loops forever"¶
A visitor solves the challenge and is immediately challenged again.
Confirm it: check that the submission path reaches the firewall at all.
Four causes, in the order they actually happen:
| Your application routes the submission path | challenge.path (default /_firewall/challenge) must reach the firewall, and the firewall must run before your router. If the app claims that URL, no solution is ever received. |
| The token was earned against a different provider | A token carries a prv claim and only satisfies rules using that provider. A visitor who solved a math challenge is still challenged by a turnstile rule. This is deliberate. |
| The client IP changes between solving and returning | Tokens are IP-bound. Behind a proxy whose trusted-proxy configuration is wrong, every request can look like a different client. |
| Two instances, same secret, same provider | Both default their aud claim to the provider name, so a token from one satisfies the other — or, if the secrets differ, neither. Set challenge.audience explicitly. |
"Everything is blocked, or nothing is"¶
Confirm it:
If nothing is blocked at all:
mode—disabledevaluates nothing;logevaluates everything and enforces nothing. Check for a panic file too: it overridesmodeand leaves no trace in the config.firewall-doctorreports an active one as a warning.- You are running under CLI.
evaluate()returns immediately underPHP_SAPI === 'cli'for every mode exceptexception. That is deliberate — Artisan, Drush, WP-CLI and cron have no visitor to protect — but it means a CLI reproduction of a web problem proves nothing. - Nothing in your config loaded. See the next entry.
If everything is blocked, or IP rules behave randomly, the client IP is probably not what you think. Every rule reads $request->getClientIp(), which only honours X-Forwarded-For after your application has called Request::setTrustedProxies(). Without it, behind a CDN, every visitor appears to come from the proxy — so one bad actor can get the whole site blocked, and an IP allowlist can be walked past with a forged header. Assert what is in front of you:
"None of my rules are configured"¶
firewall-doctor reports no rules, or firewall-rule list says No rules are configured, on a config that plainly has them.
Confirm it:
It prints the load errors. A configs: entry naming a file that does not exist empties the entire document — a missing include is a load failure, not a skipped line, so every rule stops being configured:
Fix it: create the file before referencing it (firewall-rule init does exactly that, in that order), or remove the include. Set global.require_config: true to make this throw at startup instead of degrading quietly.
"Rate limits trip on normal browsing"¶
Confirm it: check whether the path you are testing has a rule of its own.
Every path is rate limited by default, not only the ones under config:. A request matching no rule falls through to a catch-all built from default_rate and default_sample — so adding one rule to protect /user/login also imposes a site-wide cap on everything else, at whatever default_rate says.
metadata:
default_rate: 60
default_sample: 60
limit_unlisted_paths: false # count only what you listed
config:
- path: /user/login
rate: 5
sample: 300
default_rate: 0 is not unlimited
The check is count >= rate, and a count is never negative, so 0 is satisfied by no request at all — including the first. It blocks everything.
"Rules stopped matching after an update"¶
Confirm it:
$ firewall-sources firewall.yml --dry-run # what each source resolved to, no network
$ firewall-doctor firewall.yml # how stale each cache is
Two different causes:
- A rule source stopped refreshing.
firewall-sourcesfails when a fetch fails; it says nothing about a fetch that stopped being attempted — a cron removed in a migration, a credential that expired. The rule keeps matching, on a list frozen at whatever it said when the sync last worked. Setglobal.stale_source_error_afterto make that fail a deploy. composer updatechanged a preset. Presets ship inside the package, so what they block can change with a library upgrade. The release notes call out preset behaviour changes under What changes on upgrade.
Still stuck¶
| Something is misconfigured and I want it found | firewall-doctor firewall.yml |
| A rule can never match, and I want to know before deploying | firewall-check --config=firewall.yml --lint |
| I need to see what my application receives | React to Decisions |
| An exception reached my code and I do not recognise it | Exceptions |