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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -354,18 +354,28 @@ $perimeterxConfig = [

#### <a name="sensitive-routes"></a> 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\//'`.

#### <a name="api-timeout"></a>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.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "perimeterx/php-sdk",
"description": "PerimeterX SDK for PHP",
"version" : "4.1.0",
"version" : "4.1.1",
"keywords": [
"perimeterx",
"websecurity",
Expand Down
4 changes: 4 additions & 0 deletions examples/sample-site/.htaccess
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
27 changes: 27 additions & 0 deletions examples/sample-site/Dockerfile
Original file line number Diff line number Diff line change
@@ -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 '<VirtualHost *:80>\n\
DocumentRoot /var/www/html/sdk/examples/sample-site\n\
<Directory /var/www/html/sdk/examples/sample-site>\n\
AllowOverride All\n\
Require all granted\n\
FallbackResource /index.php\n\
</Directory>\n\
ErrorLog ${APACHE_LOG_DIR}/error.log\n\
CustomLog ${APACHE_LOG_DIR}/access.log combined\n\
</VirtualHost>' > /etc/apache2/sites-available/000-default.conf

EXPOSE 80
53 changes: 53 additions & 0 deletions examples/sample-site/README.md
Original file line number Diff line number Diff line change
@@ -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=<YOUR_APP_ID> \
PX_COOKIE_KEY=<YOUR_COOKIE_KEY> \
PX_AUTH_TOKEN=<YOUR_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
11 changes: 11 additions & 0 deletions examples/sample-site/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
services:
px-sample:
build:
context: ../../
dockerfile: examples/sample-site/Dockerfile
ports:
- "8080:80"
environment:
- PX_APP_ID=<YOUR_APP_ID>
- PX_COOKIE_KEY=<YOUR_COOKIE_KEY>
- PX_AUTH_TOKEN=<YOUR_AUTH_TOKEN>
189 changes: 189 additions & 0 deletions examples/sample-site/index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<?php
require_once __DIR__ . '/../../vendor/autoload.php';

use Perimeterx\Perimeterx;

$perimeterxConfig = [
'app_id' => 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);
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($title) ?> - PX Sample Site</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
.header { background: #1a1a2e; color: white; padding: 20px 40px; }
.header h1 { font-size: 1.4rem; font-weight: 600; }
.header small { color: #888; }
.container { max-width: 900px; margin: 30px auto; padding: 0 20px; }
.card { background: white; border-radius: 8px; padding: 24px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.card h2 { margin-bottom: 8px; }
.badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; margin-left: 8px; }
.badge-sensitive { background: #ffe0e0; color: #c0392b; }
.badge-normal { background: #e0f0e0; color: #27ae60; }
.nav { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
.nav a { padding: 8px 16px; background: #e8e8e8; border-radius: 6px; text-decoration: none; color: #333; font-size: 0.9rem; transition: background 0.2s; }
.nav a:hover { background: #d0d0d0; }
.nav a.active { background: #1a1a2e; color: white; }
.nav a.sensitive { border: 2px solid #c0392b; }
.info { background: #f0f4ff; border-left: 4px solid #3498db; padding: 16px; border-radius: 0 8px 8px 0; margin-top: 16px; }
.info code { background: #e0e8f0; padding: 2px 6px; border-radius: 3px; font-size: 0.85rem; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #eee; font-size: 0.85rem; }
th { color: #666; font-weight: 600; }
td code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
</style>
</head>
<body>
<div class="header">
<h1>PX PHP Enforcer - Sensitive Routes Test Site</h1>
<small>Testing regex/wildcard/suffix route matching</small>
</div>
<div class="container">
<div class="nav">
<?php foreach ($routes as $routePath => $routeInfo):
$routeSensitive = \Perimeterx\PerimeterxRouteUtils::isPathInPatterns(
$perimeterxConfig['sensitive_routes'], $routePath
);
$activeClass = ($path === $routePath) ? ' active' : '';
$sensitiveClass = $routeSensitive ? ' sensitive' : '';
?>
<a href="<?= $routePath ?>" class="<?= trim($activeClass . $sensitiveClass) ?>"><?= htmlspecialchars($routeInfo['title']) ?></a>
<?php endforeach; ?>
</div>

<div class="card">
<h2>
<?= htmlspecialchars($title) ?>
<?php if ($isSensitive): ?>
<span class="badge badge-sensitive">SENSITIVE ROUTE</span>
<?php else: ?>
<span class="badge badge-normal">NORMAL ROUTE</span>
<?php endif; ?>
</h2>
<p><?= htmlspecialchars($body) ?></p>

<div class="info">
<strong>Request details:</strong><br>
Path: <code><?= htmlspecialchars($path) ?></code><br>
Normalized: <code><?= htmlspecialchars(\Perimeterx\PerimeterxRouteUtils::normalizePath($requestUri)) ?></code><br>
Sensitive: <code><?= $isSensitive ? 'true' : 'false' ?></code>
</div>
</div>

<div class="card">
<h2>Configured Sensitive Route Patterns</h2>
<table>
<thead>
<tr><th>Pattern</th><th>Type</th><th>Matches Current Path?</th></tr>
</thead>
<tbody>
<?php foreach ($perimeterxConfig['sensitive_routes'] as $pattern):
$isRegex = \Perimeterx\PerimeterxRouteUtils::convertStringToRegex($pattern) !== null;
$matches = \Perimeterx\PerimeterxRouteUtils::isPathInPatterns([$pattern], $requestUri);
?>
<tr>
<td><code><?= htmlspecialchars($pattern) ?></code></td>
<td><?= $isRegex ? 'Regex' : 'Prefix' ?></td>
<td><?= $matches ? '&#10004;' : '&#10008;' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>

<div class="card">
<h2>All Routes</h2>
<table>
<thead>
<tr><th>Path</th><th>Sensitive?</th><th>Why?</th></tr>
</thead>
<tbody>
<?php foreach ($routes as $routePath => $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;
}
}
}
?>
<tr>
<td><code><?= htmlspecialchars($routePath) ?></code></td>
<td><?= $routeSensitive ? '&#10004; Yes' : '&#10008; No' ?></td>
<td><code><?= htmlspecialchars($matchedPattern) ?></code></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<script type="text/javascript">
(function(){
window._pxAppId = '<?= htmlspecialchars(getenv('PX_APP_ID') ?: 'PX_APP_ID') ?>';
var p = document.getElementsByTagName('script')[0],
s = document.createElement('script');
s.async = 1;
s.src = '//client.px-cloud.net/<?= htmlspecialchars(getenv('PX_APP_ID') ?: 'PX_APP_ID') ?>/main.min.js';
p.parentNode.insertBefore(s,p);
}());
</script>
</body>
</html>
4 changes: 4 additions & 0 deletions examples/sample-site/router.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
// Router for PHP's built-in server — routes all requests to index.php
$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/';
require __DIR__ . '/index.php';
2 changes: 1 addition & 1 deletion px_metadata.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "4.1.0",
"version": "4.1.1",
"supported_features": [
"additional_activity_handler",
"advanced_blocking_response",
Expand Down
2 changes: 1 addition & 1 deletion src/Perimeterx.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ private function __construct(array $pxConfig = [])
'max_buffer_len' => 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',
Expand Down
11 changes: 11 additions & 0 deletions src/PerimeterxActivitiesClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Loading