Centralizes url canonicalization and normalization
parent
b88b2ce8a5
commit
8dc0b526ab
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -14,6 +14,8 @@
|
|||
* - `password`: Change password
|
||||
*/
|
||||
|
||||
require_once '../app/helpers/url_canonicalizer.php';
|
||||
|
||||
// Initialize user object
|
||||
$userObject = new User($db);
|
||||
|
||||
|
|
@ -21,6 +23,24 @@ $userObject = new User($db);
|
|||
$action = $_REQUEST['action'] ?? '';
|
||||
$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 ($_SERVER['REQUEST_METHOD'] == 'POST') {
|
||||
// Ensure security helper is available
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@
|
|||
// clear the global error var before login
|
||||
unset($error);
|
||||
|
||||
require_once '../app/helpers/url_canonicalizer.php';
|
||||
|
||||
try {
|
||||
// connect to database
|
||||
$db = connectDB($config);
|
||||
|
|
@ -31,6 +33,39 @@ try {
|
|||
|
||||
$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'])) {
|
||||
// Handle 2FA verification
|
||||
$code = $_POST['code'] ?? '';
|
||||
|
|
|
|||
|
|
@ -12,8 +12,29 @@
|
|||
* - `edit`: Edit user profile details, rights, or avatar.
|
||||
*/
|
||||
|
||||
require_once '../app/helpers/url_canonicalizer.php';
|
||||
|
||||
$action = $_REQUEST['action'] ?? '';
|
||||
$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
|
||||
$profileHooksContext = [
|
||||
'userId' => $userId ?? null,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
require_once APP_PATH . 'helpers/url_canonicalizer.php';
|
||||
|
||||
// Check if user has any of the required rights
|
||||
if (!($userObject->hasRight($userId, 'superuser') ||
|
||||
$userObject->hasRight($userId, 'edit whitelist') ||
|
||||
|
|
@ -11,6 +13,29 @@ if (!($userObject->hasRight($userId, 'superuser') ||
|
|||
|
||||
// Get current section
|
||||
$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
|
||||
require_once '../app/classes/ratelimiter.php';
|
||||
|
|
@ -153,8 +178,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
|||
Feedback::flash('ERROR', $e->getMessage());
|
||||
}
|
||||
|
||||
// Redirect back to the appropriate section
|
||||
header("Location: $app_root?page=security§ion=" . urlencode($section));
|
||||
// Redirect back to the appropriate section using canonical query formatting.
|
||||
$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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
// Initialize security
|
||||
require_once '../app/helpers/security.php';
|
||||
require_once '../app/helpers/url_canonicalizer.php';
|
||||
$security = SecurityHelper::getInstance();
|
||||
|
||||
// Only allow access to logged-in users
|
||||
|
|
@ -22,6 +23,29 @@ if (!Session::isValidSession()) {
|
|||
// Get any old feedback messages
|
||||
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
|
||||
if (isset($_GET['switch_to'])) {
|
||||
$themeName = $_GET['switch_to'];
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ require_once PLUGIN_LOGS_PATH . 'models/Log.php';
|
|||
require_once PLUGIN_LOGS_PATH . 'models/LoggerFactory.php';
|
||||
require_once APP_PATH . 'classes/user.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 {
|
||||
$validSession = (bool)($context['valid_session'] ?? false);
|
||||
|
|
@ -31,6 +32,60 @@ function logs_plugin_handle(string $action, array $context = []): bool {
|
|||
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) {
|
||||
case 'list':
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ require_once APP_PATH . 'classes/user.php';
|
|||
require_once APP_PATH . 'classes/validator.php';
|
||||
require_once APP_PATH . 'helpers/security.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 PLUGIN_REGISTER_PATH . 'models/register.php';
|
||||
|
||||
|
|
@ -33,6 +34,20 @@ function register_plugin_handle_register(string $action, array $context = []): b
|
|||
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') {
|
||||
register_plugin_handle_submission($validSession, $app_root, $db, $logger);
|
||||
return true;
|
||||
|
|
|
|||
Loading…
Reference in New Issue