diff --git a/app/helpers/url_canonicalizer.php b/app/helpers/url_canonicalizer.php new file mode 100644 index 0000000..706bb3e --- /dev/null +++ b/app/helpers/url_canonicalizer.php @@ -0,0 +1,252 @@ + $query + * @return array + */ +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 $left + * @param array $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 $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 $currentQuery + * @param array $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 $rule + * @param array $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 $sourceQuery + * @param array> $policy + * @return array + */ +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; +} diff --git a/app/pages/credentials.php b/app/pages/credentials.php index c90d0d8..1d37409 100644 --- a/app/pages/credentials.php +++ b/app/pages/credentials.php @@ -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 diff --git a/app/pages/login.php b/app/pages/login.php index a384cdd..a04561e 100644 --- a/app/pages/login.php +++ b/app/pages/login.php @@ -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'] ?? ''; diff --git a/app/pages/profile.php b/app/pages/profile.php index 202c53b..0ac975d 100644 --- a/app/pages/profile.php +++ b/app/pages/profile.php @@ -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, diff --git a/app/pages/security.php b/app/pages/security.php index 074d436..f39e3a0 100644 --- a/app/pages/security.php +++ b/app/pages/security.php @@ -1,5 +1,7 @@ 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; } diff --git a/app/pages/theme.php b/app/pages/theme.php index 4b741d1..9b7f27c 100644 --- a/app/pages/theme.php +++ b/app/pages/theme.php @@ -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']; diff --git a/plugins/logs/controllers/logs.php b/plugins/logs/controllers/logs.php index 960c093..589b1e9 100644 --- a/plugins/logs/controllers/logs.php +++ b/plugins/logs/controllers/logs.php @@ -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: diff --git a/plugins/register/controllers/register.php b/plugins/register/controllers/register.php index b971489..f87560e 100644 --- a/plugins/register/controllers/register.php +++ b/plugins/register/controllers/register.php @@ -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;