Add security logging, domain validation, and output helpers
- Add authentication failure logging to AuthMiddleware (session expiry, access denied, unauthenticated access attempts) - Add UrlHelper for secure URL generation with host validation against configurable ALLOWED_HOSTS whitelist - Add OutputHelper with consistent XSS-safe escaping functions (h, attr, json, url, css, truncate, date, cssClass) - Add validation to AuditLogModel query parameters (pagination limits, date format validation, action/entity type validation, IP sanitization) - Add APP_DOMAIN and ALLOWED_HOSTS configuration options Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ try {
|
||||
$configPath = dirname(__DIR__) . '/config/config.php';
|
||||
require_once $configPath;
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||
|
||||
// Load environment variables (for Discord webhook)
|
||||
$envPath = dirname(__DIR__) . '/.env';
|
||||
@@ -220,10 +221,8 @@ try {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create ticket URL
|
||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 't.lotusguild.org';
|
||||
$ticketUrl = "{$protocol}://{$host}/ticket/{$ticketId}";
|
||||
// Create ticket URL using validated host
|
||||
$ticketUrl = UrlHelper::ticketUrl($ticketId);
|
||||
|
||||
// Determine embed color based on priority
|
||||
$colors = [
|
||||
|
||||
@@ -31,6 +31,14 @@ $GLOBALS['config'] = [
|
||||
'ASSETS_URL' => '/assets', // Assets URL
|
||||
'API_URL' => '/api', // API URL
|
||||
|
||||
// Domain settings for external integrations (webhooks, links, etc.)
|
||||
// Set APP_DOMAIN in .env to override
|
||||
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
|
||||
// Allowed hosts for HTTP_HOST validation (comma-separated in .env)
|
||||
'ALLOWED_HOSTS' => array_filter(array_map('trim',
|
||||
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
|
||||
)),
|
||||
|
||||
// Session settings
|
||||
'SESSION_TIMEOUT' => 3600, // 1 hour in seconds
|
||||
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
|
||||
|
||||
@@ -6,6 +6,7 @@ require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||
|
||||
class TicketController {
|
||||
private $ticketModel;
|
||||
@@ -218,10 +219,8 @@ class TicketController {
|
||||
|
||||
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
|
||||
|
||||
// Create ticket URL
|
||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 't.lotusguild.org';
|
||||
$ticketUrl = "{$protocol}://{$host}/ticket/{$ticketId}";
|
||||
// Create ticket URL using validated host
|
||||
$ticketUrl = UrlHelper::ticketUrl($ticketId);
|
||||
|
||||
// Map priorities to Discord colors (matching API endpoint)
|
||||
$priorityColors = [
|
||||
|
||||
@@ -52,6 +52,7 @@ if ($conn->connect_error) {
|
||||
// Authenticate via API key
|
||||
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
||||
require_once __DIR__ . '/models/AuditLogModel.php';
|
||||
require_once __DIR__ . '/helpers/UrlHelper.php';
|
||||
|
||||
$apiKeyAuth = new ApiKeyAuth($conn);
|
||||
|
||||
@@ -245,9 +246,8 @@ if (isset($envVars['DISCORD_WEBHOOK_URL']) && !empty($envVars['DISCORD_WEBHOOK_U
|
||||
"5" => "P5 - Info"
|
||||
];
|
||||
|
||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 't.lotusguild.org';
|
||||
$ticketUrl = "{$protocol}://{$host}/ticket/{$ticket_id}";
|
||||
// Create ticket URL using validated host
|
||||
$ticketUrl = UrlHelper::ticketUrl($ticket_id);
|
||||
|
||||
// Extract hostname from title for cleaner display
|
||||
preg_match('/^\[([^\]]+)\]/', $title, $hostnameMatch);
|
||||
|
||||
198
helpers/OutputHelper.php
Normal file
198
helpers/OutputHelper.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
/**
|
||||
* OutputHelper - Consistent output escaping utilities
|
||||
*
|
||||
* Provides secure HTML escaping functions to prevent XSS attacks.
|
||||
* Use these functions when outputting user-controlled data.
|
||||
*/
|
||||
class OutputHelper {
|
||||
/**
|
||||
* Escape string for HTML output
|
||||
*
|
||||
* Use for text content inside HTML elements.
|
||||
* Example: <p><?= OutputHelper::h($userInput) ?></p>
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
|
||||
* @return string Escaped string
|
||||
*/
|
||||
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
return htmlspecialchars($string, $flags, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape string for HTML attribute context
|
||||
*
|
||||
* Use for values inside HTML attributes.
|
||||
* Example: <input value="<?= OutputHelper::attr($userInput) ?>">
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string
|
||||
*/
|
||||
public static function attr(?string $string): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
// More aggressive escaping for attribute context
|
||||
return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode data as JSON for JavaScript context
|
||||
*
|
||||
* Use when embedding data in JavaScript.
|
||||
* Example: <script>const data = <?= OutputHelper::json($data) ?>;</script>
|
||||
*
|
||||
* @param mixed $data The data to encode
|
||||
* @param int $flags json_encode flags
|
||||
* @return string JSON encoded string (safe for script context)
|
||||
*/
|
||||
public static function json($data, int $flags = 0): string {
|
||||
// Use HEX encoding for safety in HTML context
|
||||
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
|
||||
return json_encode($data, $safeFlags);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL encode a string
|
||||
*
|
||||
* Use for values in URL query strings.
|
||||
* Example: <a href="/search?q=<?= OutputHelper::url($query) ?>">
|
||||
*
|
||||
* @param string|null $string The string to encode
|
||||
* @return string URL encoded string
|
||||
*/
|
||||
public static function url(?string $string): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
return rawurlencode($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape for CSS context
|
||||
*
|
||||
* Use for values in inline CSS.
|
||||
* Example: <div style="color: <?= OutputHelper::css($color) ?>;">
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string (only allows safe characters)
|
||||
*/
|
||||
public static function css(?string $string): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
// Only allow alphanumeric, hyphens, underscores, spaces, and common CSS values
|
||||
if (!preg_match('/^[a-zA-Z0-9_\-\s#.,()%]+$/', $string)) {
|
||||
return '';
|
||||
}
|
||||
return $string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number safely
|
||||
*
|
||||
* Ensures output is always a valid number.
|
||||
*
|
||||
* @param mixed $number The number to format
|
||||
* @param int $decimals Number of decimal places
|
||||
* @return string Formatted number
|
||||
*/
|
||||
public static function number($number, int $decimals = 0): string {
|
||||
return number_format((float)$number, $decimals, '.', ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an integer safely
|
||||
*
|
||||
* @param mixed $value The value to format
|
||||
* @return int Integer value
|
||||
*/
|
||||
public static function int($value): int {
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate string with ellipsis
|
||||
*
|
||||
* @param string|null $string The string to truncate
|
||||
* @param int $length Maximum length
|
||||
* @param string $suffix Suffix to add if truncated
|
||||
* @return string Truncated and escaped string
|
||||
*/
|
||||
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (mb_strlen($string, 'UTF-8') <= $length) {
|
||||
return self::h($string);
|
||||
}
|
||||
|
||||
return self::h(mb_substr($string, 0, $length, 'UTF-8')) . self::h($suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date safely
|
||||
*
|
||||
* @param string|int|null $date Date string, timestamp, or null
|
||||
* @param string $format PHP date format
|
||||
* @return string Formatted date
|
||||
*/
|
||||
public static function date($date, string $format = 'Y-m-d H:i:s'): string {
|
||||
if ($date === null || $date === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_numeric($date)) {
|
||||
return date($format, (int)$date);
|
||||
}
|
||||
|
||||
$timestamp = strtotime($date);
|
||||
if ($timestamp === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return date($format, $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is safe for use as a CSS class name
|
||||
*
|
||||
* @param string $class The class name to validate
|
||||
* @return bool True if safe
|
||||
*/
|
||||
public static function isValidCssClass(string $class): bool {
|
||||
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize CSS class name(s)
|
||||
*
|
||||
* @param string|null $classes Space-separated class names
|
||||
* @return string Sanitized class names
|
||||
*/
|
||||
public static function cssClass(?string $classes): string {
|
||||
if ($classes === null || $classes === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$classList = explode(' ', $classes);
|
||||
$validClasses = array_filter($classList, [self::class, 'isValidCssClass']);
|
||||
|
||||
return implode(' ', $validClasses);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand function for HTML escaping
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string
|
||||
*/
|
||||
function h(?string $string): string {
|
||||
return OutputHelper::h($string);
|
||||
}
|
||||
99
helpers/UrlHelper.php
Normal file
99
helpers/UrlHelper.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
/**
|
||||
* UrlHelper - URL and domain utilities
|
||||
*
|
||||
* Provides secure URL generation with host validation.
|
||||
*/
|
||||
class UrlHelper {
|
||||
/**
|
||||
* Get the application base URL with validated host
|
||||
*
|
||||
* Uses APP_DOMAIN from config if set, otherwise validates HTTP_HOST
|
||||
* against ALLOWED_HOSTS whitelist.
|
||||
*
|
||||
* @return string Base URL (e.g., "https://example.com")
|
||||
*/
|
||||
public static function getBaseUrl(): string {
|
||||
$protocol = self::getProtocol();
|
||||
$host = self::getValidatedHost();
|
||||
|
||||
return "{$protocol}://{$host}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current protocol (http or https)
|
||||
*
|
||||
* @return string 'https' or 'http'
|
||||
*/
|
||||
public static function getProtocol(): string {
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
return 'https';
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
|
||||
return 'https';
|
||||
}
|
||||
if (!empty($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) {
|
||||
return 'https';
|
||||
}
|
||||
return 'http';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validated hostname
|
||||
*
|
||||
* Priority:
|
||||
* 1. APP_DOMAIN from config (if set)
|
||||
* 2. HTTP_HOST if it passes validation
|
||||
* 3. First allowed host as fallback
|
||||
*
|
||||
* @return string Validated hostname
|
||||
*/
|
||||
public static function getValidatedHost(): string {
|
||||
$config = $GLOBALS['config'] ?? [];
|
||||
|
||||
// Use configured APP_DOMAIN if available
|
||||
if (!empty($config['APP_DOMAIN'])) {
|
||||
return $config['APP_DOMAIN'];
|
||||
}
|
||||
|
||||
// Get allowed hosts
|
||||
$allowedHosts = $config['ALLOWED_HOSTS'] ?? ['localhost'];
|
||||
|
||||
// Validate HTTP_HOST against whitelist
|
||||
$httpHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
|
||||
// Strip port if present for comparison
|
||||
$hostWithoutPort = preg_replace('/:\d+$/', '', $httpHost);
|
||||
|
||||
if (in_array($hostWithoutPort, $allowedHosts, true)) {
|
||||
return $httpHost;
|
||||
}
|
||||
|
||||
// Log suspicious host header
|
||||
if (!empty($httpHost) && $httpHost !== 'localhost') {
|
||||
error_log("UrlHelper: Rejected HTTP_HOST '{$httpHost}' - not in allowed hosts");
|
||||
}
|
||||
|
||||
// Return first allowed host as fallback
|
||||
return $allowedHosts[0] ?? 'localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full URL for a ticket
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return string Full ticket URL
|
||||
*/
|
||||
public static function ticketUrl(string $ticketId): string {
|
||||
return self::getBaseUrl() . '/ticket/' . urlencode($ticketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current request is using HTTPS
|
||||
*
|
||||
* @return bool True if HTTPS
|
||||
*/
|
||||
public static function isSecure(): bool {
|
||||
return self::getProtocol() === 'https';
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,39 @@ class AuthMiddleware {
|
||||
$this->userModel = new UserModel($conn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security event for authentication failures
|
||||
*
|
||||
* @param string $event Event type (e.g., 'auth_required', 'access_denied', 'session_expired')
|
||||
* @param array $context Additional context data
|
||||
*/
|
||||
private function logSecurityEvent(string $event, array $context = []): void {
|
||||
$logData = [
|
||||
'event' => $event,
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
'forwarded_for' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? null,
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
|
||||
'request_uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
|
||||
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
|
||||
'timestamp' => date('c')
|
||||
];
|
||||
|
||||
// Merge additional context
|
||||
$logData = array_merge($logData, $context);
|
||||
|
||||
// Remove null values for cleaner logs
|
||||
$logData = array_filter($logData, fn($v) => $v !== null);
|
||||
|
||||
// Format log message
|
||||
$message = sprintf(
|
||||
"[SECURITY] %s: %s",
|
||||
strtoupper($event),
|
||||
json_encode($logData, JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
|
||||
error_log($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate user from Authelia forward auth headers
|
||||
*
|
||||
@@ -37,6 +70,13 @@ class AuthMiddleware {
|
||||
if (isset($_SESSION['user']) && isset($_SESSION['user']['user_id'])) {
|
||||
// Verify session hasn't expired (5 hour timeout)
|
||||
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > 18000)) {
|
||||
// Log session expiration
|
||||
$this->logSecurityEvent('session_expired', [
|
||||
'username' => $_SESSION['user']['username'] ?? 'unknown',
|
||||
'user_id' => $_SESSION['user']['user_id'] ?? null,
|
||||
'session_age_seconds' => time() - $_SESSION['last_activity']
|
||||
]);
|
||||
|
||||
// Session expired, clear it
|
||||
session_unset();
|
||||
session_destroy();
|
||||
@@ -123,6 +163,11 @@ class AuthMiddleware {
|
||||
* Redirect to Authelia login
|
||||
*/
|
||||
private function redirectToAuth() {
|
||||
// Log unauthenticated access attempt
|
||||
$this->logSecurityEvent('auth_required', [
|
||||
'reason' => 'no_auth_headers'
|
||||
]);
|
||||
|
||||
// Redirect to the auth endpoint (Authelia will handle the redirect back)
|
||||
header('HTTP/1.1 401 Unauthorized');
|
||||
echo '<!DOCTYPE html>
|
||||
@@ -187,6 +232,14 @@ class AuthMiddleware {
|
||||
* @param string $groups User groups
|
||||
*/
|
||||
private function showAccessDenied($username, $groups) {
|
||||
// Log access denied event with user details
|
||||
$this->logSecurityEvent('access_denied', [
|
||||
'username' => $username,
|
||||
'groups' => $groups ?: 'none',
|
||||
'required_groups' => 'admin,employee',
|
||||
'reason' => 'insufficient_group_membership'
|
||||
]);
|
||||
|
||||
header('HTTP/1.1 403 Forbidden');
|
||||
echo '<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
@@ -5,10 +5,92 @@
|
||||
class AuditLogModel {
|
||||
private $conn;
|
||||
|
||||
/** @var int Maximum allowed limit for pagination */
|
||||
private const MAX_LIMIT = 1000;
|
||||
|
||||
/** @var int Default limit for pagination */
|
||||
private const DEFAULT_LIMIT = 100;
|
||||
|
||||
/** @var array Allowed action types for filtering */
|
||||
private const VALID_ACTION_TYPES = [
|
||||
'create', 'update', 'delete', 'view', 'security_event',
|
||||
'login', 'logout', 'assign', 'comment', 'bulk_update'
|
||||
];
|
||||
|
||||
/** @var array Allowed entity types for filtering */
|
||||
private const VALID_ENTITY_TYPES = [
|
||||
'ticket', 'comment', 'user', 'api_key', 'security',
|
||||
'template', 'attachment', 'group'
|
||||
];
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize pagination limit
|
||||
*
|
||||
* @param int $limit Requested limit
|
||||
* @return int Validated limit
|
||||
*/
|
||||
private function validateLimit(int $limit): int {
|
||||
if ($limit < 1) {
|
||||
return self::DEFAULT_LIMIT;
|
||||
}
|
||||
return min($limit, self::MAX_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize pagination offset
|
||||
*
|
||||
* @param int $offset Requested offset
|
||||
* @return int Validated offset (non-negative)
|
||||
*/
|
||||
private function validateOffset(int $offset): int {
|
||||
return max(0, $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate date format (YYYY-MM-DD)
|
||||
*
|
||||
* @param string $date Date string
|
||||
* @return string|null Validated date or null if invalid
|
||||
*/
|
||||
private function validateDate(string $date): ?string {
|
||||
// Check format
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify it's a valid date
|
||||
$parts = explode('-', $date);
|
||||
if (!checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate action type
|
||||
*
|
||||
* @param string $actionType Action type to validate
|
||||
* @return bool True if valid
|
||||
*/
|
||||
private function isValidActionType(string $actionType): bool {
|
||||
return in_array($actionType, self::VALID_ACTION_TYPES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate entity type
|
||||
*
|
||||
* @param string $entityType Entity type to validate
|
||||
* @return bool True if valid
|
||||
*/
|
||||
private function isValidEntityType(string $entityType): bool {
|
||||
return in_array($entityType, self::VALID_ENTITY_TYPES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an action to the audit trail
|
||||
*
|
||||
@@ -53,6 +135,8 @@ class AuditLogModel {
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByEntity($entityType, $entityId, $limit = 100) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
@@ -86,6 +170,9 @@ class AuditLogModel {
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByUser($userId, $limit = 100) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$userId = max(0, (int)$userId);
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
@@ -119,6 +206,9 @@ class AuditLogModel {
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getRecentLogs($limit = 50, $offset = 0) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$offset = $this->validateOffset((int)$offset);
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
@@ -151,6 +241,13 @@ class AuditLogModel {
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByAction($actionType, $limit = 100) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
|
||||
// Validate action type to prevent unexpected queries
|
||||
if (!$this->isValidActionType($actionType)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
@@ -370,6 +467,9 @@ class AuditLogModel {
|
||||
* @return array Security events
|
||||
*/
|
||||
public function getSecurityEvents($limit = 100, $offset = 0) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$offset = $this->validateOffset((int)$offset);
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
@@ -435,60 +535,90 @@ class AuditLogModel {
|
||||
* @return array Array containing logs and total count
|
||||
*/
|
||||
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0) {
|
||||
// Validate pagination parameters
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$offset = $this->validateOffset((int)$offset);
|
||||
|
||||
$whereConditions = [];
|
||||
$params = [];
|
||||
$paramTypes = '';
|
||||
|
||||
// Action type filter
|
||||
// Action type filter - validate each action type
|
||||
if (!empty($filters['action_type'])) {
|
||||
$actions = explode(',', $filters['action_type']);
|
||||
$actions = array_filter(
|
||||
array_map('trim', explode(',', $filters['action_type'])),
|
||||
fn($action) => $this->isValidActionType($action)
|
||||
);
|
||||
if (!empty($actions)) {
|
||||
$placeholders = str_repeat('?,', count($actions) - 1) . '?';
|
||||
$whereConditions[] = "al.action_type IN ($placeholders)";
|
||||
$params = array_merge($params, $actions);
|
||||
$params = array_merge($params, array_values($actions));
|
||||
$paramTypes .= str_repeat('s', count($actions));
|
||||
}
|
||||
}
|
||||
|
||||
// Entity type filter
|
||||
// Entity type filter - validate each entity type
|
||||
if (!empty($filters['entity_type'])) {
|
||||
$entities = explode(',', $filters['entity_type']);
|
||||
$entities = array_filter(
|
||||
array_map('trim', explode(',', $filters['entity_type'])),
|
||||
fn($entity) => $this->isValidEntityType($entity)
|
||||
);
|
||||
if (!empty($entities)) {
|
||||
$placeholders = str_repeat('?,', count($entities) - 1) . '?';
|
||||
$whereConditions[] = "al.entity_type IN ($placeholders)";
|
||||
$params = array_merge($params, $entities);
|
||||
$params = array_merge($params, array_values($entities));
|
||||
$paramTypes .= str_repeat('s', count($entities));
|
||||
}
|
||||
}
|
||||
|
||||
// User filter
|
||||
// User filter - validate as positive integer
|
||||
if (!empty($filters['user_id'])) {
|
||||
$userId = (int)$filters['user_id'];
|
||||
if ($userId > 0) {
|
||||
$whereConditions[] = "al.user_id = ?";
|
||||
$params[] = (int)$filters['user_id'];
|
||||
$params[] = $userId;
|
||||
$paramTypes .= 'i';
|
||||
}
|
||||
|
||||
// Entity ID filter (for specific ticket/comment)
|
||||
if (!empty($filters['entity_id'])) {
|
||||
$whereConditions[] = "al.entity_id = ?";
|
||||
$params[] = $filters['entity_id'];
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
|
||||
// Date range filters
|
||||
if (!empty($filters['date_from'])) {
|
||||
$whereConditions[] = "DATE(al.created_at) >= ?";
|
||||
$params[] = $filters['date_from'];
|
||||
// Entity ID filter - sanitize (alphanumeric and dashes only)
|
||||
if (!empty($filters['entity_id'])) {
|
||||
$entityId = preg_replace('/[^a-zA-Z0-9_-]/', '', $filters['entity_id']);
|
||||
if (!empty($entityId)) {
|
||||
$whereConditions[] = "al.entity_id = ?";
|
||||
$params[] = $entityId;
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
|
||||
// Date range filters - validate format
|
||||
if (!empty($filters['date_from'])) {
|
||||
$dateFrom = $this->validateDate($filters['date_from']);
|
||||
if ($dateFrom !== null) {
|
||||
$whereConditions[] = "DATE(al.created_at) >= ?";
|
||||
$params[] = $dateFrom;
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$dateTo = $this->validateDate($filters['date_to']);
|
||||
if ($dateTo !== null) {
|
||||
$whereConditions[] = "DATE(al.created_at) <= ?";
|
||||
$params[] = $filters['date_to'];
|
||||
$params[] = $dateTo;
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
|
||||
// IP address filter
|
||||
// IP address filter - validate format (basic IP pattern)
|
||||
if (!empty($filters['ip_address'])) {
|
||||
// Allow partial IP matching but sanitize input
|
||||
$ipAddress = preg_replace('/[^0-9.:a-fA-F]/', '', $filters['ip_address']);
|
||||
if (!empty($ipAddress) && strlen($ipAddress) <= 45) { // Max IPv6 length
|
||||
$whereConditions[] = "al.ip_address LIKE ?";
|
||||
$params[] = '%' . $filters['ip_address'] . '%';
|
||||
$params[] = '%' . $ipAddress . '%';
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
|
||||
// Build WHERE clause
|
||||
$whereClause = '';
|
||||
|
||||
Reference in New Issue
Block a user