Centralizes url canonicalization and normalization

main
Yasen Pramatarov 2026-04-11 19:14:19 +03:00
parent b88b2ce8a5
commit 8dc0b526ab
8 changed files with 461 additions and 2 deletions

View File

@ -0,0 +1,252 @@
<?php
/**
* Shared URL canonicalization utilities.
*
* Provides query normalization/compare helpers plus a policy-based builder so
* controllers can define canonical query contracts declaratively.
*/
/**
* Normalize query payload recursively for stable comparisons.
*
* - Sort associative keys.
* - Re-index list arrays.
* - Drop null values.
* - Convert scalar values to strings so GET payloads and canonical payloads
* compare by value rather than PHP type.
*
* @param array<string,mixed> $query
* @return array<string,mixed>
*/
function app_url_normalize_query(array $query): array
{
$normalized = [];
foreach ($query as $key => $value) {
if ($value === null) {
continue;
}
if (is_array($value)) {
$child = app_url_normalize_query($value);
$normalized[$key] = array_is_list($value)
? array_values($child)
: $child;
continue;
}
if (is_bool($value)) {
$normalized[$key] = $value ? '1' : '0';
continue;
}
$normalized[$key] = (string)$value;
}
if (!array_is_list($normalized)) {
ksort($normalized);
}
return $normalized;
}
/**
* Compare two query arrays after canonical normalization.
*
* @param array<string,mixed> $left
* @param array<string,mixed> $right
*/
function app_url_queries_match(array $left, array $right): bool
{
return app_url_normalize_query($left) === app_url_normalize_query($right);
}
/**
* Build an internal app URL from a canonical query payload.
*
* @param array<string,mixed> $query
*/
function app_url_build_internal(string $appRoot, array $query): string
{
return rtrim($appRoot !== '' ? $appRoot : '/', '/?&') . '/?' . http_build_query($query);
}
/**
* Redirect to canonical query URL when current and canonical payloads differ.
*
* @param array<string,mixed> $currentQuery
* @param array<string,mixed> $canonicalQuery
*/
function app_url_redirect_to_canonical_query(string $appRoot, array $currentQuery, array $canonicalQuery): void
{
if (!app_url_queries_match($currentQuery, $canonicalQuery)) {
header('Location: ' . app_url_build_internal($appRoot, $canonicalQuery));
exit;
}
}
/**
* Resolve one canonical value from a policy rule.
*
* Supported rule types:
* - literal: fixed `value`
* - string: trimmed scalar string
* - int: validated integer with optional `min` / `max`
* - enum: trimmed scalar string constrained by `allowed`
* - bool_flag: includes `value_true` only when request value is truthy
* - string_list: trims and filters array items
*
* @param string $targetKey
* @param array<string,mixed> $rule
* @param array<string,mixed> $sourceQuery
* @return mixed|null
*/
function app_url_policy_value(string $targetKey, array $rule, array $sourceQuery)
{
$type = (string)($rule['type'] ?? 'string');
$sourceKey = (string)($rule['source'] ?? $targetKey);
if ($type === 'literal') {
return $rule['value'] ?? null;
}
$hasSourceValue = array_key_exists($sourceKey, $sourceQuery);
$rawValue = $hasSourceValue ? $sourceQuery[$sourceKey] : null;
if (!$hasSourceValue) {
return $rule['default'] ?? null;
}
if ($type === 'string') {
if (is_array($rawValue)) {
return $rule['default'] ?? null;
}
$value = (string)$rawValue;
if (($rule['trim'] ?? true) === true) {
$value = trim($value);
}
if ($value === '' && !($rule['allow_empty'] ?? false)) {
return $rule['default'] ?? null;
}
return $value;
}
if ($type === 'int') {
if (is_array($rawValue)) {
return $rule['default'] ?? null;
}
$candidate = trim((string)$rawValue);
if ($candidate === '' || filter_var($candidate, FILTER_VALIDATE_INT) === false) {
return $rule['default'] ?? null;
}
$value = (int)$candidate;
if (isset($rule['min']) && $value < (int)$rule['min']) {
return $rule['default'] ?? null;
}
if (isset($rule['max']) && $value > (int)$rule['max']) {
return $rule['default'] ?? null;
}
return $value;
}
if ($type === 'enum') {
if (is_array($rawValue)) {
return $rule['default'] ?? null;
}
$value = trim((string)$rawValue);
if ($value === '') {
return $rule['default'] ?? null;
}
$allowed = is_array($rule['allowed'] ?? null) ? $rule['allowed'] : [];
if (!in_array($value, $allowed, true)) {
return $rule['default'] ?? null;
}
return $value;
}
if ($type === 'bool_flag') {
if (is_array($rawValue)) {
return $rule['default'] ?? null;
}
$truthyValues = is_array($rule['truthy_values'] ?? null)
? $rule['truthy_values']
: ['1', 'true', 'yes', 'on'];
$candidate = strtolower(trim((string)$rawValue));
if (in_array($candidate, $truthyValues, true)) {
return $rule['value_true'] ?? '1';
}
return $rule['default'] ?? null;
}
if ($type === 'string_list') {
if (!is_array($rawValue)) {
return $rule['default'] ?? null;
}
// Normalize list payloads into deterministic arrays for canonical URLs.
$items = array_map(static function ($item): string {
return trim((string)$item);
}, $rawValue);
$items = array_values(array_filter($items, static function ($item): bool {
return $item !== '';
}));
if (!empty($rule['unique'])) {
$items = array_values(array_unique($items));
}
if (empty($items) && !($rule['allow_empty'] ?? false)) {
return $rule['default'] ?? null;
}
return $items;
}
return $rule['default'] ?? null;
}
/**
* Build canonical query payload from declarative policy rules.
*
* @param array<string,mixed> $sourceQuery
* @param array<string,array<string,mixed>> $policy
* @return array<string,mixed>
*/
function app_url_build_query_from_policy(array $sourceQuery, array $policy): array
{
$canonical = [];
foreach ($policy as $targetKey => $rule) {
if (!is_array($rule)) {
continue;
}
if (isset($rule['include_if']) && is_callable($rule['include_if']) && !$rule['include_if']($sourceQuery, $canonical)) {
continue;
}
$value = app_url_policy_value((string)$targetKey, $rule, $sourceQuery);
if ($value === null) {
continue;
}
if (isset($rule['transform']) && is_callable($rule['transform'])) {
$value = $rule['transform']($value, $sourceQuery, $canonical);
if ($value === null) {
continue;
}
}
if (isset($rule['validator']) && is_callable($rule['validator']) && !$rule['validator']($value, $sourceQuery, $canonical)) {
continue;
}
if (array_key_exists('omit_if', $rule) && $value === $rule['omit_if']) {
continue;
}
$canonical[(string)$targetKey] = $value;
}
return $canonical;
}

