Skip to content

Portable Config

PortableConfig expresses a firewall ruleset as plain, JSON-serializable data instead of PHP closures. Because a ruleset is just data, you can:

  • store it in a database and pick up rule changes on the next request,
  • ship it through a config service (etcd, Consul, S3, a settings table),
  • diff and review it in git, or
  • share one ruleset across many apps, processes, or languages

…and then materialize a live Config from it with Config::combine(); the schema is pure data and never carries a cache. Closures are never serialized, so the surface is intentionally a safe, declarative subset (see Not portable by design).

Building and round-tripping

Build a ruleset fluently, export it with toArray() (or json_encode() the result), and rebuild it with fromArray(), then materialize it onto a Config with Config::combine():

php
use Flowd\Phirewall\Config;
use Flowd\Phirewall\Http\Firewall;
use Flowd\Phirewall\Pattern\PatternKind;
use Flowd\Phirewall\Portable\PortableConfig;

$portable = PortableConfig::create()
    ->setKeyPrefix('shop')
    ->enableRateLimitHeaders()
    ->enableResponseHeaders()
    ->safelist('health', PortableConfig::filterPathEquals('/health'))
    ->blocklist('admin-probe', PortableConfig::filterPathPrefix('/wp-admin'))
    ->blocklist('scanners', PortableConfig::filterKnownScanners())
    ->blocklist('bad-net', PortableConfig::filterIp(['203.0.113.0/24']))
    ->throttle('api', limit: 100, period: 60, key: PortableConfig::keyHashedHeader('X-Api-Key'), sliding: true)
    ->allow2ban('volume-cap', threshold: 1000, period: 60, ban: 300, key: PortableConfig::keyIp())
    ->fail2ban('wp-login-probe', threshold: 5, period: 60, ban: 900, filter: PortableConfig::filterPathEquals('/wp-login.php'), key: PortableConfig::keyIp())
    ->patternBlocklist('threats', [
        PortableConfig::patternEntry(PatternKind::CIDR, '10.66.0.0/16'),
        PortableConfig::patternEntry(PatternKind::PATH_REGEX, '#/\.git(/|$)#'),
    ]);

// Export as data …
$json = json_encode($portable->toArray(), JSON_THROW_ON_ERROR);

// … and rebuild a live Config somewhere else.
$config   = (new Config($cache))->combine(PortableConfig::fromArray(json_decode($json, true, 512, JSON_THROW_ON_ERROR)));
$firewall = new Firewall($config);

fromArray() validates the shape of the data (rule/filter/key types, regex patterns compile, pattern-entry fields) and throws InvalidArgumentException on anything malformed. It does not verify authenticity; for that, see Signed transport.

The catalogue

Everything PortableConfig can express today.

Rules

BuilderNotes
safelist(name, filter)Bypass all checks when the filter matches
blocklist(name, filter)Deny (403) when the filter matches
throttle(name, limit, period, key, sliding = false, scope = null)Fixed or sliding-window rate limit (429); the optional scope filter restricts which requests the throttle counts (e.g. only /api)
fail2ban(name, threshold, period, ban, filter, key)Auto-ban after repeated matching ("bad") requests
allow2ban(name, threshold, period, ban, key)Hard volume cap: ban after too many total requests for a key
track(name, period, filter, key, limit = null)Passive counting with optional alert threshold
addPatternBackend(name, entries)Register a reusable catalogue of block patterns
blocklistFromBackend(name, backendName)Add a blocklist that matches against a registered backend
patternBlocklist(name, entries)Convenience: register a backend and a blocklist under one name

Filters (request predicates)

FactoryMatches when …
filterAll()always
filterNone()never: a filter that never matches; use it for a rule that must not be assertable from any request property (e.g. a fail2ban driven solely by RequestContext::recordFailure)
filterPathEquals(path)the path equals path
filterPathPrefix(prefix)the path starts with prefix
filterPathRegex(pattern)the path matches the PCRE pattern (delimiters included)
filterMethodEquals(method)the HTTP method equals method (case-insensitive)
filterMethodIn(methods)the HTTP method is one of methods
filterHeaderEquals(name, value)header name equals value
filterHeaderPresent(name)header name is present with any non-empty value
filterHeaderRegex(name, pattern)header name matches the PCRE pattern
filterIp(ipsOrCidrs)the client IP is in the list (CIDR-aware, IPv4/IPv6), backed by IpMatcher
filterKnownScanners(patterns = null)the User-Agent matches a known scanner; null uses the curated default list, backed by KnownScannerMatcher
filterSuspiciousHeaders(requiredHeaders = null)a required browser header is missing; null uses the default set, backed by SuspiciousHeadersMatcher

filterIp, filterKnownScanners, and filterSuspiciousHeaders compile to the dedicated matcher classes (so you get their diagnostics and CIDR handling); the remaining filters compile to a request-predicate closure.

WARNING

filterHeaderEquals, filterHeaderPresent, and filterHeaderRegex are rejected on safelist() (and on fromArray() deserialize): a client-controlled header value would be a forgeable bypass token (anyone presenting it skips every downstream rule). They remain valid on blocklists, throttles, fail2ban, and track rules.

Key extractors

FactoryKeys on
keyIp()client IP (REMOTE_ADDR)
keyMethod()HTTP method
keyPath()request path
keyHeader(name)raw value of header name
keyHashedHeader(name)sha256 fingerprint of header name, preferred for credential-bearing headers (Authorization, Cookie, X-Api-Key) so the raw value never reaches the cache/ban registry

