Middleware
Subashi WAF Middleware
Protecting your API with Subashi, a powerful WAF middleware for Kipchak.
Introduction
Subashi is a lightweight, configurable Web Application Firewall (WAF) middleware for Kipchak. It provides a robust layer of security by filtering incoming HTTP requests based on customisable rules before they reach your application logic.
The name is inspired by the Arabic/Persian word "Subashi" (سوباشي) which means "shield" or "protection", reflecting the purpose of the middleware to protect your API from malicious attacks.
It's actually taken from the Ottoman Turkish word Subaşı (would have been written سوباشي in Ottoman Turkish), which was the title given to the police chief of a city / town in the Ottoman Empire.
Key features include:
- Access Control: Allow or block traffic based on IP, headers, query parameters, and more.
- Rate Limiting: Protect your application from abuse with sliding-window limits per IP, API key or token, counted atomically in Memcached, Valkey or on one host.
- Granular Rules: Define complex conditions using regex, list matching, and numeric comparisons.
Installation
Install the middleware via composer by running:
composer require kipchak/middleware-subashi
Configuration
The configuration in kipchak.subashi.php can be used to configure Subashi:
The configuration is explained below.
Global Settings
These settings control the master switch and default fallback behavior.
enabled(bool): Set totrueto activate the WAF. Iffalse, requests pass through unchecked.blocked_response_code(int): HTTP status code for blocked requests (Default:403).
Rate Limiting Configuration
Rate limiting has its own section below: Rate Limiting.
Rules Structure
Rules are defined in three specific arrays:
1. Whitelist (whitelist)
Priority: High. If a request matches a whitelist rule, it is allowed immediately, bypassing all subsequent checks (including rate limits).
- Use case: Trusted internal IPs, Admin API keys.
2. Blacklist (blacklist)
Priority: Medium. Processed after the whitelist. If a request matches a blacklist rule, it is blocked immediately.
- Use case: Blocking specific User-Agents, malicious IPs, or specific query parameters.
3. Rate Limit Rules (rate_limit_rules)
Priority: Low. Applies specific rate limits based on conditions.
- Use case: Stricter limits for unauthenticated users, higher limits for paid tiers.
Rule Syntax
Each rule consists of a name and a list of conditions. There is no per-rule action: the list a rule sits in decides the outcome, so everything in whitelist is allowed and everything in blacklist is blocked. Blacklist rules may additionally set response_code and response_message.
Supported Condition Types:
header: HTTP headers (e.g.,User-Agent).query_param: A single named URL query parameter.query: The whole query string, URL-decoded once. Use this to scan for a pattern without knowing the parameter names in advance.ip: Client IP address.method: HTTP verb (GET,POST).path: Request URI path only, not the query string.body: JSON body fields.
Supported Operators:
equals,not_equalscontains,not_containsregex(Regular Expression)in_list,not_in_listexists,not_existsgt,lt,gte,lte(Numeric comparisons)
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 |
| Rate limit store unavailable | error | error (at most once a minute per worker) |
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.
Rate Limiting
Rate limiting caps how many requests a client can make in a period, to protect the API from abuse: scrapers, credential stuffing, runaway scripts. It runs in the firewall, before authentication, so it can only tell clients apart by what the request itself carries: the IP address, or a secret such as an API key or a bearer token.
How requests are counted
Each rule allows limit requests in any window seconds. The count is a sliding window: a request is weighed against the requests in the current window plus the share of the previous window that still falls within the last window seconds. A client therefore cannot make limit requests at the end of one window and limit more at the start of the next.
Requests that are refused are not counted, so a client that keeps retrying while limited is let back in at the rate the limit allows.
Counters are shared by every worker, and their increments are atomic, so a limit holds when many requests arrive at once. In testing, 60 simultaneous requests against a limit of 20 let exactly 20 through, across four FrankenPHP workers, on both Memcached and Valkey.
store | Package | Scope |
|---|---|---|
memcached | kipchak/driver-memcached | Every host that shares the Memcached pool. Atomic increment and add. |
valkey | kipchak/driver-valkey (Enterprise) | Every host that shares the Valkey pool. Atomic INCR in a Lua script. |
file | kipchak/driver-filecache | One host only. Atomic between the workers of that host through file locks. |
Use memcached or valkey when the API runs on more than one host: with file, each host counts separately, so the effective limit is multiplied by the number of hosts.
Configuration
'rate_limiting' => [
'enabled' => true,
'default_limit' => 100, // requests per window, for rules that do not set their own
'default_window' => 60, // seconds
'store' => 'memcached', // 'memcached', 'valkey' or 'file'
'memcached_pool' => 'cache', // when store is 'memcached'
'valkey_pool' => 'cache', // when store is 'valkey'
'fail_open' => true, // when the store is unreachable: true allows requests, false refuses them
'headers' => false, // add RateLimit-Policy and RateLimit headers to responses
],
| Key | Default | Description |
|---|---|---|
enabled | false | Turns rate limiting on. |
default_limit | 60 | Requests per window for rules without rate_limit.limit. |
default_window | 60 | Window in seconds for rules without rate_limit.window. |
store | file | Where counters are kept. |
memcached_pool | cache | The Memcached driver pool. |
valkey_pool | cache | The Valkey driver pool. |
fail_open | true | What happens when the store cannot be reached. |
headers | false | Whether responses carry the rate limit headers. |
Each rule in rate_limit_rules has a name, optional conditions, and a rate_limit:
'rate_limit_rules' => [
[
'name' => 'Per IP',
'conditions' => [], // no conditions: applies to every request
'rate_limit' => [
'limit' => 100,
'window' => 60,
'key_prefix' => 'ip', // keeps this rule's counters apart from other rules'
'key_source' => 'ip', // what tells one client from another
],
],
],
key_source is one of:
key_source | Counts per |
|---|---|
ip | Client IP address. |
header:<Name> | Value of a request header, e.g. header:apikey. |
query:<name> | Value of a query parameter, e.g. query:apikey. |
Values are hashed before they are used as counter keys, so API keys and tokens never reach the store. A request without the header or parameter is counted under one shared unknown key for that rule.
Every rule whose conditions match counts the request against its own limit, and the first rule over its limit refuses it. Whitelisted requests are not counted at all.
Responses
A refused request receives 429 Too Many Requests with a Retry-After header giving the number of seconds until a request is likely to be allowed again:
HTTP/1.1 429 Too Many Requests
Retry-After: 18
X-Protected-By: Subashi by Mamluk
Content-Type: application/json
{"code":429,"status":"TOO MANY REQUESTS","data":"Rate limit exceeded"}
With 'headers' => true, every response from a rate-limited route also carries the RateLimit-Policy and RateLimit fields of the IETF draft RateLimit header fields for HTTP, describing the matching rule with the fewest requests remaining:
RateLimit-Policy: "Per API key";q=1000;w=3600
RateLimit: "Per API key";r=742;t=1260
q is the limit, w the window in seconds, r the requests remaining and t the seconds until the current window ends. Well-behaved clients can slow down before they are refused.
When the store is down
With fail_open set to true (the default), requests are allowed while the store cannot be reached, so a cache outage does not become an outage of the API. With false, requests are refused with 503 Service Unavailable. Either way the failure is logged at error level, at most once a minute per worker, so an outage does not flood the log. Workers reconnect to Valkey by themselves when it returns.
Examples
One limit per IP address
[
'name' => 'Per IP',
'conditions' => [],
'rate_limit' => ['limit' => 300, 'window' => 60, 'key_prefix' => 'ip', 'key_source' => 'ip'],
],
Behind a load balancer or CDN, every request appears to come from the proxy unless the client address is read from a forwarding header. Subashi uses the connecting address. Subashi Pro reads forwarding headers from proxies you trust; see its client_ip setting.
A stricter limit for one endpoint
Rules combine, so a login endpoint can have its own, much lower limit on top of the general one:
[
'name' => 'Login attempts per IP',
'conditions' => [
['type' => 'path', 'operator' => 'equals', 'value' => '/v1/login'],
['type' => 'method', 'operator' => 'equals', 'value' => 'POST'],
],
'rate_limit' => ['limit' => 5, 'window' => 300, 'key_prefix' => 'login', 'key_source' => 'ip'],
],
A stricter limit for requests without credentials
[
'name' => 'Unauthenticated per IP',
'conditions' => [
['type' => 'header', 'key' => 'apikey', 'operator' => 'not_exists'],
['type' => 'header', 'key' => 'Authorization', 'operator' => 'not_exists'],
],
'rate_limit' => ['limit' => 20, 'window' => 60, 'key_prefix' => 'anon', 'key_source' => 'ip'],
],
With the authentication middlewares
The firewall should run before authentication, so that abusive traffic is turned away before any key is looked up or token verified. In middlewares/middlewares.php Slim runs the middleware added last first, so initialise Subashi after the authentication middlewares:
Error::initialise($app);
Key::initialise($app); // or JWKS, JWT, HMAC
Subashi::initialise($app); // added last, so it runs first
At that point nothing has been verified. Key rate limits only on the IP address or on a secret the client holds. Never key on a public identifier, such as a client ID, an HMAC key ID or a tenant header: anyone can send someone else's identifier, and would use up that client's allowance.
API keys (auth-key)
With API key authentication, clients are defined in kipchak.auth.key, and each request carries its key in the apikey header or query parameter (or whichever you configured there). The key is a secret, so it identifies the client safely:
// config/kipchak.auth.key.php
'authorised_keys' => [
'k_live_8f2c...' => 'acme-corp',
'k_live_31d0...' => 'globex',
],
'header' => 'apikey',
'query_param' => 'apikey',
// config/kipchak.subashi.php
'rate_limit_rules' => [
[
'name' => 'Per API key (header)',
'conditions' => [['type' => 'header', 'key' => 'apikey', 'operator' => 'exists']],
'rate_limit' => ['limit' => 1000, 'window' => 3600, 'key_prefix' => 'apikey', 'key_source' => 'header:apikey'],
],
[
'name' => 'Per API key (query)',
'conditions' => [
['type' => 'header', 'key' => 'apikey', 'operator' => 'not_exists'],
['type' => 'query_param', 'key' => 'apikey', 'operator' => 'exists'],
],
'rate_limit' => ['limit' => 1000, 'window' => 3600, 'key_prefix' => 'apikey', 'key_source' => 'query:apikey'],
],
[
// Invalid keys get their own counter each, so cap every caller by address as well.
'name' => 'Per IP',
'conditions' => [],
'rate_limit' => ['limit' => 300, 'window' => 60, 'key_prefix' => 'ip', 'key_source' => 'ip'],
],
],
Both API key rules share the prefix apikey, so a client is counted once whether it sends its key in the header or the query string.
To give some clients a higher limit, match their keys with in_list and exclude them from the general rule with not_in_list. Every matching rule counts, so without the exclusion they would also be held to the lower limit:
[
'name' => 'Partners',
'conditions' => [['type' => 'header', 'key' => 'apikey', 'operator' => 'in_list', 'values' => [env('ACME_API_KEY', '')]]],
'rate_limit' => ['limit' => 10000, 'window' => 3600, 'key_prefix' => 'partner', 'key_source' => 'header:apikey'],
],
[
'name' => 'Everyone else',
'conditions' => [
['type' => 'header', 'key' => 'apikey', 'operator' => 'exists'],
['type' => 'header', 'key' => 'apikey', 'operator' => 'not_in_list', 'values' => [env('ACME_API_KEY', '')]],
],
'rate_limit' => ['limit' => 1000, 'window' => 3600, 'key_prefix' => 'apikey', 'key_source' => 'header:apikey'],
],
This puts the keys in two config files. Limits per client and plan, by the client name in kipchak.auth.key, are what Quotas are for.
Tokens from an identity provider (auth-jwks, auth-jwt)
With JWKS or JWT authentication, clients are not defined in Kipchak: your identity provider issues signed tokens, and the client's identity is a claim inside them, such as sub, client_id or azp. The firewall runs before the token is verified, so it must not read claims: an unverified token can claim to be anyone.
The bearer token itself is a secret, so it is safe to count per token:
[
'name' => 'Per bearer token',
'conditions' => [['type' => 'header', 'key' => 'Authorization', 'operator' => 'exists']],
'rate_limit' => ['limit' => 600, 'window' => 60, 'key_prefix' => 'bearer', 'key_source' => 'header:Authorization'],
],
[
'name' => 'Per IP',
'conditions' => [],
'rate_limit' => ['limit' => 300, 'window' => 60, 'key_prefix' => 'ip', 'key_source' => 'ip'],
],
Each new access token starts a new count, so this limits bursts within a token's lifetime rather than a client's total use. Limits per client or per plan, keyed by the verified sub or client_id, need to run after the token is verified. That is what Quotas are for.
Signed requests (auth-hmac)
Request Signing clients send a new Kipchak-Signature on every request, and the key ID inside it is not a secret, so neither identifies a client safely before the signature is verified. Limit signed traffic per IP in the firewall:
[
'name' => 'Signed API calls per IP',
'conditions' => [['type' => 'header', 'key' => 'Kipchak-Signature', 'operator' => 'exists']],
'rate_limit' => ['limit' => 1200, 'window' => 60, 'key_prefix' => 'signed', 'key_source' => 'ip'],
],
Webhook senders such as Stripe deliver from many addresses in bursts. Whitelist their paths, or give them a generous rule of their own, rather than holding them to a per-IP limit meant for API clients.
Usage
Once configured, Subashi works automatically as middleware.
Basic Workflow
- Incoming Request: The middleware intercepts the HTTP request.
- Whitelist Check: Is the IP or API key trusted? → Allow.
- Blacklist Check: Is the User-Agent malicious? → Block.
- Rate Limit Check: Has the user exceeded their limit? → Block.
- Default Action: If no rules match → Allow (based on config).
Example: Blocking a Specific Bot
To block a bot named "BadBot", add this to your blacklist array in the config:
[
'name' => 'Block BadBot',
'conditions' => [
[
'type' => 'header',
'key' => 'User-Agent',
'operator' => 'contains',
'value' => 'BadBot',
],
],
// Optional to override the default response code:
'response_code' => 401,
// Optional to override the default response message:
'response_message' => 'Unauthorized',
],
Example: Whitelisting an IP
To ensure your office IP is never blocked or rate-limited:
[
'name' => 'Office IP',
'conditions' => [
[
'type' => 'ip',
'operator' => 'equals',
'values' => ['203.0.113.50'],
],
],
],
Git Repository
The source code for this middleware is available on 1x.ax at https://1x.ax/mamluk/kipchak/middlewares/subashi.