View File

@ -14,6 +14,8 @@
* - `password`: Change password * - `password`: Change password
*/ */
require_once '../app/helpers/url_canonicalizer.php';
// Initialize user object // Initialize user object
$userObject = new User($db); $userObject = new User($db);
@ -21,6 +23,24 @@ $userObject = new User($db);
$action = $_REQUEST['action'] ?? ''; $action = $_REQUEST['action'] ?? '';
$item = $_REQUEST['item'] ?? ''; $item = $_REQUEST['item'] ?? '';
$isGetRequest = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';
if ($isGetRequest) {
$canonicalPolicy = [
'page' => [
'type' => 'literal',
'value' => 'credentials',
],
'action' => [
'type' => 'enum',
'allowed' => ['setup', 'verify'],
],
];
$canonicalQuery = app_url_build_query_from_policy($_GET, $canonicalPolicy);
// Restrict credentials URLs to valid setup/verify screen states.
app_url_redirect_to_canonical_query((string)$app_root, $_GET, $canonicalQuery);
}
// if a form is submitted // if a form is submitted
if ($_SERVER['REQUEST_METHOD'] == 'POST') { if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// Ensure security helper is available // Ensure security helper is available

View File

@ -17,6 +17,8 @@
// clear the global error var before login // clear the global error var before login
unset($error); unset($error);
require_once '../app/helpers/url_canonicalizer.php';
try { try {
// connect to database // connect to database
$db = connectDB($config); $db = connectDB($config);
@ -31,6 +33,39 @@ try {
$action = $_REQUEST['action'] ?? ''; $action = $_REQUEST['action'] ?? '';
$isGetRequest = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';
if ($isGetRequest) {
$canonicalPolicy = [
'page' => [
'type' => 'literal',
'value' => 'login',
],
'action' => [
'type' => 'enum',
'allowed' => ['verify', 'forgot', 'reset'],
],
'token' => [
'type' => 'string',
'validator' => static function ($value): bool {
return $value !== '';
},
'include_if' => static function (array $sourceQuery): bool {
return (($sourceQuery['action'] ?? '') === 'reset');
},
],
'redirect' => [
'type' => 'string',
'validator' => static function ($value): bool {
return (strpos($value, '/') === 0 || strpos($value, '?') === 0);
},
],
];
$canonicalQuery = app_url_build_query_from_policy($_GET, $canonicalPolicy);
// Keep login URLs constrained to supported route states and safe redirect inputs.
app_url_redirect_to_canonical_query((string)$app_root, $_GET, $canonicalQuery);
}
if ($action === 'verify' && isset($_SESSION['2fa_pending_user_id'])) { if ($action === 'verify' && isset($_SESSION['2fa_pending_user_id'])) {
// Handle 2FA verification // Handle 2FA verification
$code = $_POST['code'] ?? ''; $code = $_POST['code'] ?? '';

View File

@ -12,8 +12,29 @@
* - `edit`: Edit user profile details, rights, or avatar. * - `edit`: Edit user profile details, rights, or avatar.
*/ */
require_once '../app/helpers/url_canonicalizer.php';
$action = $_REQUEST['action'] ?? ''; $action = $_REQUEST['action'] ?? '';
$item = $_REQUEST['item'] ?? ''; $item = $_REQUEST['item'] ?? '';
$isGetRequest = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';
if ($isGetRequest) {
$canonicalPolicy = [
'page' => [
'type' => 'literal',
'value' => 'profile',
],
'action' => [
'type' => 'enum',
'allowed' => ['edit'],
],
];
$canonicalQuery = app_url_build_query_from_policy($_GET, $canonicalPolicy);
// Keep profile URLs constrained to supported view states only.
app_url_redirect_to_canonical_query((string)$app_root, $_GET, $canonicalQuery);
}
// pass the user details to the profile hooks // pass the user details to the profile hooks
$profileHooksContext = [ $profileHooksContext = [
'userId' => $userId ?? null, 'userId' => $userId ?? null,

View File

@ -1,5 +1,7 @@
<?php <?php
require_once APP_PATH . 'helpers/url_canonicalizer.php';
// Check if user has any of the required rights // Check if user has any of the required rights
if (!($userObject->hasRight($userId, 'superuser') || if (!($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($userId, 'edit whitelist') || $userObject->hasRight($userId, 'edit whitelist') ||
@ -11,6 +13,29 @@ if (!($userObject->hasRight($userId, 'superuser') ||
// Get current section // Get current section
$section = isset($_POST['section']) ? $_POST['section'] : (isset($_GET['section']) ? $_GET['section'] : 'whitelist'); $section = isset($_POST['section']) ? $_POST['section'] : (isset($_GET['section']) ? $_GET['section'] : 'whitelist');
$allowedSections = ['whitelist', 'blacklist', 'ratelimit'];
if (!in_array($section, $allowedSections, true)) {
$section = 'whitelist';
}
$isGetRequest = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';
if ($isGetRequest) {
$canonicalPolicy = [
'page' => [
'type' => 'literal',
'value' => 'security',
],
'section' => [
'type' => 'literal',
'value' => $section,
'omit_if' => 'whitelist',
],
];
$canonicalQuery = app_url_build_query_from_policy($_GET, $canonicalPolicy);
// Keep security page URLs stable by removing unknown GET parameters.
app_url_redirect_to_canonical_query((string)$app_root, $_GET, $canonicalQuery);
}
// Initialize RateLimiter // Initialize RateLimiter
require_once '../app/classes/ratelimiter.php'; require_once '../app/classes/ratelimiter.php';
@ -153,8 +178,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
Feedback::flash('ERROR', $e->getMessage()); Feedback::flash('ERROR', $e->getMessage());
} }
// Redirect back to the appropriate section // Redirect back to the appropriate section using canonical query formatting.
header("Location: $app_root?page=security&section=" . urlencode($section)); $redirectPolicy = [
'page' => [
'type' => 'literal',
'value' => 'security',
],
'section' => [
'type' => 'literal',
'value' => $section,
'omit_if' => 'whitelist',
],
];
$redirectQuery = app_url_build_query_from_policy([], $redirectPolicy);
header('Location: ' . app_url_build_internal((string)$app_root, $redirectQuery));
exit; exit;
} }

View File

@ -11,6 +11,7 @@
// Initialize security // Initialize security
require_once '../app/helpers/security.php'; require_once '../app/helpers/security.php';
require_once '../app/helpers/url_canonicalizer.php';
$security = SecurityHelper::getInstance(); $security = SecurityHelper::getInstance();
// Only allow access to logged-in users // Only allow access to logged-in users
@ -22,6 +23,29 @@ if (!Session::isValidSession()) {
// Get any old feedback messages // Get any old feedback messages
include_once '../app/helpers/feedback.php'; include_once '../app/helpers/feedback.php';
$isGetRequest = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';
if ($isGetRequest) {
$canonicalPolicy = [
'page' => [
'type' => 'literal',
'value' => 'theme',
],
'switch_to' => [
'type' => 'string',
],
'csrf_token' => [
'type' => 'string',
'include_if' => static function (array $sourceQuery): bool {
return trim((string)($sourceQuery['switch_to'] ?? '')) !== '';
},
],
];
$canonicalQuery = app_url_build_query_from_policy($_GET, $canonicalPolicy);
// Keep theme page URLs deterministic while preserving switch action inputs.
app_url_redirect_to_canonical_query((string)$app_root, $_GET, $canonicalQuery);
}
// Handle theme switching // Handle theme switching
if (isset($_GET['switch_to'])) { if (isset($_GET['switch_to'])) {
$themeName = $_GET['switch_to']; $themeName = $_GET['switch_to'];

View File

@ -10,6 +10,7 @@ require_once PLUGIN_LOGS_PATH . 'models/Log.php';
require_once PLUGIN_LOGS_PATH . 'models/LoggerFactory.php'; require_once PLUGIN_LOGS_PATH . 'models/LoggerFactory.php';
require_once APP_PATH . 'classes/user.php'; require_once APP_PATH . 'classes/user.php';
require_once APP_PATH . 'helpers/theme.php'; require_once APP_PATH . 'helpers/theme.php';
require_once APP_PATH . 'helpers/url_canonicalizer.php';
function logs_plugin_handle(string $action, array $context = []): bool { function logs_plugin_handle(string $action, array $context = []): bool {
$validSession = (bool)($context['valid_session'] ?? false); $validSession = (bool)($context['valid_session'] ?? false);
@ -31,6 +32,60 @@ function logs_plugin_handle(string $action, array $context = []): bool {
exit; exit;
} }
$isGetRequest = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';
if ($isGetRequest) {
$canonicalPolicy = [
'page' => [
'type' => 'literal',
'value' => 'logs',
],
'action' => [
'type' => 'enum',
'allowed' => ['list'],
'omit_if' => 'list',
],
'tab' => [
'type' => 'enum',
'allowed' => ['user', 'system'],
'omit_if' => 'user',
],
'page_num' => [
'type' => 'int',
'min' => 1,
'omit_if' => 1,
],
'from_time' => [
'type' => 'string',
'validator' => static function ($value): bool {
return trim((string)$value) !== '';
},
],
'until_time' => [
'type' => 'string',
'validator' => static function ($value): bool {
return trim((string)$value) !== '';
},
],
'message' => [
'type' => 'string',
'validator' => static function ($value): bool {
return trim((string)$value) !== '';
},
],
'id' => [
'type' => 'int',
'min' => 1,
'include_if' => static function (array $sourceQuery): bool {
return (($sourceQuery['tab'] ?? '') === 'system');
},
],
];
$canonicalQuery = app_url_build_query_from_policy($_GET, $canonicalPolicy);
// Keep logs URLs constrained to supported list filters and pagination state.
app_url_redirect_to_canonical_query((string)$app_root, $_GET, $canonicalQuery);
}
switch ($action) { switch ($action) {
case 'list': case 'list':
default: default:

View File

@ -11,6 +11,7 @@ require_once APP_PATH . 'classes/user.php';
require_once APP_PATH . 'classes/validator.php'; require_once APP_PATH . 'classes/validator.php';
require_once APP_PATH . 'helpers/security.php'; require_once APP_PATH . 'helpers/security.php';
require_once APP_PATH . 'helpers/theme.php'; require_once APP_PATH . 'helpers/theme.php';
require_once APP_PATH . 'helpers/url_canonicalizer.php';
require_once APP_PATH . 'includes/rate_limit_middleware.php'; require_once APP_PATH . 'includes/rate_limit_middleware.php';
require_once PLUGIN_REGISTER_PATH . 'models/register.php'; require_once PLUGIN_REGISTER_PATH . 'models/register.php';
@ -33,6 +34,20 @@ function register_plugin_handle_register(string $action, array $context = []): b
return true; return true;
} }
$isGetRequest = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';
if ($isGetRequest) {
$canonicalPolicy = [
'page' => [
'type' => 'literal',
'value' => 'register',
],
];
$canonicalQuery = app_url_build_query_from_policy($_GET, $canonicalPolicy);
// Keep register URLs constrained to the canonical public registration route.
app_url_redirect_to_canonical_query((string)$app_root, $_GET, $canonicalQuery);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
register_plugin_handle_submission($validSession, $app_root, $db, $logger); register_plugin_handle_submission($validSession, $app_root, $db, $logger);
return true; return true;