Skip to content

Recipes

Keyed on what you are trying to do, not on which feature does it. Each one is a working configuration and a link to the reference behind it.

I want to…
Block a country GeoLocation
Stop a login flood Rate Limit
Catch a scanner with a honeypot response: record
Let Googlebot in safely User Agent + reverse-DNS verification
Rate limit an API by key Rate Limit
Run in observe mode for a week Mode
Find out why a request was blocked firewall-check
Put the site behind a challenge during an incident Challenge

Block a country

plugins:
  - plugin: "Kanopi\\Firewall\\Plugins\\GeoLocation"
    response: block
    enable: true
    metadata:
      name: blocked-countries
      reader:
        type: reader
        db: /usr/local/share/GeoIP/GeoLite2-Country.mmdb
    config:
      - "country:CN"
      - "country:RU"

Needs a MaxMind database on disk — Set Up GeoIP. Without one the rule cannot match, and firewall-doctor reports it rather than failing silently.

Allowlist your own people first

Country blocks catch staff travelling and VPN users. Put an allow rule with a negative weight above it; the allow bucket runs first regardless of weights.

GeoLocation


Stop a login flood

plugins:
  - plugin: "Kanopi\\Firewall\\Plugins\\RateLimit"
    response: block
    enable: true
    metadata:
      name: login-flood
      default_rate: 60
      default_sample: 60
      limit_unlisted_paths: false
      storage:
        type: "Kanopi\\Firewall\\RateLimitStorage\\FileRateLimitStorage"
        config:
          file: "{config_dir}/rate-limits.data"
    config:
      - path: /user/login
        rate: 5
        sample: 300        # five attempts per five minutes, per IP

limit_unlisted_paths: false is doing real work here

Without it, adding one rule for /user/login also imposes a site-wide cap at default_rate on every other URL, and every request performs a read-modify-write on the counter store.

Rate Limit


Catch a scanner with a honeypot

A path no legitimate client has any reason to fetch. Anything that asks for one is written to the block list and refused from its next request onward — the request that springs the trap is served normally, so the scanner learns nothing about what it found.

configs:
  - "{presets_dir}/honeypot.yml"

Or your own:

plugins:
  - plugin: "Kanopi\\Firewall\\Plugins\\Url"
    response: record
    enable: true
    metadata:
      name: honeypot
      default_expiration_time: 86400
    config:
      - "path:/.ssh/id_rsa"
      - "path:/backup.sql"

Verify each path does what you think before relying on it:

$ firewall-check --config=firewall.yml --ip=203.0.113.9 --url=/.ssh/id_rsa --explain
RECORDED  GET /.ssh/id_rsa
  recorded by       honeypot
  effect            served now, refused from the next request onward

RECORDED, not BLOCKED. If it says BLOCKED, another rule matched that path first and the stealth is gone — the scanner is told exactly which URL is wired. The shipped preset deliberately avoids every path the other presets block, which is why it does not include /.git/, /.env or /wp-config*.

A false positive here is a ban, not a refusal

Pick paths that are never part of a working site — credentials, keys, repositories, database dumps. And allowlist your own scanners first: a security audit you commissioned will walk into this and be banned mid-run.

Keep them out of your sitemap.

Evaluation Order


Let Googlebot in safely

The naive version — allow anything whose user agent says Googlebot — is an open door, because the user agent is a string the client chooses. Make the rule prove it:

plugins:
  - plugin: "Kanopi\\Firewall\\Plugins\\UserAgent"
    response: allow
    weight: -200
    enable: true
    metadata:
      name: verified-search-crawlers
      verify: reverse-dns
      verify_suffixes:
        - .googlebot.com
        - .google.com
        - .search.msn.com
        - .applebot.apple.com
    config:
      - "bot:true"

The match has to survive a reverse lookup of the client IP, a suffix check, and a forward lookup back to the same address. A client claiming to be Googlebot from a residential ISP fails at the first step.

A mistyped verify does not verify

verify: reverse_dns is not verify: reverse-dns, and verify with no verify_suffixes accepts any domain. Either mistake leaves you with the open door you were trying to close. firewall-check --lint reports it.

