Compare commits

..

No commits in common. "665d5bded91e0fdfef67dbd43561b0decc9ac84f" and "10460baad85b4914af20c2e66f74b8ff31efa77c" have entirely different histories.

15 changed files with 27 additions and 797 deletions

View File

@ -39,9 +39,6 @@ class ApiResponse {
* @param int $status HTTP status code * @param int $status HTTP status code
*/ */
private static function send($data, $status) { private static function send($data, $status) {
while (ob_get_level() > 0) {
ob_end_clean();
}
http_response_code($status); http_response_code($status);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode($data); echo json_encode($data);

View File

@ -17,8 +17,8 @@ class Session {
} }
private static $sessionOptions = [ private static $sessionOptions = [
'cookie_httponly' => 1, 'cookie_httponly' => 1,
'cookie_secure' => 0, 'cookie_secure' => 1,
'cookie_samesite' => 'Lax', 'cookie_samesite' => 'Strict',
'gc_maxlifetime' => 7200 // 2 hours 'gc_maxlifetime' => 7200 // 2 hours
]; ];
@ -52,13 +52,13 @@ class Session {
'domain' => $thisDomain, 'domain' => $thisDomain,
'secure' => $isSecure, 'secure' => $isSecure,
'httponly' => true, 'httponly' => true,
'samesite' => 'Lax' 'samesite' => 'Strict'
]); ]);
} }
// Align session start options dynamically with current transport // Align session start options dynamically with current transport
self::$sessionOptions['cookie_secure'] = $isSecure ? 1 : 0; self::$sessionOptions['cookie_secure'] = $isSecure ? 1 : 0;
self::$sessionOptions['cookie_samesite'] = 'Lax'; self::$sessionOptions['cookie_samesite'] = 'Strict';
self::$initialized = true; self::$initialized = true;
} }
@ -181,7 +181,7 @@ class Session {
'domain' => $config['domain'], 'domain' => $config['domain'],
'secure' => isset($_SERVER['HTTPS']), 'secure' => isset($_SERVER['HTTPS']),
'httponly' => true, 'httponly' => true,
'samesite' => 'Lax' 'samesite' => 'Strict'
]); ]);
} }
@ -219,7 +219,7 @@ class Session {
'domain' => $config['domain'] ?? '', 'domain' => $config['domain'] ?? '',
'secure' => isset($_SERVER['HTTPS']), 'secure' => isset($_SERVER['HTTPS']),
'httponly' => true, 'httponly' => true,
'samesite' => 'Lax' 'samesite' => 'Strict'
] ]
); );
@ -230,7 +230,7 @@ class Session {
'domain' => $config['domain'] ?? '', 'domain' => $config['domain'] ?? '',
'secure' => isset($_SERVER['HTTPS']), 'secure' => isset($_SERVER['HTTPS']),
'httponly' => true, 'httponly' => true,
'samesite' => 'Lax' 'samesite' => 'Strict'
]); ]);
} }

View File

@ -63,7 +63,7 @@ class Validator {
} }
break; break;
case 'phone': case 'phone':
if (!empty($value) && !preg_match('/^(\+?\d{1,4})?\s?(\d[\d\s]{6,})$/', $value)) { if (!empty($value) && !preg_match('/^[+]?[\d\s-()]{7,}$/', $value)) {
$this->addError($field, "Invalid phone number format"); $this->addError($field, "Invalid phone number format");
} }
break; break;

View File

