Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
38 changes: 34 additions & 4 deletions src/Firewall.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ class Firewall

private ?Rule $lastMatchedRule = null;

/**
* @var array<Rule>
*/
private array $matchedNonTerminalRules = [];

public function __construct()
{
$this->attributeTypes = [
Expand Down Expand Up @@ -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<Rule>
*/
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);
Expand Down
11 changes: 11 additions & 0 deletions src/Rule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Condition>
Expand Down Expand Up @@ -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;
Expand Down
63 changes: 63 additions & 0 deletions src/Rules/Headers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace Utopia\WAF\Rules;

use Utopia\WAF\Rule;

/**
* Carries response headers to add when the rule matches.
*
* Unlike the other actions this rule is non-terminal: it does not decide the
* request, so the firewall records it and keeps evaluating the rules after it.
*/
class Headers extends Rule
{
/**
* @var array<string, string>
*/
private array $headers;

/**
* @param array<\Utopia\WAF\Condition|array<string, mixed>> $conditions
* @param array<string, string> $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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Trailing newline passes validation A name such as "X-Test " passes this check because $ can match just before a final newline. The rule then exposes a name that is not a valid HTTP token, so a consumer writing the collected headers may reject it or handle it unsafely. Anchor the match to the absolute end of the string.

Suggested change
if (!\is_string($name) || preg_match('/^[A-Za-z0-9!#$%&\'*+.^_`|~-]+$/', $name) !== 1) {
if (!\is_string($name) || preg_match('/^[A-Za-z0-9!#$%&\'*+.^_`|~-]+\z/', $name) !== 1) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Rules/Headers.php
Line: 33

Comment:
**Trailing newline passes validation** A name such as `"X-Test
"` passes this check because `$` can match just before a final newline. The rule then exposes a name that is not a valid HTTP token, so a consumer writing the collected headers may reject it or handle it unsafely. Anchor the match to the absolute end of the string.

```suggestion
            if (!\is_string($name) || preg_match('/^[A-Za-z0-9!#$%&\'*+.^_`|~-]+\z/', $name) !== 1) {
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Numeric header names rejected A numeric-only name such as "123" is a valid HTTP token, but PHP converts it to an integer array key before this check. is_string($name) therefore rejects it, preventing callers from registering that valid header name.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Rules/Headers.php
Line: 33

Comment:
**Numeric header names rejected** A numeric-only name such as `"123"` is a valid HTTP token, but PHP converts it to an integer array key before this check. `is_string($name)` therefore rejects it, preventing callers from registering that valid header name.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

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<string, string>
*/
public function getHeaders(): array
{
return $this->headers;
}
}
68 changes: 68 additions & 0 deletions tests/FirewallTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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());
}
}
32 changes: 32 additions & 0 deletions tests/RulesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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());
Comment on lines +81 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests assert implementation flags These assertions check isTerminal() directly, while the firewall tests already show the observable behavior: evaluation continues after a Headers match. The repository requires tests of observable behavior rather than assertions that mirror implementation choices. Remove these flag checks before merging.

Suggested change
$this->assertFalse($rule->isTerminal());
$this->assertTrue((new Deny())->isTerminal());

Context Used: Call out and harshly judge implementation-coupled tests. We don't mirror source code, configuration, or version pins in assertions. We test observable behavior; use linters for syntax and schema checks. (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/RulesTest.php
Line: 81-82

Comment:
**Tests assert implementation flags** These assertions check `isTerminal()` directly, while the firewall tests already show the observable behavior: evaluation continues after a Headers match. The repository requires tests of observable behavior rather than assertions that mirror implementation choices. Remove these flag checks before merging.

```suggestion

```

**Context Used:** Call out and harshly judge implementation-coupled tests. We don't mirror source code, configuration, or version pins in assertions. We test observable behavior; use linters for syntax and schema checks. ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

}

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"]);
}
}
Loading