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:
@@ -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,59 +535,89 @@ 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']);
|
||||
$placeholders = str_repeat('?,', count($actions) - 1) . '?';
|
||||
$whereConditions[] = "al.action_type IN ($placeholders)";
|
||||
$params = array_merge($params, $actions);
|
||||
$paramTypes .= str_repeat('s', count($actions));
|
||||
$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, 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']);
|
||||
$placeholders = str_repeat('?,', count($entities) - 1) . '?';
|
||||
$whereConditions[] = "al.entity_type IN ($placeholders)";
|
||||
$params = array_merge($params, $entities);
|
||||
$paramTypes .= str_repeat('s', count($entities));
|
||||
$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, array_values($entities));
|
||||
$paramTypes .= str_repeat('s', count($entities));
|
||||
}
|
||||
}
|
||||
|
||||
// User filter
|
||||
// User filter - validate as positive integer
|
||||
if (!empty($filters['user_id'])) {
|
||||
$whereConditions[] = "al.user_id = ?";
|
||||
$params[] = (int)$filters['user_id'];
|
||||
$paramTypes .= 'i';
|
||||
$userId = (int)$filters['user_id'];
|
||||
if ($userId > 0) {
|
||||
$whereConditions[] = "al.user_id = ?";
|
||||
$params[] = $userId;
|
||||
$paramTypes .= 'i';
|
||||
}
|
||||
}
|
||||
|
||||
// Entity ID filter (for specific ticket/comment)
|
||||
// Entity ID filter - sanitize (alphanumeric and dashes only)
|
||||
if (!empty($filters['entity_id'])) {
|
||||
$whereConditions[] = "al.entity_id = ?";
|
||||
$params[] = $filters['entity_id'];
|
||||
$paramTypes .= 's';
|
||||
$entityId = preg_replace('/[^a-zA-Z0-9_-]/', '', $filters['entity_id']);
|
||||
if (!empty($entityId)) {
|
||||
$whereConditions[] = "al.entity_id = ?";
|
||||
$params[] = $entityId;
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
|
||||
// Date range filters
|
||||
// Date range filters - validate format
|
||||
if (!empty($filters['date_from'])) {
|
||||
$whereConditions[] = "DATE(al.created_at) >= ?";
|
||||
$params[] = $filters['date_from'];
|
||||
$paramTypes .= 's';
|
||||
$dateFrom = $this->validateDate($filters['date_from']);
|
||||
if ($dateFrom !== null) {
|
||||
$whereConditions[] = "DATE(al.created_at) >= ?";
|
||||
$params[] = $dateFrom;
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$whereConditions[] = "DATE(al.created_at) <= ?";
|
||||
$params[] = $filters['date_to'];
|
||||
$paramTypes .= 's';
|
||||
$dateTo = $this->validateDate($filters['date_to']);
|
||||
if ($dateTo !== null) {
|
||||
$whereConditions[] = "DATE(al.created_at) <= ?";
|
||||
$params[] = $dateTo;
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
|
||||
// IP address filter
|
||||
// IP address filter - validate format (basic IP pattern)
|
||||
if (!empty($filters['ip_address'])) {
|
||||
$whereConditions[] = "al.ip_address LIKE ?";
|
||||
$params[] = '%' . $filters['ip_address'] . '%';
|
||||
$paramTypes .= 's';
|
||||
// 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[] = '%' . $ipAddress . '%';
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
|
||||
// Build WHERE clause
|
||||
|
||||
Reference in New Issue
Block a user