OWASP Core Rule Set
Phirewall includes a built-in OWASP CRS (Core Rule Set) engine that parses and evaluates ModSecurity-compatible SecRule directives. This provides web application firewall (WAF) capabilities for detecting SQL injection, XSS, remote code execution, path traversal, and other common attack vectors.
Quick Start
use Flowd\Phirewall\Config;
use Flowd\Phirewall\Owasp\SecRuleLoader;
use Flowd\Phirewall\Store\InMemoryCache;
$config = new Config(new InMemoryCache());
$rules = SecRuleLoader::fromString(<<<'CRS'
SecRule ARGS "@rx (?i)\bunion\b.*\bselect\b" "id:942100,phase:2,deny,msg:'SQL Injection'"
SecRule ARGS "@rx (?i)<script[^>]*>" "id:941100,phase:2,deny,msg:'XSS'"
CRS);
$config->blocklists->owasp('owasp', $rules);Loading Rules
From a String
Inline rules for simple configurations:
use Flowd\Phirewall\Owasp\SecRuleLoader;
$rules = SecRuleLoader::fromString(<<<'CRS'
SecRule ARGS "@rx (?i)\bunion\b.*\bselect\b" "id:942100,phase:2,deny,msg:'SQL Injection'"
SecRule ARGS "@rx (?i)<script[^>]*>" "id:941100,phase:2,deny,msg:'XSS'"
CRS);From a File
Load rules from a .conf file:
$rules = SecRuleLoader::fromFile('/etc/phirewall/owasp-custom.conf');From Multiple Files
Load and merge multiple rule files (all must be in the same directory):
$rules = SecRuleLoader::fromFiles([
'/etc/phirewall/rules/sqli.conf',
'/etc/phirewall/rules/xss.conf',
'/etc/phirewall/rules/rce.conf',
]);From a Directory
Load all rule files in a directory (processed in sorted order):
// Load all files
$rules = SecRuleLoader::fromDirectory('/etc/phirewall/rules/');
// Load only .conf files
$rules = SecRuleLoader::fromDirectory('/etc/phirewall/rules/',
fn(string $path): bool => str_ends_with($path, '.conf')
);With Parse Report
Get statistics about parsing results:
$report = SecRuleLoader::fromStringWithReport($rulesText);
$rules = $report['rules']; // CoreRuleSet
$parsed = $report['parsed']; // int - Successfully parsed rules
$skipped = $report['skipped']; // int - Lines that were skippedSecRuleLoader API
| Method | Parameters | Description |
|---|---|---|
fromString() | string $rulesText, ?string $contextFolder | Parse rules from a string |
fromFile() | string $filePath | Load rules from a single file |
fromFiles() | list<string> $paths | Load and merge multiple files |
fromDirectory() | string $dir, ?callable $filter | Load all files in a directory |
fromStringWithReport() | string $rulesText | Parse with statistics |
Supported SecRule Syntax
Phirewall supports a subset of the ModSecurity SecRule language:
Variables
| Variable | Description |
|---|---|
ARGS | All request parameters (query string + body) |
ARGS_NAMES | Names of all request parameters |
REQUEST_URI | Full request URI including query string |
REQUEST_METHOD | HTTP method (GET, POST, etc.) |
QUERY_STRING | Raw query string |
REQUEST_FILENAME | Request path without query string |
REQUEST_HEADERS | All request header values |
REQUEST_HEADERS_NAMES | Names of all request headers |
REQUEST_COOKIES | All cookie values |
REQUEST_COOKIES_NAMES | Names of all cookies |
Operators
| Operator | Syntax | Description |
|---|---|---|
@rx | @rx pattern | PCRE regular expression match |
@contains | @contains text | Case-insensitive substring match |
@streq | @streq text | Case-insensitive exact string match |
@startswith | @startswith text | Case-insensitive prefix match |
@beginswith | @beginswith text | Alias for @startswith |
@endswith | @endswith text | Case-insensitive suffix match |
@pm | @pm word1 word2 | Phrase match (case-insensitive, any of the listed words) |
@pmFromFile | @pmFromFile file.txt | Phrase match from a file (one phrase per line) |
Actions
| Action | Description |
|---|---|
id:N | Rule ID (required, must be unique) |
phase:N | Processing phase (currently informational) |
deny | Block the request (required for the rule to trigger blocking) |
block | Alias for deny -- both trigger blocking |
msg:'text' | Human-readable description for logging |
Line Continuation
Rules can span multiple lines using backslash continuation:
SecRule ARGS "@rx (?i)\bunion\b.*\bselect\b" \
"id:942100,phase:2,deny,msg:'SQL Injection'"Comments
Lines starting with # are ignored:
# SQL Injection rules
SecRule ARGS "@rx (?i)\bunion\b.*\bselect\b" "id:942100,phase:2,deny,msg:'SQLi'"Managing Rules
Disabling Rules
Disable specific rules that cause false positives:
$rules = SecRuleLoader::fromString(/* ... */);
// Disable a specific rule by ID
$rules->disable(941110); // XSS Event Handler (too aggressive for some apps)
$config->blocklists->owasp('owasp', $rules);Re-enabling Rules
$rules->enable(941110);Checking Rule State
if ($rules->isEnabled(941110)) {
echo "Rule 941110 is active";
}Listing Rule IDs
$ids = $rules->ids(); // Returns list<int> of all rule IDsGetting a Specific Rule
$rule = $rules->getRule(942100);OWASP Diagnostics Header
Enable the diagnostics header to see which OWASP rule matched:
$config->enableResponseHeaders();
$config->enableOwaspDiagnosticsHeader();When an OWASP rule blocks a request, the response includes:
X-Phirewall: blocklist
X-Phirewall-Matched: owasp
X-Phirewall-Owasp-Rule: 942100INFO
X-Phirewall and X-Phirewall-Matched require enableResponseHeaders(). The X-Phirewall-Owasp-Rule header is controlled independently by enableOwaspDiagnosticsHeader().
WARNING
Disable the diagnostics header in production. It reveals which security rules are in place, which could help attackers craft evasion payloads.
Common Rule Sets
SQL Injection (SQLi)
SecRule ARGS "@rx (?i)(\bunion\b.*\bselect\b|\bselect\b.*\bfrom\b)" \
"id:942100,phase:2,deny,msg:'SQL Injection'"
SecRule ARGS "@rx ('\s*(or|and)\s*'|'\s*=\s*')" \
"id:942120,phase:2,deny,msg:'SQL Quote Injection'"
SecRule ARGS "@rx (?i)(drop|alter|create|truncate)\s+(table|database)" \
"id:942130,phase:2,deny,msg:'SQL DDL Injection'"Cross-Site Scripting (XSS)
SecRule ARGS "@rx (?i)<script[^>]*>" \
"id:941100,phase:2,deny,msg:'XSS Script Tag'"
SecRule ARGS "@rx (?i)\bon\w+\s*=" \
"id:941110,phase:2,deny,msg:'XSS Event Handler'"
SecRule ARGS "@rx (?i)javascript\s*:" \
"id:941120,phase:2,deny,msg:'XSS JavaScript Protocol'"Remote Code Execution (RCE)
SecRule ARGS "@rx (?i)(eval|exec|system|shell_exec|passthru)\s*\(" \
"id:933100,phase:2,deny,msg:'PHP Code Injection'"
SecRule ARGS "@rx (?i)(base64_decode|gzinflate|str_rot13)\s*\(" \
"id:933110,phase:2,deny,msg:'PHP Obfuscation'"Path Traversal
SecRule REQUEST_URI "@rx \.\.\/" \
"id:930100,phase:2,deny,msg:'Path Traversal'"
SecRule REQUEST_URI "@rx (?i)(%2e%2e%2f|%2e%2e/)" \
"id:930110,phase:2,deny,msg:'Encoded Path Traversal'"Production Configuration
A comprehensive rule set for production:
use Flowd\Phirewall\Config;
use Flowd\Phirewall\Owasp\SecRuleLoader;
use Flowd\Phirewall\Store\RedisCache;
use Predis\Client as PredisClient;
$redis = new PredisClient(getenv('REDIS_URL') ?: 'redis://localhost:6379');
$config = new Config(new RedisCache($redis));
$rules = SecRuleLoader::fromString(<<<'CRS'
# ── SQL Injection ──────────────────────────────────────────
SecRule ARGS "@rx (?i)(\bunion\b.*\bselect\b|\bselect\b.*\bfrom\b)" \
"id:942100,phase:2,deny,msg:'SQL Injection'"
SecRule ARGS "@rx ('\s*(or|and)\s*'|'\s*=\s*')" \
"id:942120,phase:2,deny,msg:'SQL Quote Injection'"
# ── XSS ───────────────────────────────────────────────────
SecRule ARGS "@rx (?i)<script[^>]*>" \
"id:941100,phase:2,deny,msg:'XSS Script Tag'"
SecRule ARGS "@rx (?i)\bon\w+\s*=" \
"id:941110,phase:2,deny,msg:'XSS Event Handler'"
SecRule ARGS "@rx (?i)javascript\s*:" \
"id:941120,phase:2,deny,msg:'XSS JavaScript Protocol'"
# ── Remote Code Execution ─────────────────────────────────
SecRule ARGS "@rx (?i)(eval|exec|system|shell_exec|passthru)\s*\(" \
"id:933100,phase:2,deny,msg:'PHP Code Injection'"
SecRule ARGS "@rx (?i)(base64_decode|gzinflate|str_rot13)\s*\(" \
"id:933110,phase:2,deny,msg:'PHP Obfuscation'"
# ── Path Traversal ────────────────────────────────────────
SecRule REQUEST_URI "@rx \.\.\/" \
"id:930100,phase:2,deny,msg:'Path Traversal'"
SecRule REQUEST_URI "@rx (?i)(%2e%2e%2f|%2e%2e/)" \
"id:930110,phase:2,deny,msg:'Encoded Path Traversal'"
CRS);
// Disable rules that cause false positives in your application
// $rules->disable(941110); // XSS Event Handler
$config->blocklists->owasp('owasp', $rules);File-Based Rule Management
For larger deployments, manage rules in files:
// Load from a directory of .conf files
$rules = SecRuleLoader::fromDirectory('/etc/phirewall/rules/',
fn(string $path): bool => str_ends_with($path, '.conf')
);
// Check parsing results
$report = SecRuleLoader::fromStringWithReport(
file_get_contents('/etc/phirewall/rules/custom.conf')
);
echo "Parsed: {$report['parsed']}, Skipped: {$report['skipped']}\n";@pmFromFile Support
The @pmFromFile operator loads phrase lists from external files. The file path is resolved relative to the rule file's directory:
# rules/sqli.conf
SecRule ARGS "@pmFromFile sqli-keywords.txt" "id:942200,phase:2,deny,msg:'SQLi keyword'"# rules/sqli-keywords.txt
union select
drop table
insert intoWARNING
@pmFromFile includes path traversal protection. Paths containing .. are rejected to prevent loading files outside the rules directory.
Architecture
The OWASP CRS engine uses a strategy pattern to keep rule evaluation extensible and maintainable. Each CoreRule delegates two concerns to dedicated strategy classes:
- Variable collectors (
VariableCollectorInterface) extract target values from the PSR-7 request - Operator evaluators (
OperatorEvaluatorInterface) match those values against the rule's pattern
SecRule ARGS "@rx (?i)union.*select" "id:942100,phase:2,deny,msg:'SQLi'"
^^^^ ^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| | Actions (parsed into a map)
| Operator --> OperatorEvaluatorFactory --> RegexEvaluator
Variable --------> VariableCollectorFactory --> ArgsCollectorWhen a rule is constructed, the factories resolve the variable names and operator into concrete strategy instances. On each request, CoreRule::matches() collects values via the variable collectors and passes them to the operator evaluator.
Variable Collectors
Each CRS variable maps to a VariableCollectorInterface implementation:
| Variable | Collector Class | Source |
|---|---|---|
ARGS | ArgsCollector | Query params + parsed body (names and values) |
ARGS_NAMES | ArgsNamesCollector | Query param + body parameter names |
REQUEST_URI | RequestUriCollector | Full URI including query string |
REQUEST_METHOD | RequestMethodCollector | HTTP method |
QUERY_STRING | QueryStringCollector | Raw query string |
REQUEST_FILENAME | RequestFilenameCollector | URI path without query string |
REQUEST_HEADERS | RequestHeadersCollector | All header values |
REQUEST_HEADERS_NAMES | RequestHeadersNamesCollector | Header names |
REQUEST_COOKIES | RequestCookiesCollector | All cookie values |
REQUEST_COOKIES_NAMES | RequestCookiesNamesCollector | Cookie names |
Operator Evaluators
Each CRS operator maps to an OperatorEvaluatorInterface implementation:
| Operator | Evaluator Class | Behavior |
|---|---|---|
@rx | RegexEvaluator | PCRE match with auto-delimiters and Unicode mode; values exceeding 8 KiB are skipped (not matched) |
@contains | ContainsEvaluator | Case-insensitive substring search |
@streq | StringEqualEvaluator | Case-insensitive exact match |
@startswith / @beginswith | StartsWithEvaluator | Case-insensitive prefix match |
@endswith | EndsWithEvaluator | Case-insensitive suffix match |
@pm | PhraseMatchEvaluator | Multi-phrase case-insensitive match |
@pmFromFile | PhraseMatchFromFileEvaluator | Phrase match from file with path traversal protection |
Unsupported operators resolve to UnsupportedOperatorEvaluator, which never matches (safe no-op).
ReDoS protection: 8 KiB length guard on @rx
RegexEvaluator skips any value whose byte length exceeds 8,192 bytes — the value is treated as non-matching. This is an intentional trade-off: running PCRE on unbounded attacker-controlled input risks catastrophic backtracking that can freeze the PHP process (ReDoS). Skipping overlength values mirrors the behavior of standard WAFs such as ModSecurity's SecRequestBodyLimit.
In practice, legitimate request values (query parameters, header values, cookie values) are rarely larger than a few kilobytes. If you are matching multi-megabyte request bodies via @rx, consider pre-processing them before passing to the firewall.
Adding Custom Operators
Implement OperatorEvaluatorInterface and register it in OperatorEvaluatorFactory:
namespace Flowd\Phirewall\Owasp\Operator;
final readonly class IpMatchEvaluator implements OperatorEvaluatorInterface
{
/** @param list<string> $cidrs */
public function __construct(private array $cidrs) {}
/** @param list<string> $values */
public function evaluate(array $values): bool
{
foreach ($values as $value) {
// Check if $value falls within any CIDR range
if ($this->matchesCidr($value)) {
return true;
}
}
return false;
}
private function matchesCidr(string $ip): bool
{
// CIDR matching logic
}
}Adding Custom Variables
Implement VariableCollectorInterface and register it in VariableCollectorFactory:
namespace Flowd\Phirewall\Owasp\Variable;
use Psr\Http\Message\ServerRequestInterface;
final readonly class RequestBodyCollector implements VariableCollectorInterface
{
/** @return list<string> */
public function collect(ServerRequestInterface $serverRequest): array
{
$body = (string) $serverRequest->getBody();
return $body !== '' ? [$body] : [];
}
}Performance
Caching
Each operator evaluator and variable collector is instantiated once per rule at construction time and reused across requests. Regular expressions are compiled on first use (with PCRE's internal JIT cache), phrase lists from @pmFromFile are loaded and cached per file path, and all other operators use simple string operations with no additional overhead. There is no need to cache the CoreRuleSet externally.
Operator Performance
| Operator | Relative Cost | Notes |
|---|---|---|
@streq | Low | Simple string comparison |
@contains | Low | Substring search |
@startswith / @endswith | Low | Prefix/suffix check |
@pm | Medium | Case-insensitive phrase matching (pre-compiled) |
@rx | High | PCRE regex (compiled on first use, cached) |
TIP
Use @pm for simple keyword matching and @rx for complex patterns. @pm is significantly faster for lists of words.
Best Practices
Start with a minimal rule set. Add rules incrementally and test each addition against your application's normal traffic to identify false positives.
Use unique rule IDs. Each rule must have a unique
id. Use the OWASP convention: 9xxxxx for attack categories (942xxx for SQLi, 941xxx for XSS, etc.).Combine with fail2ban. Use OWASP rules to detect attacks and fail2ban to ban repeat offenders:
php$config->blocklists->owasp('owasp', $rules); $config->fail2ban->add('persistent-attacker', threshold: 5, period: 60, ban: 86400, filter: fn($req) => true, key: KeyExtractors::ip() );Log matched rules. Use the observability system to log which rules are triggering and tune accordingly.
Keep rules in version control. Store rule files alongside your application code and deploy them together.