diff --git a/README.md b/README.md index 7210b7b..97e6e7e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Lite & fast micro PHP Web Application Firewall (WAF) rules management library th The library ships with: - A `Condition` builder that mirrors the API of [`Utopia\Database\Query`](https://github.com/utopia-php/database/blob/main/src/Database/Query.php), including JSON parsing helpers and logical operators. -- Action specific rule classes (`Bypass`, `Deny`, `Challenge`, `RateLimit`, `Redirect`). +- Action specific rule classes (`Bypass`, `Deny`, `Challenge`, `RateLimit`, `Redirect`, `Headers`). - A dependency-free `Firewall` orchestrator that evaluates rules against any set of request attributes. ## Installation @@ -99,6 +99,34 @@ if ($firewall->verify()) { } ``` +### Response Headers + +`Headers` rules carry response headers to add when the rule matches. They are non-terminal: a match does not decide the request, so the firewall records the rule and keeps evaluating the rules after it. Collect the matches with `getMatchedNonTerminalRules()` and write the headers onto your response. + +```php +use Utopia\WAF\Rules\Headers; + +$firewall->addRule(new Headers([ + Condition::startsWith('path', '/api'), +], headers: ['X-Frame-Options' => 'DENY'])); + +$firewall->addRule(new Deny([ + Condition::equal('ip', ['203.0.113.12']), +])); + +$allowed = $firewall->verify(); // decided by Deny, or false when no terminal rule matches + +foreach ($firewall->getMatchedNonTerminalRules() as $rule) { + if ($rule instanceof Headers) { + foreach ($rule->getHeaders() as $name => $value) { + // Add the header to your response + } + } +} +``` + +Evaluation stops at the first terminal match, so place `Headers` rules ahead of the terminal rules they should apply alongside. Header names and values are validated on construction; a value containing line breaks is rejected. + ### Testing Locally ```bash diff --git a/src/Firewall.php b/src/Firewall.php index d09ddc1..029c323 100644 --- a/src/Firewall.php +++ b/src/Firewall.php @@ -25,6 +25,11 @@ class Firewall private ?Rule $lastMatchedRule = null; + /** + * @var array + */ + private array $matchedNonTerminalRules = []; + public function __construct() { $this->attributeTypes = [ @@ -121,28 +126,53 @@ public function clearRules(): self return $this; } + /** + * The terminal rule that decided the last verify() call, if any. + */ public function getLastMatchedRule(): ?Rule { return $this->lastMatchedRule; } + /** + * The non-terminal rules that matched during the last verify() call, in + * evaluation order. Rules placed after the terminal match are never + * evaluated, so they are not included. + * + * @return array + */ + public function getMatchedNonTerminalRules(): array + { + return $this->matchedNonTerminalRules; + } + /** * Evaluate registered rules in order against populated attributes. * - * Sets the matched rule via getLastMatchedRule() when a rule's conditions - * match. Returns whether that rule's action allows the request to continue - * (bypass/rateLimit) or should be blocked (deny/challenge/redirect). - * Returns false when no rule matches. + * Sets the matched rule via getLastMatchedRule() when a terminal rule's + * conditions match. Returns whether that rule's action allows the request + * to continue (bypass/rateLimit) or should be blocked + * (deny/challenge/redirect). Returns false when no terminal rule matches. + * + * Non-terminal rules (headers) that match on the way are collected via + * getMatchedNonTerminalRules() and do not stop evaluation. */ public function verify(): bool { $this->lastMatchedRule = null; + $this->matchedNonTerminalRules = []; foreach ($this->rules as $rule) { if (!$rule->matches($this->attributes, $this->attributeTypes)) { continue; } + if (!$rule->isTerminal()) { + $this->matchedNonTerminalRules[] = $rule; + + continue; + } + $this->lastMatchedRule = $rule; return $this->applyRule($rule); diff --git a/src/Rule.php b/src/Rule.php index 16dffe1..791e913 100644 --- a/src/Rule.php +++ b/src/Rule.php @@ -9,6 +9,7 @@ abstract class Rule public const ACTION_CHALLENGE = 'challenge'; public const ACTION_RATE_LIMIT = 'rateLimit'; public const ACTION_REDIRECT = 'redirect'; + public const ACTION_HEADERS = 'headers'; /** * @var array @@ -36,6 +37,16 @@ static function (Condition|array $condition): Condition { abstract public function getAction(): string; + /** + * Whether a match ends rule evaluation. Terminal rules decide the request; + * non-terminal rules only contribute something (such as response headers) + * and let the rules after them run. + */ + public function isTerminal(): bool + { + return true; + } + public function setId(string $id): self { $this->id = $id; diff --git a/src/Rules/Headers.php b/src/Rules/Headers.php new file mode 100644 index 0000000..fa1a091 --- /dev/null +++ b/src/Rules/Headers.php @@ -0,0 +1,63 @@ + + */ + private array $headers; + + /** + * @param array<\Utopia\WAF\Condition|array> $conditions + * @param array $headers Response headers, keyed by header name. + */ + public function __construct(array $conditions = [], array $headers = []) + { + parent::__construct($conditions); + + if ($headers === []) { + throw new \InvalidArgumentException('Headers rule requires at least one header.'); + } + + foreach ($headers as $name => $value) { + if (!\is_string($name) || preg_match('/^[A-Za-z0-9!#$%&\'*+.^_`|~-]+$/', $name) !== 1) { + throw new \InvalidArgumentException('Invalid header name: ' . $name); + } + + // Control characters would let a value smuggle in further headers. + if (!\is_string($value) || preg_match('/[\x00-\x08\x0A-\x1F\x7F]/', $value) === 1) { + throw new \InvalidArgumentException('Invalid value for header: ' . $name); + } + } + + $this->headers = $headers; + } + + public function getAction(): string + { + return self::ACTION_HEADERS; + } + + public function isTerminal(): bool + { + return false; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } +} diff --git a/tests/FirewallTest.php b/tests/FirewallTest.php index 841afb4..097dcf2 100644 --- a/tests/FirewallTest.php +++ b/tests/FirewallTest.php @@ -7,6 +7,7 @@ use Utopia\WAF\Firewall; use Utopia\WAF\Rules\Bypass; use Utopia\WAF\Rules\Deny; +use Utopia\WAF\Rules\Headers; use Utopia\WAF\Rules\RateLimit; class FirewallTest extends TestCase @@ -149,4 +150,71 @@ public function testNotEqualIpConditionExcludesCidrBlock(): void $this->assertFalse($outside->verify()); $this->assertSame('rule_outside', $outside->getLastMatchedRule()?->getId()); } + + public function testHeadersRuleDoesNotStopEvaluation(): void + { + $headers = (new Headers([ + Condition::startsWith('path', '/api'), + ], headers: ['X-Frame-Options' => 'DENY']))->setId('rule_headers'); + + $deny = (new Deny([ + Condition::equal('method', ['POST']), + ]))->setId('rule_deny'); + + $firewall = new Firewall(); + $firewall->setAttributes(['path' => '/api/users', 'method' => 'POST']); + $firewall->addRule($headers); + $firewall->addRule($deny); + + // The headers rule matches first but the deny after it still decides. + $this->assertFalse($firewall->verify()); + $this->assertSame($deny, $firewall->getLastMatchedRule()); + $this->assertSame([$headers], $firewall->getMatchedNonTerminalRules()); + } + + public function testHeadersRulesAreCollectedInOrderWithoutTerminalMatch(): void + { + $first = new Headers([Condition::startsWith('path', '/api')], headers: ['X-Frame-Options' => 'DENY']); + $miss = new Headers([Condition::startsWith('path', '/admin')], headers: ['X-Robots-Tag' => 'noindex']); + $second = new Headers([], headers: ['Referrer-Policy' => 'no-referrer']); + + $firewall = new Firewall(); + $firewall->setAttribute('path', '/api/users'); + $firewall->setRules([$first, $miss, $second]); + + // No terminal rule matched, so the verdict is the same as for no match. + $this->assertFalse($firewall->verify()); + $this->assertNull($firewall->getLastMatchedRule()); + $this->assertSame([$first, $second], $firewall->getMatchedNonTerminalRules()); + } + + public function testHeadersRuleAfterTerminalMatchIsNotEvaluated(): void + { + $bypass = new Bypass([Condition::equal('method', ['GET'])]); + $headers = new Headers([], headers: ['X-Frame-Options' => 'DENY']); + + $firewall = new Firewall(); + $firewall->setAttribute('method', 'GET'); + $firewall->setRules([$bypass, $headers]); + + $this->assertTrue($firewall->verify()); + $this->assertSame($bypass, $firewall->getLastMatchedRule()); + $this->assertSame([], $firewall->getMatchedNonTerminalRules()); + } + + public function testMatchedNonTerminalRulesResetBetweenVerifyCalls(): void + { + $headers = new Headers([Condition::startsWith('path', '/api')], headers: ['X-Frame-Options' => 'DENY']); + + $firewall = new Firewall(); + $firewall->addRule($headers); + + $firewall->setAttribute('path', '/api/users'); + $firewall->verify(); + $this->assertSame([$headers], $firewall->getMatchedNonTerminalRules()); + + $firewall->setAttribute('path', '/home'); + $firewall->verify(); + $this->assertSame([], $firewall->getMatchedNonTerminalRules()); + } } diff --git a/tests/RulesTest.php b/tests/RulesTest.php index 25655ee..a07389a 100644 --- a/tests/RulesTest.php +++ b/tests/RulesTest.php @@ -7,6 +7,7 @@ use Utopia\WAF\Rules\Bypass; use Utopia\WAF\Rules\Challenge; use Utopia\WAF\Rules\Deny; +use Utopia\WAF\Rules\Headers; use Utopia\WAF\Rules\RateLimit; use Utopia\WAF\Rules\Redirect; @@ -67,4 +68,35 @@ public function testRedirectRule(): void $this->assertSame('/new', $rule->getLocation()); $this->assertSame(301, $rule->getStatusCode()); } + + public function testHeadersRule(): void + { + $rule = new Headers([ + Condition::startsWith('path', '/api'), + ], headers: ['X-Frame-Options' => 'DENY']); + + $this->assertTrue($rule->matches(['path' => '/api/users'])); + $this->assertSame('headers', $rule->getAction()); + $this->assertSame(['X-Frame-Options' => 'DENY'], $rule->getHeaders()); + $this->assertFalse($rule->isTerminal()); + $this->assertTrue((new Deny())->isTerminal()); + } + + public function testHeadersRuleRejectsEmptyHeaders(): void + { + $this->expectException(\InvalidArgumentException::class); + new Headers([], headers: []); + } + + public function testHeadersRuleRejectsInvalidName(): void + { + $this->expectException(\InvalidArgumentException::class); + new Headers([], headers: ['X Frame: Options' => 'DENY']); + } + + public function testHeadersRuleRejectsLineBreaksInValue(): void + { + $this->expectException(\InvalidArgumentException::class); + new Headers([], headers: ['X-Frame-Options' => "DENY\r\nSet-Cookie: session=1"]); + } }