@ -275,7 +275,7 @@ class PluginManager
} }
/** /**
* Purge plugin by dropping its migration-defined tables and removing settings. * Purge plugin by dropping its tables and removing settings.
*/ */
public static function purge(string $plugin): bool public static function purge(string $plugin): bool
{ {
@ -290,8 +290,6 @@ class PluginManager
} }
$pdo = ($db instanceof \PDO) ? $db : $db->getConnection(); $pdo = ($db instanceof \PDO) ? $db : $db->getConnection();
$foreignKeyChecksDisabled = false;
try { try {
// First disable the plugin // First disable the plugin
self::setEnabled($plugin, false); self::setEnabled($plugin, false);
@ -300,55 +298,34 @@ class PluginManager
$stmt = $pdo->prepare('DELETE FROM settings WHERE `key` LIKE :pattern'); $stmt = $pdo->prepare('DELETE FROM settings WHERE `key` LIKE :pattern');
$stmt->execute([':pattern' => 'plugin_enabled_' . $plugin]); $stmt->execute([':pattern' => 'plugin_enabled_' . $plugin]);
$migrationDir = self::$catalog[$plugin]['path'] . '/migrations'; // Drop plugin-specific tables (user_pro_* tables for this plugin)
$migrationFiles = glob($migrationDir . '/*.sql') ?: []; $stmt = $pdo->prepare('SHOW TABLES LIKE "user_pro_%"');
$tables = []; $stmt->execute();
$tables = $stmt->fetchAll(\PDO::FETCH_COLUMN, 0);
foreach ($migrationFiles as $migrationFile) {
if (!is_string($migrationFile) || !file_exists($migrationFile)) {
continue;
}
$migrationContent = file_get_contents($migrationFile);
if (!is_string($migrationContent) || $migrationContent === '') {
continue;
}
// Derive table ownership from CREATE TABLE statements in plugin migrations.
preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([a-zA-Z0-9_]+)`?/i', $migrationContent, $matches);
$discoveredTables = $matches[1] ?? [];
foreach ($discoveredTables as $tableName) {
if (!is_string($tableName) || $tableName === '') {
continue;
}
$tables[] = $tableName;
}
}
$tables = array_values(array_unique($tables));
// Disable foreign key checks temporarily to allow table drops // Disable foreign key checks temporarily to allow table drops
$pdo->exec('SET FOREIGN_KEY_CHECKS=0'); $pdo->exec('SET FOREIGN_KEY_CHECKS=0');
$foreignKeyChecksDisabled = true;
foreach ($tables as $table) { foreach ($tables as $table) {
// Check if this table belongs to the plugin by checking its migration file
$migrationFile = self::$catalog[$plugin]['path'] . '/migrations/create_' . $plugin . '_tables.sql';
if (file_exists($migrationFile)) {
$migrationContent = file_get_contents($migrationFile);
if (strpos($migrationContent, $table) !== false) {
$pdo->exec("DROP TABLE IF EXISTS `$table`"); $pdo->exec("DROP TABLE IF EXISTS `$table`");
app_log('info', 'PluginManager::purge: Dropped table ' . $table . ' for plugin ' . $plugin, ['scope' => 'plugin']); app_log('info', 'PluginManager::purge: Dropped table ' . $table . ' for plugin ' . $plugin, ['scope' => 'plugin']);
} }
}
}
// Re-enable foreign key checks
$pdo->exec('SET FOREIGN_KEY_CHECKS=1');
app_log('info', 'PluginManager::purge: Successfully purged plugin ' . $plugin, ['scope' => 'plugin']); app_log('info', 'PluginManager::purge: Successfully purged plugin ' . $plugin, ['scope' => 'plugin']);
return true; return true;
} catch (Throwable $e) { } catch (Throwable $e) {
app_log('error', 'PluginManager::purge failed for ' . $plugin . ': ' . $e->getMessage(), ['scope' => 'plugin']); app_log('error', 'PluginManager::purge failed for ' . $plugin . ': ' . $e->getMessage(), ['scope' => 'plugin']);
return false; return false;
} finally {
if ($foreignKeyChecksDisabled) {
try {
$pdo->exec('SET FOREIGN_KEY_CHECKS=1');
} catch (Throwable $e) {
app_log('error', 'PluginManager::purge: Failed to restore FOREIGN_KEY_CHECKS for ' . $plugin . ': ' . $e->getMessage(), ['scope' => 'plugin']);
}
}
} }
} }
} }

View File

@ -1,252 +0,0 @@
<?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,8 +14,6 @@
* - `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);
@ -23,24 +21,6 @@ $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,8 +17,6 @@
// 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);
@ -33,39 +31,6 @@ 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,29 +12,8 @@
* - `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,7 +1,5 @@
<?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') ||
@ -13,29 +11,6 @@ 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';
@ -178,20 +153,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
Feedback::flash('ERROR', $e->getMessage()); Feedback::flash('ERROR', $e->getMessage());
} }
// Redirect back to the appropriate section using canonical query formatting. // Redirect back to the appropriate section
$redirectPolicy = [ header("Location: $app_root?page=security&section=" . urlencode($section));
'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,7 +11,6 @@
// 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
@ -23,29 +22,6 @@ 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

@ -1,59 +0,0 @@
# App Registry Helper
The `App\App` class provides a service locator to expose core services to
plugins without relying on globals or relative `require_once` paths.
## Goals
1. Provide a stable API surface for plugins and core modules.
2. Allow gradual refactors away from `$GLOBALS`.
3. Keep legacy code working by falling back to existing globals when no service
has been registered yet.
## Usage
```php
use App\App;
// Register services during bootstrap
App::set('config', $config);
App::set('db', $dbConnection);
App::set('logger', $logger);
// Use services anywhere later
$db = App::db();
$config = App::config();
$logger = App::get('logger');
```
### Convenience Helpers
The helper exposes shortcuts for the most common services:
- `App::db()` database connection
- `App::config()` configuration array
- `App::user()` authenticated user object (if any)
All helper calls fall back to their legacy `$GLOBALS` equivalents so older code
can be migrated incrementally.
### Resetting (Tests)
Unit tests can call `App::reset()` (optionally with a service key) to clear the
registry and avoid old state bleed between test cases.
## Bootstrap Integration
`public_html/index.php` now registers runtime services as they are created:
```php
App::set('config', $config);
App::set('config_path', $configFile);
App::set('app_root', $appRoot);
App::set('db', $db);
App::set('logger', $logger);
```
Plugins should prefer `App` over accessing globals directly. This ensures future
moves (like relocating call logic into `plugins/calls/`) do not require path rewrites
or global variables.

View File

@ -1,100 +0,0 @@
# Security Documentation
## Overview
This document outlines the security features and practices implemented in the system.
## Authentication
Authentication is handled through the user accounts system. See `user-accounts.md` for details on:
- User registration
- Login/logout functionality
- Password requirements
- Session management
## Database Security
1. **SQL Injection Prevention**
- All database queries use prepared statements with parameterized queries
- Input validation and sanitization
- Use of PDO for database access
2. **Data Access Control**
- User ownership verification on all operations
- Permission checks before data access
- Proper error handling to prevent information leakage
## Database Tables
The security system uses the following tables:
1. **Rate Limits (`rate_limit`)**
- Tracks rate limiting for various operations
- User and IP tracking
- Operation type identification
- Timestamp tracking
- Attempt counting
2. **Security Events (`security_event`)**
- Records security-related events
- Event type and severity
- User and IP information
- Timestamp tracking
- Event details storage
3. **Blocked IPs (`blocked_ip`)**
- Manages IP blocking
- Block reason tracking
- Block duration
- Administrator notes
## Data Protection
1. **Passwords**
- Stored using secure hashing
- Never stored or transmitted in plain text
- Password reset functionality with secure tokens
2. **Session Security**
- Session tokens properly generated and managed
- Session timeout implementation
- Protection against session fixation
3. **Input Validation**
- Data validation on both client and server side
- Protection against XSS attacks
- Content type verification
- Size limits on inputs
## Access Control
1. **Resource Protection**
- User ownership verification for all resources
- Permission checks before operations
- Proper error handling for unauthorized access
2. **API Security**
- Authentication required for API access
- Rate limiting
- Input validation
- Error handling without information leakage
## Best Practices
1. **Code Security**
- Use of prepared statements
- Input validation and sanitization
- Proper error handling
- Secure configuration management
2. **Data Security**
- User data protection
- Secure storage practices
- Access control implementation
- Error handling without leaks
3. **Infrastructure Security**
- Configuration security
- Environment separation
- Secure deployment practices
- Regular security updates

View File

@ -1,126 +0,0 @@
# URL canonicalization and normalization guide
This document defines the standard flow for query-string canonicalization in page/controllers.
Use it for all new route work and when touching existing page logic.
## Why this exists
Canonical URLs make route behavior predictable and secure by:
- removing unknown query parameters,
- normalizing known parameters to expected types,
- preventing duplicate URL variants for the same page state,
- reducing controller-specific ad-hoc redirect logic.
## Shared helper
All route canonicalization must use:
- `app/helpers/url_canonicalizer.php`
Core functions:
- `app_url_build_query_from_policy(array $sourceQuery, array $policy): array`
- `app_url_redirect_to_canonical_query(string $appRoot, array $currentQuery, array $canonicalQuery): void`
- `app_url_build_internal(string $appRoot, array $query): string`
- `app_url_policy_value(string $targetKey, array $rule, array $sourceQuery)`
## Standard controller flow
For GET routes, follow this order:
1. Resolve request context (`app_root`, user/session state, etc.).
2. Resolve a defensive GET guard (`$isGetRequest`) from `$_SERVER['REQUEST_METHOD']`.
3. Define canonical policy rules for the route.
4. Build canonical query from `$_GET`.
5. Redirect if current query differs from canonical query.
6. Continue regular page logic (rendering, DB loading, etc.).
Reference pattern:
```php
require_once APP_PATH . 'helpers/url_canonicalizer.php';
$isGetRequest = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';
if ($isGetRequest) {
$canonicalPolicy = [
'page' => [
'type' => 'literal',
'value' => 'example',
],
];
$canonicalQuery = app_url_build_query_from_policy($_GET, $canonicalPolicy);
// Keep example URLs constrained to supported route state.
app_url_redirect_to_canonical_query((string)$app_root, $_GET, $canonicalQuery);
}
```
## Policy rule types
Supported rule `type` values:
- `literal`: fixed value from policy (`value`)
- `string`: trimmed scalar string
- `int`: integer with optional bounds (`min`, `max`)
- `enum`: string limited to `allowed` values
- `bool_flag`: emits `value_true` for truthy request inputs
- `string_list`: normalized list values (optionally `unique`)
Useful options:
- `source`: map canonical key from another source key
- `default`: fallback value
- `include_if`: callable gate to include rule conditionally
- `omit_if`: drop key when value equals sentinel
- `transform`: callable value transformer
- `validator`: callable final validator
## Route design rules
When adding canonicalization:
- Always include `page` as `literal`.
- Keep allowed query set minimal.
- Use `enum` for fixed states (`tab`, `action`, `status`, etc.).
- Use `int` with bounds for IDs and pagination.
- Use `omit_if` to avoid noisy defaults in URLs (for example `p=1`).
- Preserve only query keys that materially represent page state.
## What not to canonicalize as page URLs
Do not force page-style canonicalization on non-page endpoints that intentionally behave as API/callback streams, for example:
- JSON suggestion endpoints,
- payment webhook/callback handlers,
- binary/document output handlers,
- static asset streaming handlers.
For these endpoints, keep strict input validation and explicit allowlists as currently implemented.
## Redirect behavior
`app_url_redirect_to_canonical_query` compares normalized current and canonical queries.
If different, it sends a `Location` header and exits.
Implications:
- Logic after the call runs only for canonical request URLs.
- Downstream code may continue reading `$_GET`; values are already canonicalized by redirect gate.
- If custom redirect URL construction is needed after POST actions, use `app_url_build_internal` with a policy-built query.
## Update checklist for new/edited routes
When changing a route:
1. Add/confirm `require_once` for `url_canonicalizer.php`.
2. Use the standardized defensive guard:
`$isGetRequest = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';`
3. Add/adjust GET canonical policy near route entry.
4. Keep existing business logic unchanged unless explicitly requested.
5. Add concise inline comment for non-trivial policy/condition blocks.
6. Update deployment-facing route documentation used in your environment.
7. Run syntax checks and PHPUnit as part of validation cadence.
## Deployment notes
Coverage is deployment-scoped.
When auditing a specific environment:
- verify enabled route entry points use policy-based canonicalization,
- keep non-page API/callback/document/asset endpoints on strict allowlist
validation,
- keep local operational/developer documentation updated according to the
documentation set available in that installation.

View File

@ -10,7 +10,6 @@ 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);
@ -32,60 +31,6 @@ 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,7 +11,6 @@ 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';
@ -34,20 +33,6 @@ 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;