User Agent


Rate limit an API by key

Name the field to count by:

plugins:
  - plugin: "Kanopi\\Firewall\\Plugins\\RateLimit"
    response: block
    enable: true
    metadata:
      name: api-limits
      limit_unlisted_paths: false
    config:
      - path: /api/*
        rate: 1000
        sample: 3600
        key: [header.x-api-key]     # per key, whatever address it comes from

Any request field works — header.*, post.*, cookie.*, query.*, plus client_ip, path and rule_pattern. See Rate Limit.

A composed key is stored hashed, so a token named in a key: never reaches the backend.

Counting by something other than the address does not ban an address

The durable block list is keyed on the client IP, so a rule with a non-address key refuses the request and writes no ban — otherwise an attacker could exhaust a victim's account budget and get the victim's address banned. See Rate Limit.

Counting by account stops credential stuffing — alongside, not instead

key: [post.name] on a login rule counts attempts against the account, so ten thousand addresses trying one account land in one bucket instead of ten thousand.

Keep an address-keyed rule for the same path too. An account key gives every account its own budget, so one address working through a username list is never limited by it — the two catch opposite attacks. firewall-check --lint warns if you have only one.

If you need something the field vocabulary cannot express, buildRateKey() is still protected — one method on a subclass:

final class ApiKeyRateLimit extends \Kanopi\Firewall\Plugins\RateLimit
{
    protected function buildRateKey(\Symfony\Component\HttpFoundation\Request $request, array $rule): string
    {
        $key = $request->headers->get('X-Api-Key');

        // Fall back to the IP rather than to one shared bucket: an unkeyed
        // caller must not be able to exhaust every keyed caller's allowance.
        return sprintf('rate:%s:%s', $key ?? (string) $request->getClientIp(), $rule['path']);
    }
}

Point a rule at it with plugin: "App\\Firewall\\ApiKeyRateLimit".

Write a Custom Plugin


Run in observe mode for a week

Everything evaluates, nothing is enforced, every decision is logged:

global:
  mode: log

This is the right way to introduce an unfamiliar rule set. Turning one straight on is how a site finds its false positives in production, and the usual recovery is to remove the firewall rather than tune it.

To observe one rule while the rest keep enforcing, which is almost always what you actually want:

metadata:
  name: crs-paranoia-2
  mode: log        # match, report it, carry on

Count what it would have blocked:

$ grep 'enforced":false' /var/log/firewall/firewall.log | wc -l

Mode · Evaluation Order


Find out why a request was blocked

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

Names the rule, shows every rule evaluated in order, and lists the ones configured but never reached. It runs against a throwaway store, so asking about an address cannot ban it.

Check a Request · Troubleshooting


Put the site behind a challenge during an incident

Everyone proves they are human; your own people do not:

challenge:
  provider: math
  secret: '%env(FIREWALL_CHALLENGE_SECRET)%'
  path: /_firewall/challenge

plugins:
  - plugin: "Kanopi\\Firewall\\Plugins\\IpAddress"
    response: allow
    weight: -200
    enable: true
    metadata: { name: office }
    config: ['198.51.100.0/24']

  - plugin: "Kanopi\\Firewall\\Plugins\\IpAddress"
    response: challenge
    weight: 500
    enable: true
    metadata: { name: incident-challenge-everyone }
    config: ['0.0.0.0/0', '::/0']
CHALLENGED  GET /      client 203.0.113.9    challenged by incident-challenge-everyone
ALLOWED     GET /      client 198.51.100.7   allowed by    office

Add and remove it without editing YAML, which is the point during an incident:

$ firewall-rule add firewall.yml --ip=0.0.0.0/0 --ip=::/0 \
    --response=challenge --weight=500 --name=incident-challenge
$ firewall-rule remove incident-challenge firewall.yml

Challenging is not blocking, and that is deliberate

A challenge costs an attacker CPU and a real visitor a few seconds. If you want to serve nobody at all, that is a block rule with the same 0.0.0.0/0 config — but every refused visitor is then written to the durable block list with escalation applied, and stays banned after you remove the rule. #304 is the proper fix for that.

Add a Challenge · Manage Rules