diff --git a/CHANGELOG.md b/CHANGELOG.md index b679e8b..9cf25a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [4.1.1] - 2026-07-05 + +### Added + +- Support REGEX configuration for sensitive route +- Added orig_cookie_vid field across all enforcer activities + ## [4.1.0] - 2026-01-29 ### Added diff --git a/README.md b/README.md index fa855d7..cf75f02 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ # [PerimeterX](http://www.perimeterx.com) PHP SDK -> Latest stable version: [v4.1.0](https://packagist.org/packages/perimeterx/php-sdk#4.1.0) +> Latest stable version: [v4.1.1](https://packagist.org/packages/perimeterx/php-sdk#4.1.1) ## Table of Contents @@ -354,18 +354,28 @@ $perimeterxConfig = [ #### Sensitive Routes -List of routes prefix. The Perimeterx module will always match request uri by this prefix list and if match was found will create a server-to-server call for, even if the cookie score is low and valid. +List of route patterns. The PerimeterX module will always match the request URI against this list and if a match is found, will create a server-to-server call even if the cookie score is low and valid. + +Patterns can be: +- **Plain strings** — matched as a prefix (backward compatible). E.g. `'/login'` matches `/login`, `/login/page`, `/loginx`. +- **Regex-format strings** — strings wrapped in `/` delimiters with optional flags. E.g. `'/^\/api\/.*\/payment$/i'` matches `/api/v2/payment` case-insensitively. **Default: None** ```php $perimeterxConfig = [ .. - 'sensitive_routes' => ['/login', '/user/profile'] + 'sensitive_routes' => [ + '/login', // prefix match + '/^\/api\/.*\/payment$/i', // regex: any /api/*/payment path, case-insensitive + '/.*\/checkout$/', // regex: any path ending with /checkout + ] .. ] ``` +> **Important:** Do not use trailing slashes on plain-string prefixes (use `'/login'` not `'/login/'`), because a trailing slash causes the value to be interpreted as a regex pattern. If your route genuinely ends with `/`, use an explicit regex instead: `'/^\/login\//'`. + #### API Timeouts > Note: Controls the timeouts for PerimeterX requests. The API is called when a Risk Cookie does not exist, or is expired or invalid. diff --git a/composer.json b/composer.json index ea90f6b..4798c6d 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "perimeterx/php-sdk", "description": "PerimeterX SDK for PHP", - "version" : "4.1.0", + "version" : "4.1.1", "keywords": [ "perimeterx", "websecurity", diff --git a/examples/sample-site/.htaccess b/examples/sample-site/.htaccess new file mode 100644 index 0000000..4f368db --- /dev/null +++ b/examples/sample-site/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [L] diff --git a/examples/sample-site/Dockerfile b/examples/sample-site/Dockerfile new file mode 100644 index 0000000..e4af3f2 --- /dev/null +++ b/examples/sample-site/Dockerfile @@ -0,0 +1,27 @@ +FROM php:8.1-apache + +RUN apt-get update && apt-get install -y unzip git && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +RUN a2enmod rewrite + +WORKDIR /var/www/html + +COPY . /var/www/html/sdk + +WORKDIR /var/www/html/sdk +RUN composer install --no-dev --optimize-autoloader + +RUN echo '\n\ + DocumentRoot /var/www/html/sdk/examples/sample-site\n\ + \n\ + AllowOverride All\n\ + Require all granted\n\ + FallbackResource /index.php\n\ + \n\ + ErrorLog ${APACHE_LOG_DIR}/error.log\n\ + CustomLog ${APACHE_LOG_DIR}/access.log combined\n\ +' > /etc/apache2/sites-available/000-default.conf + +EXPOSE 80 diff --git a/examples/sample-site/README.md b/examples/sample-site/README.md new file mode 100644 index 0000000..10454fb --- /dev/null +++ b/examples/sample-site/README.md @@ -0,0 +1,53 @@ +# Sensitive Routes Sample Site + +A test site for verifying sensitive route matching (prefix, regex, suffix, wildcard) with the PX PHP enforcer. + +## Setup + +### Option 1: PHP built-in server + +```bash +cd examples/sample-site + +# Install dependencies (from the SDK root) +composer install --no-dev + +# Start the server +PX_APP_ID= \ +PX_COOKIE_KEY= \ +PX_AUTH_TOKEN= \ +php -S localhost:8080 router.php +``` + +### Option 2: Docker + +```bash +cd examples/sample-site + +# Edit docker-compose.yml and fill in your PX credentials, then: +docker compose up --build +``` + +Open http://localhost:8080 in your browser. + +## Configured sensitive routes + +| Pattern | Type | Matches | +|---|---|---| +| `/login` | Prefix | `/login`, `/login/reset`, `/loginx` | +| `/^\/api\/.*\/payment$/i` | Regex | `/api/v1/payment`, `/API/v2/Payment` | +| `/.*\/checkout$/` | Regex (suffix) | `/shop/checkout` | +| `/^\/admin$/` | Regex (exact) | `/admin` only, not `/admin/settings` | +| `/^\/account\/.*\/delete$/` | Regex | `/account/123/delete` | +| `/.*\.json$/` | Regex (extension) | `/data/config.json` | + +## Testing + +1. Open a **non-sensitive** page first (e.g. `/about`) so the PX sensor sets a valid cookie. +2. Navigate to a **sensitive** route — check your PX logs for `s2s_call_reason: sensitive_route`. +3. Compare with non-sensitive routes (`/contact`, `/api/v1/users`) which should show no Risk API call when the cookie is valid. + +Normalization test URLs: +- `http://localhost:8080/logi%6E` — URL decoding +- `http://localhost:8080/fake/../login` — traversal resolution +- `http://localhost:8080/login/?foo=bar` — trailing slash + query string stripping diff --git a/examples/sample-site/docker-compose.yml b/examples/sample-site/docker-compose.yml new file mode 100644 index 0000000..4a8f14d --- /dev/null +++ b/examples/sample-site/docker-compose.yml @@ -0,0 +1,11 @@ +services: + px-sample: + build: + context: ../../ + dockerfile: examples/sample-site/Dockerfile + ports: + - "8080:80" + environment: + - PX_APP_ID= + - PX_COOKIE_KEY= + - PX_AUTH_TOKEN= \ No newline at end of file diff --git a/examples/sample-site/index.php b/examples/sample-site/index.php new file mode 100644 index 0000000..c6a9c89 --- /dev/null +++ b/examples/sample-site/index.php @@ -0,0 +1,189 @@ + getenv('PX_APP_ID') ?: 'PX_APP_ID', + 'cookie_key' => getenv('PX_COOKIE_KEY') ?: 'PX_COOKIE_KEY', + 'auth_token' => getenv('PX_AUTH_TOKEN') ?: 'PX_AUTH_TOKEN', + 'blocking_score' => 80, + 'module_mode' => Perimeterx::$ACTIVE_MODE, + 'debug_mode' => true, + 'sensitive_routes' => [ + '/login', // plain prefix match + '/^\/api\/.*\/payment$/i', // regex: /api/*/payment, case-insensitive + '/.*\/checkout$/', // regex: suffix match for /checkout + '/^\/admin$/', // regex: exact match /admin only + '/^\/account\/.*\/delete$/', // regex: any /account/*/delete path + '/.*\.json$/', // regex: any path ending in .json + ], +]; + +$requestUri = $_SERVER['REQUEST_URI']; +$path = parse_url($requestUri, PHP_URL_PATH); + +$px = Perimeterx::Instance($perimeterxConfig); +$px->pxVerify(); + +$isSensitive = \Perimeterx\PerimeterxRouteUtils::isPathInPatterns( + $perimeterxConfig['sensitive_routes'], + $requestUri +); + +$routes = [ + '/' => ['title' => 'Home', 'body' => 'Welcome to the sample site!'], + '/login' => ['title' => 'Login', 'body' => 'This is the login page (sensitive route - prefix match).'], + '/login/reset' => ['title' => 'Password Reset', 'body' => 'This is the password reset page (sensitive - matches /login prefix).'], + '/admin' => ['title' => 'Admin', 'body' => 'This is the admin panel (sensitive route - exact regex match).'], + '/admin/settings' => ['title' => 'Admin Settings', 'body' => 'Admin settings page (NOT sensitive - /^\/admin$/ does not match subpaths).'], + '/api/v1/payment' => ['title' => 'Payment API', 'body' => 'Payment endpoint (sensitive route - regex match /api/*/payment).'], + '/api/v2/payment' => ['title' => 'Payment API v2', 'body' => 'Payment v2 endpoint (sensitive route - regex match).'], + '/api/v1/users' => ['title' => 'Users API', 'body' => 'Users endpoint (NOT sensitive).'], + '/shop/checkout' => ['title' => 'Checkout', 'body' => 'Checkout page (sensitive route - suffix regex match for /checkout).'], + '/account/123/delete' => ['title' => 'Delete Account', 'body' => 'Delete account endpoint (sensitive - regex /account/*/delete).'], + '/data/config.json' => ['title' => 'JSON Config', 'body' => 'JSON endpoint (sensitive - regex suffix match *.json).'], + '/about' => ['title' => 'About', 'body' => 'This is a regular page (not sensitive).'], + '/contact' => ['title' => 'Contact', 'body' => 'Contact page (not sensitive).'], +]; + +$page = isset($routes[$path]) ? $routes[$path] : null; +$title = $page ? $page['title'] : '404 Not Found'; +$body = $page ? $page['body'] : 'Page not found.'; + +if (!$page) { + http_response_code(404); +} +?> + + + + + + <?= htmlspecialchars($title) ?> - PX Sample Site + + + +
+

