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:
2026-01-30 18:51:16 -05:00
parent 44f2c21f2d
commit 5b2a2c271e
8 changed files with 528 additions and 42 deletions

View File

@@ -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