Skip to content

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

php
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:

php
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:

php
$rules = SecRuleLoader::fromFile('/etc/phirewall/owasp-custom.conf');

From Multiple Files

Load and merge multiple rule files (all must be in the same directory):

php
$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):

php
// 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:

php
$report = SecRuleLoader::fromStringWithReport($rulesText);
$rules = $report['rules'];    // CoreRuleSet
$parsed = $report['parsed'];  // int - Successfully parsed rules
$skipped = $report['skipped']; // int - Lines that were skipped

SecRuleLoader API

MethodParametersDescription
fromString()string $rulesText, ?string $contextFolderParse rules from a string
fromFile()string $filePathLoad rules from a single file
fromFiles()list<string> $pathsLoad and merge multiple files
fromDirectory()string $dir, ?callable $filterLoad all files in a directory
fromStringWithReport()string $rulesTextParse with statistics

Supported SecRule Syntax

Phirewall supports a subset of the ModSecurity SecRule language:

Variables

VariableDescription
ARGSAll request parameters (query string + body)
ARGS_NAMESNames of all request parameters
REQUEST_URIFull request URI including query string
REQUEST_METHODHTTP method (GET, POST, etc.)
QUERY_STRINGRaw query string
REQUEST_FILENAMERequest path without query string
REQUEST_HEADERSAll request header values
REQUEST_HEADERS_NAMESNames of all request headers
REQUEST_COOKIESAll cookie values
REQUEST_COOKIES_NAMESNames of all cookies

Operators

OperatorSyntaxDescription
@rx@rx patternPCRE regular expression match
@contains@contains textCase-insensitive substring match
@streq@streq textCase-insensitive exact string match
@startswith@startswith textCase-insensitive prefix match
@beginswith@beginswith textAlias for @startswith
@endswith@endswith textCase-insensitive suffix match
@pm@pm word1 word2Phrase match (case-insensitive, any of the listed words)
@pmFromFile@pmFromFile file.txtPhrase match from a file (one phrase per line)

Actions

ActionDescription
id:NRule ID (required, must be unique)
phase:NProcessing phase (currently informational)
denyBlock the request (required for the rule to trigger blocking)
blockAlias 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:

php
$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

php
$rules->enable(941110);

Checking Rule State

php
if ($rules->isEnabled(941110)) {
    echo "Rule 941110 is active";
}

Listing Rule IDs

php
$ids = $rules->ids(); // Returns list<int> of all rule IDs

Getting a Specific Rule

php
$rule = $rules->getRule(942100);

OWASP Diagnostics Header

Enable the diagnostics header to see which OWASP rule matched:

php
$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: 942100

INFO

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:

php
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:

php
// 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 into

WARNING

@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
text
SecRule ARGS "@rx (?i)union.*select" "id:942100,phase:2,deny,msg:'SQLi'"
       ^^^^  ^^^                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
       |     |                       Actions (parsed into a map)
       |     Operator --> OperatorEvaluatorFactory --> RegexEvaluator
       Variable --------> VariableCollectorFactory --> ArgsCollector

When 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:

VariableCollector ClassSource
ARGSArgsCollectorQuery params + parsed body (names and values)
ARGS_NAMESArgsNamesCollectorQuery param + body parameter names
REQUEST_URIRequestUriCollectorFull URI including query string
REQUEST_METHODRequestMethodCollectorHTTP method
QUERY_STRINGQueryStringCollectorRaw query string
REQUEST_FILENAMERequestFilenameCollectorURI path without query string
REQUEST_HEADERSRequestHeadersCollectorAll header values
REQUEST_HEADERS_NAMESRequestHeadersNamesCollectorHeader names
REQUEST_COOKIESRequestCookiesCollectorAll cookie values
REQUEST_COOKIES_NAMESRequestCookiesNamesCollectorCookie names

Operator Evaluators

Each CRS operator maps to an OperatorEvaluatorInterface implementation:

OperatorEvaluator ClassBehavior
@rxRegexEvaluatorPCRE match with auto-delimiters and Unicode mode; values exceeding 8 KiB are skipped (not matched)
@containsContainsEvaluatorCase-insensitive substring search
@streqStringEqualEvaluatorCase-insensitive exact match
@startswith / @beginswithStartsWithEvaluatorCase-insensitive prefix match
@endswithEndsWithEvaluatorCase-insensitive suffix match
@pmPhraseMatchEvaluatorMulti-phrase case-insensitive match
@pmFromFilePhraseMatchFromFileEvaluatorPhrase 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:

php
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:

php
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

OperatorRelative CostNotes
@streqLowSimple string comparison
@containsLowSubstring search
@startswith / @endswithLowPrefix/suffix check
@pmMediumCase-insensitive phrase matching (pre-compiled)
@rxHighPCRE 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

  1. Start with a minimal rule set. Add rules incrementally and test each addition against your application's normal traffic to identify false positives.

  2. 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.).

  3. 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()
    );
  4. Log matched rules. Use the observability system to log which rules are triggering and tune accordingly.

  5. Keep rules in version control. Store rule files alongside your application code and deploy them together.