TIP

keyIp() keys on REMOTE_ADDR, which behind a CDN or load balancer is the proxy's address, not the client's. The IP resolver is a closure and therefore not portable; set it on the rebuilt Config with setIpResolver(KeyExtractors::clientIp(new TrustedProxyResolver([...]))). See Client IP behind proxies.

Pattern kinds (PortableConfig::patternEntry())

Pattern backends carry a list of entries; each entry has a PatternKind:

KindMatches
PatternKind::IPexact client IP
PatternKind::CIDRclient IP within a CIDR range
PatternKind::PATH_EXACTexact path
PatternKind::PATH_PREFIXpath prefix
PatternKind::PATH_REGEXpath PCRE pattern
PatternKind::HEADER_EXACTnamed header equals value (entry target = header name)
PatternKind::HEADER_REGEXnamed header matches PCRE pattern (entry target = header name)
PatternKind::REQUEST_REGEXpattern over path + query + headers

patternEntry() also accepts optional target, expiresAt, addedAt, and a scalar metadata map, all of which round-trip as data, so an entry can carry its own expiry and provenance (handy when the catalogue lives in a database).

Options

BuilderEffect on the built Config
enableRateLimitHeaders()emit X-RateLimit-* headers
enableResponseHeaders()emit X-Phirewall-* headers
enableOwaspDiagnosticsHeader()emit the OWASP diagnostics header
setFailOpen(bool)fail-open (default) vs fail-closed on backend errors
setKeyPrefix(prefix)cache-key prefix

Rules in a database

Pattern backends and the portable schema are the natural fit for a block catalogue you maintain outside code, e.g. a blocked_patterns table or a threat feed. Store the (ideally signed) ruleset in your store, and on each request load it and build the Config. Changing the stored rules then takes effect on the next request, with no deploy:

php
use Flowd\Phirewall\Http\Firewall;
use Flowd\Phirewall\Portable\PortableConfig;

// $store->load() returns the signed blob from your DB / cache / config service.
$portable = PortableConfig::loadSigned($store->load(), $secret);
$firewall = new Firewall((new Config($cache))->combine($portable));

Under classic PHP-FPM each request is a fresh process, so this runs once per request and always reflects the current rules. To avoid querying the database on every request, put a shared cache (APCu, for example) in front of the store.

Long-running workers

Under a long-running worker runtime (Swoole, RoadRunner, FrankenPHP worker mode, Octane) the process handles many requests, so keep the built Firewall in memory and rebuild it only when the stored ruleset version changes:

php
// $store->load() returns ['version' => int, 'blob' => string].
$row = $store->load();
if ($loadedVersion !== $row['version']) {
    $portable = PortableConfig::loadSigned($row['blob'], $secret);
    $firewall = new Firewall((new Config($cache))->combine($portable));
    $loadedVersion = $row['version'];
}

See examples/29-portable-config.php for a runnable version with the database simulated in memory.

Signed transport

When the serialized config is read back from storage you do not fully control (a shared filesystem, an S3 bucket, etcd, a config service, a git repo that accepts external contributions), an attacker who can write the blob could inject an allow-all safelist and disable the firewall. fromArray() validates shape only, not authenticity.

toSignedJson() / loadSigned() close that gap with an HMAC-SHA256 envelope:

php
$signed   = $portable->toSignedJson($secretKey);             // <header>.<payload>.<signature>
$restored = PortableConfig::loadSigned($signed, $secretKey); // verifies before returning
  • The envelope is JWS-compact-style: <header>.<payload>.<signature>, where the signature is HMAC-SHA256 over <header>.<payload>.
  • Verification uses a constant-time hash_equals() compare. Any tampering (payload edit, key substitution, or an alg=none downgrade attempt) is rejected with a RuntimeException before the rules are applied.
  • Signing keys must be at least 16 bytes; 32 random bytes is recommended (random_bytes(32)), stored in your secrets manager.

Threat model

Signing protects integrity and authenticity, not confidentiality: the payload is base64url-encoded, not encrypted, so anyone who can read the envelope can read the ruleset. Distribute the secret only to the producer and the consumers, rotate it like any other credential, and keep it out of the serialized blob. Signing also does not make a ruleset safe to run if you do not trust its author; it only proves the bytes were not altered after signing.

See examples/28-portable-config-signing.php for a signing + tamper-rejection walkthrough.

Not portable by design

A few capabilities cannot be represented as pure data and are intentionally excluded from the schema. Configure these directly on the Config you build before (or after) combining the portable rules in:

ExcludedWhy
Trusted-bot reverse-DNS safelisting (TrustedBotMatcher)needs live DNS resolution and an optional cache at request time
OWASP Core Rule Set (blocklists->owasp())a ruleset is parsed SecRule objects / rule files, not a small data blob
File-backed lists (fileIp, filePatternBackend)filesystem paths are environment-specific; the in-memory pattern backend is the portable equivalent
Closure-driven dynamic throttle limits/periods, $config->throttles->multi()limits/periods can be arbitrary PHP closures and cannot be serialized (express the multi-window case as several throttles->add() entries; sliding is supported)
Response factories, ipResolver, discriminatorNormalizerthese are closures / objects, not declarative data

Examples