PX PHP Enforcer - Sensitive Routes Test Site

+ Testing regex/wildcard/suffix route matching +
+
+ + +
+

+ + + SENSITIVE ROUTE + + NORMAL ROUTE + +

+

+ +
+ Request details:
+ Path:
+ Normalized:
+ Sensitive: +
+
+ +
+

Configured Sensitive Route Patterns

+ + + + + + + + + + + + + +
PatternTypeMatches Current Path?
+
+ +
+

All Routes

+ + + + + + $routeInfo): + $routeSensitive = \Perimeterx\PerimeterxRouteUtils::isPathInPatterns( + $perimeterxConfig['sensitive_routes'], $routePath + ); + $matchedPattern = 'N/A'; + if ($routeSensitive) { + foreach ($perimeterxConfig['sensitive_routes'] as $p) { + if (\Perimeterx\PerimeterxRouteUtils::isPathInPatterns([$p], $routePath)) { + $matchedPattern = $p; + break; + } + } + } + ?> + + + + + + + +
PathSensitive?Why?
+
+
+ + + diff --git a/examples/sample-site/router.php b/examples/sample-site/router.php new file mode 100644 index 0000000..77fe3d5 --- /dev/null +++ b/examples/sample-site/router.php @@ -0,0 +1,4 @@ + 1, 'send_page_activities' => true, 'send_block_activities' => true, - 'sdk_name' => 'PHP SDK v4.1.0', + 'sdk_name' => 'PHP SDK v4.1.1', 'debug_mode' => false, 'perimeterx_server_host' => 'https://sapi-' . strtolower($pxConfig['app_id']) . '.perimeterx.net', 'captcha_script_host' => 'https://captcha.px-cdn.net', diff --git a/src/PerimeterxActivitiesClient.php b/src/PerimeterxActivitiesClient.php index 91893eb..ab2a4e6 100644 --- a/src/PerimeterxActivitiesClient.php +++ b/src/PerimeterxActivitiesClient.php @@ -172,6 +172,17 @@ public function generateActivity($activityType, $pxCtx, $details) { $details['client_uuid'] = $pxCtx->getUuid(); $details['request_id'] = $pxCtx->getRequestId(); + + $vidSource = $pxCtx->getVidSource(); + if (isset($vidSource)) { + $details['enforcer_vid_source'] = $vidSource; + } + + $origCookieVid = $pxCtx->getOrigCookieVid(); + if (isset($origCookieVid)) { + $details['orig_cookie_vid'] = $origCookieVid; + } + $this->addAdditionalFieldsToDetails($pxCtx, $details); if ($activityType !== 'additional_s2s') { diff --git a/src/PerimeterxContext.php b/src/PerimeterxContext.php index 981be38..9be1eb8 100644 --- a/src/PerimeterxContext.php +++ b/src/PerimeterxContext.php @@ -31,6 +31,16 @@ public function __construct($pxConfig, $additionalFields = null) } } + $pxvid = $this->getPxVidCookie(); + if (isset($pxvid)) { + if (self::isValidVid($pxvid)) { + $this->vid = $pxvid; + $this->vid_source = 'vid_cookie'; + } else { + $this->orig_cookie_vid = $pxvid; + } + } + $this->hostname = $_SERVER['HTTP_HOST']; // User Agent isn't always sent by bots so handle it gracefully. $this->userAgent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : ''; @@ -53,7 +63,7 @@ public function __construct($pxConfig, $additionalFields = null) } } $this->http_method = $_SERVER['REQUEST_METHOD']; - $this->sensitive_route = $this->checkSensitiveRoutePrefix($pxConfig['sensitive_routes'], $this->uri); + $this->sensitive_route = PerimeterxRouteUtils::isPathInPatterns($pxConfig['sensitive_routes'], $this->uri); if (is_array($additionalFields)) { $this->loginCredentials = array_key_exists('loginCredentials', $additionalFields) ? $additionalFields['loginCredentials'] : null; $this->graphqlFields = array_key_exists('graphqlFields', $additionalFields) ? $additionalFields['graphqlFields'] : null; @@ -293,7 +303,17 @@ private function extractIP($pxConfig, $headers) * @var array */ protected $jwt_additional_fields; - + + /** + * @var string|null - raw _pxvid value when it fails UUID validation + */ + protected $orig_cookie_vid; + + /** + * @var string|null - source of the VID: 'vid_cookie', 'risk_cookie', or null + */ + protected $vid_source; + /** * @return string */ @@ -583,16 +603,6 @@ public function getCookieNames() { return $this->request_cookie_names; } - private function checkSensitiveRoutePrefix($sensitive_routes, $uri) - { - foreach ($sensitive_routes as $route) { - if (strncmp($uri, $route, strlen($route)) === 0) { - return true; - } - } - return false; - } - private function selfURL() { $s = empty($_SERVER["HTTPS"]) ? '' : (($_SERVER["HTTPS"] == "on") ? "s" : ""); @@ -910,4 +920,40 @@ public function getJwtAdditionalFields() { public function setJwtAdditionalFields($jwt_additional_fields) { $this->jwt_additional_fields = $jwt_additional_fields; } + + /** + * @return string|null + */ + public function getOrigCookieVid() { + return $this->orig_cookie_vid; + } + + /** + * @param string $orig_cookie_vid + */ + public function setOrigCookieVid($orig_cookie_vid) { + $this->orig_cookie_vid = $orig_cookie_vid; + } + + /** + * @return string|null + */ + public function getVidSource() { + return $this->vid_source; + } + + /** + * @param string $vid_source + */ + public function setVidSource($vid_source) { + $this->vid_source = $vid_source; + } + + /** + * @param string $vid + * @return bool + */ + public static function isValidVid($vid) { + return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $vid) === 1; + } } diff --git a/src/PerimeterxCookieValidator.php b/src/PerimeterxCookieValidator.php index 7df568c..875b5b2 100644 --- a/src/PerimeterxCookieValidator.php +++ b/src/PerimeterxCookieValidator.php @@ -71,6 +71,7 @@ public function verify() $this->pxCtx->setScore($cookie->getScore()); $this->pxCtx->setUuid($cookie->getUuid()); $this->pxCtx->setVid($cookie->getVid()); + $this->pxCtx->setVidSource('risk_cookie'); $this->pxCtx->setBlockAction($cookie->getBlockAction()); $this->pxCtx->setResponseBlockAction($cookie->getBlockAction()); $this->pxCtx->setCookieHmac($cookie->getHmac()); diff --git a/src/PerimeterxLogger.php b/src/PerimeterxLogger.php index a282b4b..775fa33 100644 --- a/src/PerimeterxLogger.php +++ b/src/PerimeterxLogger.php @@ -28,7 +28,7 @@ public function __construct($pxConfig) { * * @return void */ - public function log($level, $message, array $context = []) + public function log($level, \Stringable|string $message, array $context = []): void { if (!$this->debug_mode) { return; diff --git a/src/PerimeterxOriginalTokenValidator.php b/src/PerimeterxOriginalTokenValidator.php index bcbb784..e732e0b 100644 --- a/src/PerimeterxOriginalTokenValidator.php +++ b/src/PerimeterxOriginalTokenValidator.php @@ -49,6 +49,7 @@ public function verify() $this->pxCtx->setDecodedOriginalToken($payload->getDecodedPayload()); $this->pxCtx->setOriginalTokenUuid($payload->getUuid()); $this->pxCtx->setVid($payload->getVid()); + $this->pxCtx->setVidSource('risk_cookie'); if (!$payload->isSecure()) { $payloadString = json_encode($payload->getDecodedPayload()); diff --git a/src/PerimeterxPayload.php b/src/PerimeterxPayload.php index e4ec94d..b8c6125 100644 --- a/src/PerimeterxPayload.php +++ b/src/PerimeterxPayload.php @@ -24,6 +24,16 @@ abstract class PerimeterxPayload { */ protected $payloadSecret; + /** + * @var string + */ + protected $cookieHash; + + /** + * @var string + */ + protected $cookieSecret; + /** * Factory method for creating PX payload object according to the version found on the request */ diff --git a/src/PerimeterxRouteUtils.php b/src/PerimeterxRouteUtils.php new file mode 100644 index 0000000..c14ff3c --- /dev/null +++ b/src/PerimeterxRouteUtils.php @@ -0,0 +1,104 @@ + 1) { + array_pop($resolved); + } + } elseif ($segment !== '.') { + $resolved[] = $segment; + } + } + $path = implode('/', $resolved); + + $path = rtrim($path, '/'); + + if ($path === '') { + $path = '/'; + } + + return $path; + } + + /** + * Attempts to parse a string as a regex-format pattern (e.g. "/^\/path$/i"). + * Returns a preg_match-compatible pattern string, or null if not regex format. + */ + public static function convertStringToRegex($pattern) + { + if (empty($pattern)) { + return null; + } + + if (!preg_match(self::REGEX_STRUCTURE, $pattern, $matches)) { + return null; + } + + $regexBody = $matches[1]; + $flags = $matches[2]; + + $phpFlags = ''; + for ($i = 0; $i < strlen($flags); $i++) { + if (in_array($flags[$i], self::PHP_VALID_FLAGS)) { + $phpFlags .= $flags[$i]; + } + } + + $regex = '/' . $regexBody . '/' . $phpFlags; + + if (@preg_match($regex, '') === false) { + return null; + } + + return $regex; + } + + /** + * Checks whether a path matches any of the given patterns. + * Patterns can be plain strings (prefix match) or regex-format strings. + */ + public static function isPathInPatterns($patterns, $path) + { + $normalizedPath = self::normalizePath($path); + + foreach ($patterns as $pattern) { + $regex = self::convertStringToRegex($pattern); + if ($regex !== null) { + if (preg_match($regex, $normalizedPath)) { + return true; + } + } else { + if (strpos($normalizedPath, $pattern) === 0) { + return true; + } + } + } + + return false; + } +} diff --git a/src/PerimeterxS2SValidator.php b/src/PerimeterxS2SValidator.php index ee9594b..647dca6 100644 --- a/src/PerimeterxS2SValidator.php +++ b/src/PerimeterxS2SValidator.php @@ -71,18 +71,18 @@ private function prepareRiskRequestBody() { ] ]; - $pxvid = $this->pxCtx->getPxVidCookie(); $vid = $this->pxCtx->getVid(); - $vid_source = "none"; + $vid_source = $this->pxCtx->getVidSource(); if (isset($vid)) { - $vid_source = "risk_cookie"; $requestBody['vid'] = $vid; - } else if (isset($pxvid) && preg_match('/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/', $pxvid)) { - $vid_source = "vid_cookie"; - $requestBody['vid'] = $pxvid; } - $requestBody["additional"]["enforcer_vid_source"] = $vid_source; + $requestBody["additional"]["enforcer_vid_source"] = isset($vid_source) ? $vid_source : "none"; + + $origCookieVid = $this->pxCtx->getOrigCookieVid(); + if (isset($origCookieVid)) { + $requestBody["additional"]["orig_cookie_vid"] = $origCookieVid; + } $uuid = $this->pxCtx->getUuid(); if (isset($uuid)) { diff --git a/tests/PerimeterxOrigCookieVidTest.php b/tests/PerimeterxOrigCookieVidTest.php new file mode 100644 index 0000000..9afc450 --- /dev/null +++ b/tests/PerimeterxOrigCookieVidTest.php @@ -0,0 +1,182 @@ +assertTrue(PerimeterxContext::isValidVid(self::VALID_VID)); + } + + public function testIsValidVidWithInvalidUuid() + { + $this->assertFalse(PerimeterxContext::isValidVid(self::INVALID_VID)); + } + + public function testIsValidVidWithEmptyString() + { + $this->assertFalse(PerimeterxContext::isValidVid('')); + } + + public function testIsValidVidWithUppercaseUuid() + { + $this->assertFalse(PerimeterxContext::isValidVid('69521DCE-AB65-11E6-80F5-76304DEC7EB7')); + } + + public function testIsValidVidRejectsPartialMatch() + { + $this->assertFalse(PerimeterxContext::isValidVid('prefix-69521dce-ab65-11e6-80f5-76304dec7eb7')); + $this->assertFalse(PerimeterxContext::isValidVid('69521dce-ab65-11e6-80f5-76304dec7eb7-suffix')); + } + + public function testInvalidPxvidSetsOrigCookieVid() + { + $pxCtx = $this->createContextWithPxvid(self::INVALID_VID); + + $this->assertEquals(self::INVALID_VID, $pxCtx->getOrigCookieVid()); + $this->assertNull($pxCtx->getVid()); + $this->assertNull($pxCtx->getVidSource()); + } + + public function testValidPxvidSetsVidAndSource() + { + $pxCtx = $this->createContextWithPxvid(self::VALID_VID); + + $this->assertEquals(self::VALID_VID, $pxCtx->getVid()); + $this->assertEquals('vid_cookie', $pxCtx->getVidSource()); + $this->assertNull($pxCtx->getOrigCookieVid()); + } + + public function testMissingPxvidSetsNothing() + { + $pxCtx = $this->createContextWithPxvid(null); + + $this->assertNull($pxCtx->getVid()); + $this->assertNull($pxCtx->getVidSource()); + $this->assertNull($pxCtx->getOrigCookieVid()); + } + + public function testActivityIncludesOrigCookieVidWhenInvalid() + { + $pxCtx = $this->createContextWithPxvid(self::INVALID_VID); + $activitiesClient = $this->createActivitiesClient(); + + $activity = $activitiesClient->generateActivity('page_requested', $pxCtx, ['pass_reason' => 'cookie']); + + $this->assertEquals(self::INVALID_VID, $activity['details']['orig_cookie_vid']); + $this->assertArrayNotHasKey('vid', $activity); + } + + public function testActivityDoesNotIncludeOrigCookieVidWhenValid() + { + $pxCtx = $this->createContextWithPxvid(self::VALID_VID); + $activitiesClient = $this->createActivitiesClient(); + + $activity = $activitiesClient->generateActivity('page_requested', $pxCtx, ['pass_reason' => 'cookie']); + + $this->assertArrayNotHasKey('orig_cookie_vid', $activity['details']); + $this->assertEquals(self::VALID_VID, $activity['vid']); + $this->assertEquals('vid_cookie', $activity['details']['enforcer_vid_source']); + } + + public function testActivityIncludesEnforcerVidSourceWhenSet() + { + $pxCtx = $this->createContextWithPxvid(self::VALID_VID); + $pxCtx->setVidSource('risk_cookie'); + $activitiesClient = $this->createActivitiesClient(); + + $activity = $activitiesClient->generateActivity('page_requested', $pxCtx, ['pass_reason' => 's2s']); + + $this->assertEquals('risk_cookie', $activity['details']['enforcer_vid_source']); + } + + public function testBlockActivityIncludesOrigCookieVid() + { + $pxCtx = $this->createContextWithPxvid(self::INVALID_VID); + $activitiesClient = $this->createActivitiesClient(); + + $activity = $activitiesClient->generateActivity('block', $pxCtx, [ + 'block_score' => 100, + 'block_reason' => 's2s_high_score', + 'block_action' => 'c', + 'simulated_block' => false + ]); + + $this->assertEquals(self::INVALID_VID, $activity['details']['orig_cookie_vid']); + } + + /** + * @return PerimeterxContext + */ + private function createContextWithPxvid($pxvidValue) + { + $pxCtx = $this->getMockBuilder(PerimeterxContext::class) + ->disableOriginalConstructor() + ->setMethods([ + 'getPxVidCookie', + 'getHeaders', + 'getIp', + 'getFullUrl', + 'getHttpMethod', + 'getHttpVersion', + 'getCookieOrigin', + 'getRiskRtt', + 'getPxhdCookie', + 'getUri' + ]) + ->getMock(); + + $pxCtx->method('getPxVidCookie')->willReturn($pxvidValue); + $pxCtx->method('getHeaders')->willReturn([]); + $pxCtx->method('getIp')->willReturn('1.1.1.1'); + $pxCtx->method('getFullUrl')->willReturn('http://localhost/'); + $pxCtx->method('getHttpMethod')->willReturn('GET'); + $pxCtx->method('getHttpVersion')->willReturn('1.1'); + $pxCtx->method('getCookieOrigin')->willReturn('cookie'); + $pxCtx->method('getRiskRtt')->willReturn(0); + $pxCtx->method('getPxhdCookie')->willReturn(null); + $pxCtx->method('getUri')->willReturn('/'); + + if (isset($pxvidValue) && PerimeterxContext::isValidVid($pxvidValue)) { + $pxCtx->setVid($pxvidValue); + $pxCtx->setVidSource('vid_cookie'); + } elseif (isset($pxvidValue)) { + $pxCtx->setOrigCookieVid($pxvidValue); + } + + return $pxCtx; + } + + /** + * @return PerimeterxActivitiesClient + */ + private function createActivitiesClient() + { + $httpClient = $this->createMock(PerimeterxHttpClient::class); + $logger = $this->createMock(AbstractLogger::class); + + $pxConfig = [ + 'app_id' => self::APP_ID, + 'auth_token' => self::AUTH_TOKEN, + 'sdk_name' => self::SDK_NAME, + 'http_client' => $httpClient, + 'logger' => $logger, + 'send_page_activities' => true, + 'sensitive_headers' => [], + 'defer_activities' => false, + 'module_mode' => 1 + ]; + + return new PerimeterxActivitiesClient($pxConfig); + } +} diff --git a/tests/PerimeterxRouteUtilsTest.php b/tests/PerimeterxRouteUtilsTest.php new file mode 100644 index 0000000..e1c34b3 --- /dev/null +++ b/tests/PerimeterxRouteUtilsTest.php @@ -0,0 +1,250 @@ +assertEquals('/login', PerimeterxRouteUtils::normalizePath('/login')); + } + + public function testNormalizePath_stripsQueryString() + { + $this->assertEquals('/login', PerimeterxRouteUtils::normalizePath('/login?foo=bar')); + } + + public function testNormalizePath_urlDecoding() + { + $this->assertEquals('/login', PerimeterxRouteUtils::normalizePath('/logi%6E')); + } + + public function testNormalizePath_traversalResolution() + { + $this->assertEquals('/login', PerimeterxRouteUtils::normalizePath('/fake/../login')); + } + + public function testNormalizePath_dotSegments() + { + $this->assertEquals('/login/profile', PerimeterxRouteUtils::normalizePath('/login/./profile')); + } + + public function testNormalizePath_trailingSlashStripped() + { + $this->assertEquals('/login', PerimeterxRouteUtils::normalizePath('/login/')); + } + + public function testNormalizePath_multipleTrailingSlashesStripped() + { + $this->assertEquals('/login', PerimeterxRouteUtils::normalizePath('/login///')); + } + + public function testNormalizePath_repeatedSlashesCollapsed() + { + $this->assertEquals('/login/page', PerimeterxRouteUtils::normalizePath('/login//page')); + } + + public function testNormalizePath_rootPath() + { + $this->assertEquals('/', PerimeterxRouteUtils::normalizePath('/')); + } + + public function testNormalizePath_complexCombination() + { + $this->assertEquals('/login', PerimeterxRouteUtils::normalizePath('/fake/../logi%6E?session=123')); + } + + public function testNormalizePath_traversalAboveRoot() + { + $this->assertEquals('/login', PerimeterxRouteUtils::normalizePath('/../login')); + } + + public function testNormalizePath_multipleTraversalsAboveRoot() + { + $this->assertEquals('/login', PerimeterxRouteUtils::normalizePath('/../../../login')); + } + + public function testNormalizePath_traversalAboveRootOnly() + { + $this->assertEquals('/', PerimeterxRouteUtils::normalizePath('/../../..')); + } + + // --- convertStringToRegex tests --- + + public function testConvertStringToRegex_validRegexString() + { + $result = PerimeterxRouteUtils::convertStringToRegex('/^\/path$/i'); + $this->assertNotNull($result); + $this->assertEquals('/^\/path$/i', $result); + } + + public function testConvertStringToRegex_validRegexNoFlags() + { + $result = PerimeterxRouteUtils::convertStringToRegex('/^\/login$/'); + $this->assertNotNull($result); + $this->assertEquals('/^\/login$/', $result); + } + + public function testConvertStringToRegex_nonRegexString() + { + $this->assertNull(PerimeterxRouteUtils::convertStringToRegex('/login')); + } + + public function testConvertStringToRegex_nonRegexMultiSegmentPath() + { + $this->assertNull(PerimeterxRouteUtils::convertStringToRegex('/login/page')); + } + + public function testConvertStringToRegex_invalidFlags() + { + $result = PerimeterxRouteUtils::convertStringToRegex('/path/z'); + $this->assertNull($result); + } + + public function testConvertStringToRegex_trailingSlashAmbiguity() + { + $result = PerimeterxRouteUtils::convertStringToRegex('/login/'); + $this->assertNotNull($result, 'Trailing-slash path is detected as regex per spec'); + $this->assertEquals('/login/', $result); + } + + public function testConvertStringToRegex_singleFlagCharSuffix() + { + $result = PerimeterxRouteUtils::convertStringToRegex('/something/i'); + $this->assertNotNull($result, 'Path ending with valid flag char is detected as regex per spec'); + $this->assertEquals('/something/i', $result); + } + + public function testConvertStringToRegex_emptyString() + { + $this->assertNull(PerimeterxRouteUtils::convertStringToRegex('')); + } + + public function testConvertStringToRegex_jsOnlyFlagsStripped() + { + $result = PerimeterxRouteUtils::convertStringToRegex('/^\/path$/gi'); + $this->assertNotNull($result); + $this->assertEquals('/^\/path$/i', $result); + } + + public function testConvertStringToRegex_invalidRegexBody() + { + $result = PerimeterxRouteUtils::convertStringToRegex('/[invalid/'); + $this->assertNull($result, 'Invalid regex body should return null'); + } + + // --- isPathInPatterns tests: backward compatibility (prefix match) --- + + public function testIsPathInPatterns_prefixExactMatch() + { + $this->assertTrue(PerimeterxRouteUtils::isPathInPatterns(['/login'], '/login')); + } + + public function testIsPathInPatterns_prefixMatchSubpath() + { + $this->assertTrue(PerimeterxRouteUtils::isPathInPatterns(['/login'], '/login/page')); + } + + public function testIsPathInPatterns_prefixMatchExtension() + { + $this->assertTrue(PerimeterxRouteUtils::isPathInPatterns(['/login'], '/loginx')); + } + + public function testIsPathInPatterns_prefixNoMatchDifferentCase() + { + $this->assertFalse(PerimeterxRouteUtils::isPathInPatterns(['/login'], '/LOGIN')); + } + + public function testIsPathInPatterns_prefixNoMatchMiddle() + { + $this->assertFalse(PerimeterxRouteUtils::isPathInPatterns(['/login'], '/my/login')); + } + + // --- isPathInPatterns tests: regex match --- + + public function testIsPathInPatterns_regexExactMatch() + { + $this->assertTrue(PerimeterxRouteUtils::isPathInPatterns(['/^\/login$/'], '/login')); + } + + public function testIsPathInPatterns_regexExactNoMatchSubpath() + { + $this->assertFalse(PerimeterxRouteUtils::isPathInPatterns(['/^\/login$/'], '/login/page')); + } + + public function testIsPathInPatterns_regexSuffixMatch() + { + $this->assertTrue(PerimeterxRouteUtils::isPathInPatterns(['/\/login$/'], '/my/login')); + } + + public function testIsPathInPatterns_regexCaseInsensitive() + { + $patterns = ['/^\/api\/.*\/payment$/i']; + $this->assertTrue(PerimeterxRouteUtils::isPathInPatterns($patterns, '/api/v2/payment')); + $this->assertTrue(PerimeterxRouteUtils::isPathInPatterns($patterns, '/API/v1/Payment')); + } + + public function testIsPathInPatterns_regexExtensionMatch() + { + $this->assertTrue(PerimeterxRouteUtils::isPathInPatterns(['/.*\.json$/'], '/data/config.json')); + } + + public function testIsPathInPatterns_regexNoMatch() + { + $this->assertFalse(PerimeterxRouteUtils::isPathInPatterns(['/^\/login$/'], '/other')); + } + + // --- isPathInPatterns tests: mixed patterns --- + + public function testIsPathInPatterns_mixedPatternsStringMatch() + { + $patterns = ['/admin', '/^\/api\/.*$/']; + $this->assertTrue(PerimeterxRouteUtils::isPathInPatterns($patterns, '/admin/panel')); + } + + public function testIsPathInPatterns_mixedPatternsRegexMatch() + { + $patterns = ['/admin', '/^\/api\/.*$/']; + $this->assertTrue(PerimeterxRouteUtils::isPathInPatterns($patterns, '/api/users')); + } + + public function testIsPathInPatterns_mixedPatternsNoMatch() + { + $patterns = ['/admin', '/^\/api\/.*$/']; + $this->assertFalse(PerimeterxRouteUtils::isPathInPatterns($patterns, '/other')); + } + + // --- isPathInPatterns tests: normalization interaction --- + + public function testIsPathInPatterns_normalizationUrlEncoded() + { + $this->assertTrue(PerimeterxRouteUtils::isPathInPatterns( + ['/^\/login$/'], + '/logi%6E?session=123' + )); + } + + public function testIsPathInPatterns_normalizationTraversal() + { + $this->assertTrue(PerimeterxRouteUtils::isPathInPatterns( + ['/^\/login$/'], + '/fake/../login' + )); + } + + public function testIsPathInPatterns_normalizationTrailingSlash() + { + $this->assertTrue(PerimeterxRouteUtils::isPathInPatterns( + ['/login'], + '/login/' + )); + } + + public function testIsPathInPatterns_emptyPatterns() + { + $this->assertFalse(PerimeterxRouteUtils::isPathInPatterns([], '/login')); + } +}