Enterprise Middleware
Subashi Pro WAF Middleware
Protect your Kipchak API with configurable WAF rules, rate limiting, and IP intelligence.
Introduction
Note: This middleware is only available with Kipchak Enterprise.
The Subashi Pro middleware is a Web Application Firewall (WAF) for Kipchak APIs. It builds on the existing Subashi WAF and evaluates requests against whitelist, blacklist, and rate-limit rules to help block common attack patterns and abusive traffic.
In addition to this, it adds Geo and ASN based IP intelligence, allowing you to block traffic by countries or ASN ranges.
Key features include:
- Rule-Based Filtering: Combine multiple conditions per rule using a simple operator system.
- Rate Limiting: Apply per-IP or custom key limits with a pluggable cache backend.
- IP Intelligence: Geo-country and ASN matching against bundled MMDB databases - no API key, no network call on the request path.
Installation
To install this middleware, you need to access the Enterprise Composer repository at https://php.pkgs.1x.ax.
If you have an enterprise license, please contact your account representative for access.
Once you have access (see https://getcomposer.org/doc/articles/authentication-for-private-packages.md on how to configure access once you have credentials), install the middleware via composer by running:
composer require kipchak/middleware-subashi-pro
Configuration
The middleware reads its configuration from kipchak.subashi.pro (for example in a subashi.pro.php config file).
Global Settings
enabled(bool): Master switch for the WAF.blocked_response_code(int): Default HTTP status code for blocked requests.blocked_response_message(string): Default response message for blocked requests.
Client IP Resolution
Every rule that depends on who is calling - ip, geo_country, asn and per-IP rate limiting - is evaluated against the address resolved here, so this is worth configuring deliberately.
Forwarding headers are set by whatever sits in front of the application, and anyone on the internet can send one. Subashi Pro therefore only reads them when the direct peer is a trusted proxy. The defaults trust nothing and use the connecting address: unspoofable, but it will be your load balancer's address if you have one in front.
client_ip.sources(array): Sources tried in order; the first that yields a valid address wins. Each entry isremote_addrorheader:<Name>. Defaults to['remote_addr'].client_ip.trusted_proxies(array): Addresses and CIDR ranges, IPv4 or IPv6, whose forwarding headers may be believed. Empty (the default) means forwarding headers are never read. The literal'*'trusts any peer.client_ip.chain_position(string):first(default) orlast. Which end of a comma-separated header chain to read.
Put forwarding headers ahead of remote_addr in sources, or they will never be reached:
'client_ip' => [
'sources' => ['header:X-Forwarded-For', 'remote_addr'],
'trusted_proxies' => ['10.0.0.0/8'],
'chain_position' => 'first',
],
If the trusted proxy sends no such header, resolution falls through to the next source, so keeping remote_addr last is a sensible backstop.
Headers with more than one value
X-Forwarded-For is a chain, not a single value:
X-Forwarded-For: 203.0.113.9, 70.41.3.18, 150.172.238.178
The leftmost entry is the original client as reported by the first proxy, and each hop appends the peer it saw. Only the rightmost entry was written by your own infrastructure - everything to its left is a claim the previous hop passed along, and a client can seed the chain by sending the header itself.
chain_position: 'first'reads203.0.113.9. Correct when your edge proxy overwrites the header rather than appending to it, which is what Cloudflare, Kong and most CDNs do.chain_position: 'last'reads150.172.238.178. Cannot be spoofed, but with more than one hop in front it is your own proxy's address rather than the client's.
Entries that are not valid addresses - unknown, obfuscated identifiers - are skipped rather than failing the whole lookup. Port suffixes (203.0.113.9:41234), bracketed IPv6 ([2001:db8::1]:443) and zone identifiers (fe80::1%eth0) are stripped, and anything that is still not an IP address is discarded.
Rate Limiting
rate_limiting.enabled(bool): Enable or disable rate limiting.rate_limiting.default_limit(int): Default request limit.rate_limiting.default_window(int): Default window length in seconds.rate_limiting.store(string):memcachedorfile.rate_limiting.memcached_pool(string): Pool name when using Memcached.
Geolocation and ASN
Country and ASN are resolved locally from the MMDB databases shipped in kipchak/data-geo-asn, which is installed as a dependency. There is no API key to configure and no network call on the request path.
Two databases back each lookup and are consulted in order. If the first holds no record for the client address the second is tried; if neither does, the value is null, the condition does not match, and the request is let through.
| Lookup | Order | Database | Provider | Refreshed |
|---|---|---|---|---|
| Country | 1st | dbip-country.mmdb | DB-IP IP to Country Lite | Monthly |
| Country | 2nd | iptoasn-country.mmdb | iptoasn.com | Daily |
| ASN | 1st | iptoasn-asn.mmdb | iptoasn.com | Daily |
| ASN | 2nd | dbip-asn.mmdb | DB-IP IP to ASN Lite | Monthly |
A weekly pipeline publishes a refreshed kipchak/data-geo-asn, so composer update keeps the data current.
geolocation.enabled(bool): Enable country and ASN lookups.geolocation.memo_limit(int): Client addresses memoised per worker process. Defaults to1000; set to0to disable.geolocation.country_databases(array): Optional. Absolute paths to country databases, in the order to consult them. Defaults to those bundled.geolocation.asn_databases(array): Optional. Absolute paths to ASN databases, in the order to consult them. Defaults to those bundled.
Under FrankenPHP worker mode the readers are opened once per worker and held, so the cost per request is a single in-memory trie walk. For very high lookup volumes, install the optional maxmind-db/reader-ext C extension; it is a drop-in replacement and needs no configuration change.
Results derived from the DB-IP databases carry a CC BY 4.0 attribution requirement. Blocking a request is not a display of the data, so nothing is required for firewall use, but if you surface country or ASN in an API response or UI you must credit IP geolocation by DB-IP.
Diagnostics
Getting client_ip right means knowing which headers actually reach the application and what your proxies put in them — which is precisely what you cannot see from outside. The diagnostics dump puts it on screen.
diagnostics.enabled(bool): Off by default.diagnostics.token(string): Shared secret. Diagnostics stay inert while this is empty, even when enabled.diagnostics.query_param(string): Query parameter carrying the token. Defaults tokipchak-waf-debug.
'diagnostics' => [
'enabled' => true,
'query_param' => 'kipchak-waf-debug',
'token' => env('SUBASHI_DIAGNOSTICS_TOKEN', ''),
],
Add the parameter to any route and the middleware answers with the dump instead of passing the request on:
https://api.example.com/v1/anything?kipchak-waf-debug=your-token
It reports the resolved client address and which source produced it, whether the connecting peer counted as a trusted proxy, the country and ASN with which of the four databases answered, every header on the request, and a narrow slice of server parameters:
{
"client_ip": {
"resolved": "212.58.244.20",
"resolved_from": "header:X-Forwarded-For",
"remote_addr": "10.1.2.3",
"peer_is_trusted_proxy": true,
"configured_sources": ["header:X-Forwarded-For", "remote_addr"],
"configured_trusted_proxies": ["10.0.0.0/8"],
"chain_position": "first"
},
"geo": {
"country": { "value": "GB", "database": "dbip-country.mmdb" },
"asn": { "value": "2818", "organisation": "BBC Internet Services, UK", "database": "iptoasn-asn.mmdb" },
"data_version": "2026.09.06"
},
"headers": { "...": "every header, repeated ones kept as a list" }
}
The check runs before every other rule, and before the enabled switch, so you can still ask "why does the firewall see me as this address?" on a request that would otherwise be blocked.
Two deliberate choices about what the dump contains. Headers are not redacted — they are the caller's own headers, and hiding the Authorization header from someone debugging their Authorization header would defeat the point. Server parameters, by contrast, are limited to a fixed list of network-related keys, because on most SAPIs that array carries the process environment and would otherwise hand over your database password.
That combination is why the token is mandatory rather than optional: the dump shows a caller their own credentials and describes your proxy topology. Each use is logged at warning level with the connecting address and path.
Logging
A rule's name does not affect matching — the list a rule sits in decides the outcome — but it is what identifies the rule afterwards, so make it something you would want to read in an alert.
| 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 log at warning because they are the events you want visible without turning on debug logging. Whitelist hits are routine traffic and log at debug.
Rules
Rules are declared under whitelist, blacklist, and rate_limit_rules. Each rule has a name and a list of conditions. All conditions must match for the rule to apply.
Condition Types
header- HTTP header valuequery_param- A single named URL query parameterquery- The whole query string, URL-decoded once. Use this to scan for patterns without knowing the parameter names in advanceip- Client IP address, as resolved by the rules in Client IP Resolutiongeo_country- ISO 3166-1 alpha-2 country code (from the bundled country databases)asn- Autonomous System Number, as a string (from the bundled ASN databases)method- HTTP methodpath- URL path only, not the query stringbody- JSON body field
Operators
equalsnot_equalscontainsnot_containsregexin_listnot_in_listexistsnot_existsgtltgtelte
Usage
Once enabled, the middleware evaluates requests in this order:
- Whitelist: Matching rules bypass all other checks.
- Blacklist: Matching rules block the request (with optional custom response).
- Rate limiting: Matching rules enforce request limits.
Example Blacklist Rule
[
'name' => 'Block SQL injection attempts',
'conditions' => [
[
'type' => 'path',
'operator' => 'regex',
'value' => '/(union.*select|select.*from|drop.*table)/i',
],
],
'response_code' => 403,
'response_message' => 'SQL injection attempt detected',
]
Example Geo Country Rule
[
'name' => 'Block specific countries',
'conditions' => [
[
'type' => 'geo_country',
'operator' => 'in_list',
'values' => ['RU', 'CN'],
],
],
]
Example ASN Rule
[
'name' => 'Block known ASN ranges',
'conditions' => [
[
'type' => 'asn',
'operator' => 'in_list',
'values' => ['13335', '15169'],
],
],
]
Example Rate Limit Rule
[
'name' => 'Per IP rate limit',
'conditions' => [],
'rate_limit' => [
'limit' => 100,
'window' => 60,
'key_prefix' => 'ip',
'key_source' => 'ip',
],
]
Git Repository
The source code for this middleware is hosted internally.