Article
Inside Subashi Pro
Most web application firewalls are something you put in front of your application. Subashi Pro is something you put inside it: a PSR-15 middleware that runs in the same process as your API, sees the same request object your controllers see, and makes its decision before your routing does.
That has consequences worth being upfront about. It cannot protect you from traffic that never reaches PHP, and it is not a replacement for a network-layer WAF if you need to shed volumetric attacks. What it gives you instead is a firewall that understands your application’s own vocabulary — routes, headers, JSON bodies, API keys — configured in the same PHP config files as the rest of your project, deployed in the same artefact, with no sidecar to run and no separate control plane to keep in sync.
This is a tour of everything it does.
The rule model
There are three rule lists, evaluated in a fixed order:
- Whitelist — if a rule matches, the request is passed straight through. Nothing after this runs.
- Blacklist — if a rule matches, the request is blocked.
- Rate limit rules — the first matching rule’s limit is applied.
The whitelist short-circuiting everything, rate limits included, is the part worth internalising. A whitelisted internal service is not merely unblocked; it is unthrottled.
Every rule is a name, a list of conditions and an action:
[
'name' => 'Block scripted clients from outside the GCC',
'conditions' => [
['type' => 'header', 'key' => 'User-Agent', 'operator' => 'regex', 'value' => '/curl|python-requests/i'],
['type' => 'geo_country', 'operator' => 'not_in_list', 'values' => ['AE', 'SA', 'QA', 'KW', 'BH', 'OM']],
],
'response_code' => 403,
'response_message' => 'Access Denied',
],
Conditions within a rule are AND-ed: all of them must match. There is no OR — you express that by writing two rules. A rule with no conditions never matches, which means an empty conditions array cannot accidentally block everything.
There is no per-rule action, either. Which list a rule sits in is the decision, so a rule cannot quietly contradict the list it belongs to. The name is not used for matching at all — it is what gets logged when the rule fires, so it is worth writing as something you would want to read in an alert at three in the morning.
Condition types
| Type | Inspects |
|---|---|
header |
One named header, first value |
query_param |
One named query parameter |
query |
The whole query string, decoded once |
body |
One named top-level key of a JSON body |
path |
The URL path — not the query string |
method |
The HTTP method |
ip |
The resolved client address |
geo_country |
ISO 3166-1 alpha-2 country for that address |
asn |
Autonomous system number for that address, as a string |
Operators
equals, not_equals, contains, not_contains, regex, in_list, not_in_list, exists, not_exists, gt, lt, gte, lte.
exists and not_exists are more useful than they look: ['type' => 'header', 'key' => 'Authorization', 'operator' => 'not_exists'] is how you write “unauthenticated” as a rate-limiting condition, and it costs nothing to evaluate.
The OWASP defaults
The sample configuration ships with a starter set of blacklist rules mapped to the OWASP Top 10 (2021). They are a floor, not a ceiling, and it is worth being precise about what each one actually inspects rather than leaving you to assume.
| Rule | OWASP | Inspects | Response |
|---|---|---|---|
| Path Traversal Attack | A01 Broken Access Control | Path — ../, ..\, and their percent-encodings |
403 |
| SQL Injection in Path | A03 Injection | Path — UNION SELECT, DROP TABLE, exec xp_… |
403 |
| SQL Injection in Query String | A03 Injection | Query string — same patterns | 403 |
| Command Injection | A03 Injection | Path — shell metacharacters | 403 |
| LDAP Injection | A03 Injection | Path — filter syntax | 403 |
| XPath Injection | A03 Injection | Path — axis and node-test syntax | 403 |
| XSS in Path | A03 Injection | Path — <script, javascript:, event handlers |
403 |
| XSS in Query String | A03 Injection | Query string — same patterns | 403 |
| Sensitive File Access | A05 Security Misconfiguration | Path — .env, .git, wp-config.php, passwd |
404 |
| Known Vulnerability Scanner | A06 Vulnerable Components | User-Agent — nikto, sqlmap, nmap, nessus… | 403 |
| Null Byte Injection | A08 Integrity Failures | Path — %00 |
400 |
| Log Forging | A09 Logging Failures | Path — CR/LF and encodings | 400 |
| SSRF in Path | A10 SSRF | Path — localhost, 127.0.0.1, 169.254., file:// |
400 |
| SSRF in Query String | A10 SSRF | Query string — same patterns | 400 |
| Remote File Inclusion | — | Path — http://, php://, data:// |
400 |
| XXE | — | Path — <!DOCTYPE, <!ENTITY |
400 |
Two details in that table are deliberate.
Sensitive file access answers 404, not 403. A 403 confirms something is there. Scanners walking a wordlist learn nothing from a 404, and the difference in what you leak over thousands of probes is not small.
Each attack class is covered in two places. path reads getUri()->getPath() and nothing else, so a rule inspecting the path cannot see ?id=1 UNION SELECT. The query condition is its counterpart: it matches against the whole query string, URL-decoded once, so a pattern can be written as union select without also having to spell union%20select and union+select. Injection, XSS and SSRF each get one rule for each surface.
Request bodies are the exception. body addresses a single named JSON key, so body rules are targeted rather than scanning — you name the field you care about. That is a deliberate limit: scanning an arbitrary JSON document with regular expressions produces false positives faster than it produces security.
What a WAF rule set is and is not
A regular expression that matches union.*select will block a scanner and will not stop someone who has read your code. Parameterised queries stop SQL injection; a WAF rule buys you time and cuts noise. The honest value of this rule set is that it removes the background radiation of automated scanning from your logs and your application, so that what remains is worth looking at.
Treat the shipped rules as a starting point to be tightened against your own traffic, and keep the actual fix — validate input, parameterise queries, encode output — where it belongs.
Geo and ASN matching
geo_country and asn conditions resolve the client address against MMDB databases bundled with the middleware, as of the release we wrote about yesterday. There is no API key and no network call while a request is in flight.
Each lookup is backed by two databases consulted in order — DB-IP and iptoasn for country, iptoasn and DB-IP for ASN. If neither has a record, the condition does not match and the request is let through: the firewall never blocks on the basis of data it does not have. A weekly pipeline republishes the data, so keeping it current is a composer update.
[
'name' => 'Block hosting providers from the signup endpoint',
'conditions' => [
['type' => 'path', 'operator' => 'equals', 'value' => '/v1/signup'],
['type' => 'asn', 'operator' => 'in_list', 'values' => ['14061', '16509', '14618']],
],
],
ASN rules are the quiet workhorse here. Abusive automated traffic overwhelmingly originates from a small number of hosting and VPS networks, and blocking an AS number is both more durable and more precise than chasing individual addresses.
Knowing who the client is
Every rule above that depends on the caller — ip, geo_country, asn, and per-IP rate limiting — is evaluated against one resolved address. Get that wrong and the rules are not wrong so much as meaningless.
'client_ip' => [
'sources' => ['header:X-Forwarded-For', 'remote_addr'],
'trusted_proxies' => ['10.42.0.0/16'],
'chain_position' => 'first',
],
Sources are tried in order, first valid address wins. Forwarding headers are read only when the peer that opened the connection is a configured trusted proxy — anyone on the internet can send an X-Forwarded-For, and a firewall that believed one unconditionally would be taking the caller’s word for who the caller is. The default trusts nothing and uses the connecting address.
The trusted proxy is the hop that actually connects to your application, which in Kubernetes is your ingress controller’s pod address, not the public address of the load balancer in front of it. That distinction catches people out, which is a good reason for the next feature to exist.
Diagnostics
You cannot see your own request the way your server sees it. Diagnostics closes that gap:
'diagnostics' => [
'enabled' => true,
'query_param' => 'kipchak-waf-debug',
'token' => env('SUBASHI_DIAGNOSTICS_TOKEN', ''),
],
Add the parameter to any route and the middleware answers with a dump instead of passing the request on — the address it resolved and which source produced it, whether your peer counted as a trusted proxy, the country and ASN with which database answered, and every header on the request:
{
"client_ip": {
"resolved": "95.174.68.63",
"resolved_from": "header:X-Forwarded-For",
"remote_addr": "10.42.229.205",
"peer_is_trusted_proxy": true
},
"geo": {
"country": { "value": "GB", "database": "dbip-country.mmdb" },
"asn": { "value": "2818", "organisation": "BBC Internet Services, UK" }
}
}
The check runs ahead of every rule and ahead of the master enabled switch, so you can still ask why the firewall sees you as a particular address on a request that would otherwise be blocked.
It is off by default and inert until you set a token. The dump shows a caller their own headers and describes your proxy topology, so it is gated behind a secret; server parameters are limited to a fixed list of network keys, because on most runtimes that array carries the process environment. Every use is logged.
Rate limiting
A fixed-window counter, keyed by whatever identifies the caller:
'rate_limit_rules' => [
[
'name' => 'Unauthenticated callers',
'conditions' => [
['type' => 'header', 'key' => 'Authorization', 'operator' => 'not_exists'],
],
'rate_limit' => ['limit' => 20, 'window' => 60, 'key_prefix' => 'anon', 'key_source' => 'ip'],
],
[
'name' => 'Per API key',
'conditions' => [
['type' => 'header', 'key' => 'apikey', 'operator' => 'exists'],
],
'rate_limit' => ['limit' => 1000, 'window' => 60, 'key_prefix' => 'apikey', 'key_source' => 'header:apikey'],
],
],
key_source accepts ip, header:<name> or query:<name>, and the identifier is hashed before it becomes a cache key, so tokens and user identifiers never appear in Memcached in the clear. Counters live in Memcached (shared across your pods, which is what you want) or in the file cache for single-instance deployments.
Rate limiting fails open. If the cache is unreachable the request is allowed rather than rejected — a Memcached outage should not become an outage of your API. That is the right default for most people and the wrong one for a few; it is worth knowing which you are.
Responses
Blocked requests return your configured code and message, overridable per rule, and always carry X-Protected-By: Subashi Pro by Mamluk. Rate-limited requests return 429. Responses use the standard Kipchak JSON envelope, so a blocked request looks like every other error your clients already handle:
{ "code": 403, "status": "Forbidden", "data": "Access Denied" }
Per-rule response codes are more useful than they first appear. Answering 404 for sensitive-file probes, 400 for malformed input and 403 for genuine policy denials gives you three distinguishable signals in your logs instead of one undifferentiated wall of 403s.
What you see afterwards
A firewall that blocks silently is hard to reason about. Every decision is attributed to the rule that made it:
| Event | Level | Context |
|---|---|---|
| Request blocked | warning |
rule, ip, method, path, user_agent, response_code |
| Request rate limited | warning |
rule, ip, path, limit, window |
| Request whitelisted | debug |
rule, ip, path |
Blocks and throttles are at warning so they show up without turning on debug logging across your API; whitelist hits are ordinary traffic and sit at debug.
What it costs
Under FrankenPHP worker mode, the firewall is constructed once when the worker boots. Config is read once, the MMDB readers are opened once and held, and the condition handlers are built once. Per request you pay for the rules that actually run: string and regex comparisons, plus at most two in-memory trie walks if a geo or ASN condition is evaluated, memoised per address for the worker’s lifetime.
There is no network call, no database query and no subprocess anywhere on the request path.
Getting started
Subashi Pro is part of Kipchak Enterprise, distributed through our Composer repository:
composer require kipchak/middleware-subashi-pro
Register it in middlewares/middlewares.php, copy sample.config.php to config/kipchak.subashi.pro.php, and start with the shipped OWASP rules plus a whitelist for your own infrastructure. Turn diagnostics on for the first ten minutes to confirm the firewall sees the addresses you expect, then turn it off.
Full configuration reference is in the Subashi Pro documentation. If you have an enterprise licence and need access to the repository, talk to us.