Security hardening and performance improvements

- Add visibility check to attachment downloads (prevents unauthorized access)
- Fix ticket ID collision with uniqueness verification loop
- Harden CSP: replace unsafe-inline with nonce-based script execution
- Add IP-based rate limiting (supplements session-based)
- Add visibility checks to bulk operations
- Validate internal visibility requires groups
- Optimize user activity query (JOINs vs subqueries)
- Update documentation with design decisions and security info

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 20:27:15 -05:00
parent a08390a500
commit fa40010287
17 changed files with 457 additions and 128 deletions

105
Claude.md
View File

@@ -17,6 +17,24 @@
- Comment Edit/Delete (owner or admin can modify their comments) - Comment Edit/Delete (owner or admin can modify their comments)
- Markdown Tables Support, Auto-linking URLs in Comments - Markdown Tables Support, Auto-linking URLs in Comments
**Security Features** (January 2026):
- CSP with nonce-based script execution (no unsafe-inline)
- IP-based rate limiting (prevents session bypass attacks)
- Visibility checks on attachment downloads
- Unique ticket ID generation with collision prevention
- Internal visibility requires groups validation
## Design Decisions
**Not Planned / Out of Scope**:
- Email integration - Discord webhooks are the notification method for this system
- SLA management - Not required for internal infrastructure use
- Time tracking - Out of scope for current requirements
- OAuth2/External identity providers - Authelia is the only approved SSO method
- GraphQL API - REST API is sufficient for current needs
**Wiki Documentation**: https://wiki.lotusguild.org/en/Services/service-tinker-tickets
## Project Overview ## Project Overview
Tinker Tickets is a feature-rich, self-hosted ticket management system built for managing data center infrastructure issues. It features SSO integration with Authelia/LLDAP, workflow management, Discord notifications, and a retro terminal-style web interface. Tinker Tickets is a feature-rich, self-hosted ticket management system built for managing data center infrastructure issues. It features SSO integration with Authelia/LLDAP, workflow management, Discord notifications, and a retro terminal-style web interface.
@@ -54,6 +72,7 @@ Controllers → Models → Database
│ ├── check_duplicates.php # GET: Check for duplicate tickets │ ├── check_duplicates.php # GET: Check for duplicate tickets
│ ├── delete_attachment.php # POST/DELETE: Delete attachment │ ├── delete_attachment.php # POST/DELETE: Delete attachment
│ ├── delete_comment.php # POST: Delete comment (owner/admin) │ ├── delete_comment.php # POST: Delete comment (owner/admin)
│ ├── download_attachment.php # GET: Download attachment (with visibility check)
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON │ ├── export_tickets.php # GET: Export tickets to CSV/JSON
│ ├── generate_api_key.php # POST: Generate API key (admin) │ ├── generate_api_key.php # POST: Generate API key (admin)
│ ├── get_template.php # GET: Fetch ticket template │ ├── get_template.php # GET: Fetch ticket template
@@ -93,8 +112,8 @@ Controllers → Models → Database
├── middleware/ ├── middleware/
│ ├── AuthMiddleware.php # Authelia SSO integration │ ├── AuthMiddleware.php # Authelia SSO integration
│ ├── CsrfMiddleware.php # CSRF protection │ ├── CsrfMiddleware.php # CSRF protection
│ ├── RateLimitMiddleware.php # API rate limiting │ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
│ └── SecurityHeadersMiddleware.php # Security headers │ └── SecurityHeadersMiddleware.php # CSP with nonces, security headers
├── models/ ├── models/
│ ├── ApiKeyModel.php # API key generation/validation │ ├── ApiKeyModel.php # API key generation/validation
│ ├── AuditLogModel.php # Audit logging + timeline │ ├── AuditLogModel.php # Audit logging + timeline
@@ -152,25 +171,41 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade
### Core Tables ### Core Tables
- `tickets` - Core ticket data with assignment and visibility | Table | Description |
- `ticket_comments` - Markdown-supported comments |-------|-------------|
- `ticket_attachments` - File attachment metadata | `tickets` | Core ticket data with assignment, visibility, and tracking |
- `ticket_dependencies` - Ticket relationships | `ticket_comments` | Markdown-supported comments with user_id reference |
- `users` - User accounts synced from LLDAP (includes groups) | `ticket_attachments` | File attachment metadata |
- `user_preferences` - User settings and preferences | `ticket_dependencies` | Ticket relationships (blocks, blocked_by, relates_to, duplicates) |
- `audit_log` - Complete audit trail | `users` | User accounts synced from LLDAP (includes groups) |
- `status_transitions` - Workflow configuration | `user_preferences` | User settings and preferences |
- `ticket_templates` - Reusable ticket templates | `audit_log` | Complete audit trail with indexed queries |
- `recurring_tickets` - Scheduled ticket definitions | `status_transitions` | Workflow configuration |
- `custom_field_definitions` - Custom field schemas | `ticket_templates` | Reusable ticket templates |
- `custom_field_values` - Custom field data per ticket | `recurring_tickets` | Scheduled ticket definitions |
- `saved_filters` - User-saved dashboard filters | `custom_field_definitions` | Custom field schemas per category |
- `bulk_operations` - Bulk operation tracking | `custom_field_values` | Custom field data per ticket |
- `api_keys` - API key storage with hashes | `saved_filters` | User-saved dashboard filters |
| `bulk_operations` | Bulk operation tracking |
| `api_keys` | API key storage with hashed keys |
### Ticket Visibility Columns ### tickets Table Key Columns
- `visibility` - ENUM('public', 'internal', 'confidential')
- `visibility_groups` - VARCHAR(500) comma-separated group names | Column | Type | Description |
|--------|------|-------------|
| `ticket_id` | varchar(9) | Unique 9-digit identifier |
| `visibility` | enum | 'public', 'internal', 'confidential' |
| `visibility_groups` | varchar(500) | Comma-separated group names (for internal) |
| `created_by` | int | Foreign key to users |
| `assigned_to` | int | Foreign key to users (nullable) |
| `updated_by` | int | Foreign key to users |
| `priority` | int | 1-5 (1=Critical, 5=Minimal) |
| `status` | varchar(20) | Open, Pending, In Progress, Closed |
### Indexed Columns (for performance)
- `tickets`: ticket_id (unique), status, priority, created_at, created_by, assigned_to, visibility
- `audit_log`: user_id, action_type, entity_type, created_at
## Dashboard Features ## Dashboard Features
@@ -187,9 +222,11 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade
## Ticket Visibility Levels ## Ticket Visibility Levels
- **Public**: All authenticated users can view - **Public**: All authenticated users can view
- **Internal**: Only users in specified groups can view - **Internal**: Only users in specified groups can view (groups required)
- **Confidential**: Only creator, assignee, and admins can view - **Confidential**: Only creator, assignee, and admins can view
**Important**: Internal visibility requires at least one group to be specified. Attempting to create/update a ticket with internal visibility but no groups will fail validation.
## Important Notes for AI Assistants ## Important Notes for AI Assistants
1. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`) 1. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
@@ -205,6 +242,9 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade
11. **Session in APIs**: RateLimitMiddleware starts session; don't call session_start() again 11. **Session in APIs**: RateLimitMiddleware starts session; don't call session_start() again
12. **Database collation**: Use `utf8mb4_general_ci` (not unicode_ci) for new tables 12. **Database collation**: Use `utf8mb4_general_ci` (not unicode_ci) for new tables
13. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files 13. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
14. **CSP Nonces**: All inline scripts require `nonce="<?php echo $nonce; ?>"` attribute
15. **Visibility validation**: Internal visibility requires groups; code validates this
16. **Rate limiting**: Both session-based AND IP-based limits are enforced
## File Reference Quick Guide ## File Reference Quick Guide
@@ -212,14 +252,31 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade
|------|---------| |------|---------|
| `index.php` | Main router for all routes | | `index.php` | Main router for all routes |
| `api/update_ticket.php` | Ticket updates with workflow + visibility | | `api/update_ticket.php` | Ticket updates with workflow + visibility |
| `models/TicketModel.php` | Ticket CRUD, visibility filtering | | `api/download_attachment.php` | File downloads with visibility check |
| `api/bulk_operation.php` | Bulk operations with visibility filtering |
| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation |
| `models/ApiKeyModel.php` | API key generation and validation | | `models/ApiKeyModel.php` | API key generation and validation |
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with nonce generation |
| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting |
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions | | `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions |
| `assets/js/ticket.js` | Ticket UI, visibility editing | | `assets/js/ticket.js` | Ticket UI, visibility editing |
| `assets/js/markdown.js` | Markdown parsing + ticket linking | | `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) |
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar | | `assets/css/dashboard.css` | Terminal styling, kanban, sidebar |
## Security Implementations
| Feature | Implementation |
|---------|---------------|
| SQL Injection | All queries use prepared statements with parameter binding |
| XSS Prevention | HTML escaped in markdown parser, CSP with nonces |
| CSRF Protection | Token-based with constant-time comparison |
| Session Security | Fixation prevention, secure cookies, timeout |
| Rate Limiting | Session-based + IP-based (file storage) |
| File Security | Path traversal prevention, MIME validation |
| Visibility | Enforced on views, downloads, and bulk operations |
## Repository ## Repository
- **Gitea**: https://code.lotusguild.org/LotusGuild/tinker_tickets - **Gitea**: https://code.lotusguild.org/LotusGuild/tinker_tickets
- **Production**: http://t.lotusguild.org - **Production**: https://t.lotusguild.org
- **Wiki**: https://wiki.lotusguild.org/en/Services/service-tinker-tickets

View File

@@ -2,6 +2,16 @@
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic. A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
## Design Decisions
The following features are intentionally **not planned** for this system:
- **Email Integration**: Discord webhooks are the chosen notification method
- **SLA Management**: Not required for internal infrastructure use
- **Time Tracking**: Out of scope for current requirements
- **OAuth2/External Identity Providers**: Authelia is the only approved SSO method
## Core Features ## Core Features
### Dashboard & Ticket Management ### Dashboard & Ticket Management
@@ -104,12 +114,14 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `?` | Show keyboard shortcuts help | | `?` | Show keyboard shortcuts help |
### Security Features ### Security Features
- **CSRF Protection**: Token-based protection on all forms - **CSRF Protection**: Token-based protection with constant-time comparison
- **Rate Limiting**: API rate limiting to prevent abuse - **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse
- **Security Headers**: CSP, X-Frame-Options, X-Content-Type-Options - **Security Headers**: CSP with nonces (no unsafe-inline), X-Frame-Options, X-Content-Type-Options
- **SQL Injection Prevention**: All queries use prepared statements - **SQL Injection Prevention**: All queries use prepared statements with parameter binding
- **XSS Protection**: All output properly escaped - **XSS Protection**: HTML escaped in markdown parser, CSP headers block inline scripts
- **Audit Logging**: Complete audit trail of all actions - **Audit Logging**: Complete audit trail of all actions
- **Visibility Enforcement**: Access checks on ticket views, downloads, and bulk operations
- **Collision-Safe IDs**: Ticket IDs verified unique before creation
## Technical Architecture ## Technical Architecture

View File

@@ -55,6 +55,9 @@ foreach ($ticketIds as $ticketId) {
} }
} }
// Create database connection (needed for visibility check)
require_once dirname(__DIR__) . '/models/TicketModel.php';
// Create database connection // Create database connection
$conn = new mysqli( $conn = new mysqli(
$GLOBALS['config']['DB_HOST'], $GLOBALS['config']['DB_HOST'],
@@ -69,6 +72,33 @@ if ($conn->connect_error) {
} }
$bulkOpsModel = new BulkOperationsModel($conn); $bulkOpsModel = new BulkOperationsModel($conn);
$ticketModel = new TicketModel($conn);
// Verify user can access all tickets in the bulk operation
// (Admins can access all, but this is defense-in-depth)
$accessibleTicketIds = [];
$inaccessibleCount = 0;
$tickets = $ticketModel->getTicketsByIds($ticketIds);
foreach ($ticketIds as $ticketId) {
$ticketId = trim($ticketId);
$ticket = $tickets[$ticketId] ?? null;
if ($ticket && $ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
$accessibleTicketIds[] = $ticketId;
} else {
$inaccessibleCount++;
}
}
if (empty($accessibleTicketIds)) {
$conn->close();
echo json_encode(['success' => false, 'error' => 'No accessible tickets in selection']);
exit;
}
// Use only accessible ticket IDs
$ticketIds = $accessibleTicketIds;
// Create bulk operation record // Create bulk operation record
$operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters); $operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters);
@@ -90,11 +120,16 @@ if (isset($result['error'])) {
'error' => $result['error'] 'error' => $result['error']
]); ]);
} else { } else {
$message = "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed";
if ($inaccessibleCount > 0) {
$message .= " ($inaccessibleCount skipped - no access)";
}
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'operation_id' => $operationId, 'operation_id' => $operationId,
'processed' => $result['processed'], 'processed' => $result['processed'],
'failed' => $result['failed'], 'failed' => $result['failed'],
'message' => "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed" 'skipped' => $inaccessibleCount,
'message' => $message
]); ]);
} }

View File

@@ -41,26 +41,42 @@ try {
exit; exit;
} }
// Verify the associated ticket exists (access control) // Verify the associated ticket exists and user has access
$conn = new mysqli( $conn = new mysqli(
$GLOBALS['config']['DB_HOST'], $GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'], $GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'], $GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME'] $GLOBALS['config']['DB_NAME']
); );
if (!$conn->connect_error) { if ($conn->connect_error) {
$ticketModel = new TicketModel($conn); http_response_code(500);
$ticket = $ticketModel->getTicketById($attachment['ticket_id']); header('Content-Type: application/json');
$conn->close(); echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit;
if (!$ticket) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Associated ticket not found']);
exit;
}
} }
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($attachment['ticket_id']);
if (!$ticket) {
$conn->close();
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Associated ticket not found']);
exit;
}
// Check if user has access to this ticket based on visibility settings
if (!$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
$conn->close();
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied to this ticket']);
exit;
}
$conn->close();
// Build file path // Build file path
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads'; $uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename']; $filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];

View File

@@ -151,6 +151,15 @@ try {
if (is_array($visibilityGroups)) { if (is_array($visibilityGroups)) {
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups)); $visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
} }
// Validate internal visibility requires groups
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
return [
'success' => false,
'error' => 'Internal visibility requires at least one group to be specified'
];
}
$this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId); $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
} }

View File

@@ -307,14 +307,45 @@ switch (true) {
'to' => $_GET['date_to'] ?? date('Y-m-d') 'to' => $_GET['date_to'] ?? date('Y-m-d')
]; ];
// Optimized query using LEFT JOINs with aggregated subqueries instead of correlated subqueries
// This eliminates N+1 query pattern and runs much faster with many users
$sql = "SELECT $sql = "SELECT
u.user_id, u.username, u.display_name, u.is_admin, u.user_id, u.username, u.display_name, u.is_admin,
(SELECT COUNT(*) FROM tickets t WHERE t.created_by = u.user_id AND DATE(t.created_at) BETWEEN ? AND ?) as tickets_created, COALESCE(tc.tickets_created, 0) as tickets_created,
(SELECT COUNT(*) FROM tickets t WHERE t.assigned_to = u.user_id AND t.status = 'Closed' AND DATE(t.updated_at) BETWEEN ? AND ?) as tickets_resolved, COALESCE(tr.tickets_resolved, 0) as tickets_resolved,
(SELECT COUNT(*) FROM ticket_comments tc WHERE tc.user_id = u.user_id AND DATE(tc.created_at) BETWEEN ? AND ?) as comments_added, COALESCE(cm.comments_added, 0) as comments_added,
(SELECT COUNT(*) FROM tickets t WHERE t.assigned_to = u.user_id AND DATE(t.created_at) BETWEEN ? AND ?) as tickets_assigned, COALESCE(ta.tickets_assigned, 0) as tickets_assigned,
(SELECT MAX(al.created_at) FROM audit_log al WHERE al.user_id = u.user_id) as last_activity al.last_activity
FROM users u FROM users u
LEFT JOIN (
SELECT created_by, COUNT(*) as tickets_created
FROM tickets
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY created_by
) tc ON u.user_id = tc.created_by
LEFT JOIN (
SELECT assigned_to, COUNT(*) as tickets_resolved
FROM tickets
WHERE status = 'Closed' AND DATE(updated_at) BETWEEN ? AND ?
GROUP BY assigned_to
) tr ON u.user_id = tr.assigned_to
LEFT JOIN (
SELECT user_id, COUNT(*) as comments_added
FROM ticket_comments
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY user_id
) cm ON u.user_id = cm.user_id
LEFT JOIN (
SELECT assigned_to, COUNT(*) as tickets_assigned
FROM tickets
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY assigned_to
) ta ON u.user_id = ta.assigned_to
LEFT JOIN (
SELECT user_id, MAX(created_at) as last_activity
FROM audit_log
GROUP BY user_id
) al ON u.user_id = al.user_id
ORDER BY tickets_created DESC, tickets_resolved DESC"; ORDER BY tickets_created DESC, tickets_resolved DESC";
$stmt = $conn->prepare($sql); $stmt = $conn->prepare($sql);

View File

@@ -2,21 +2,127 @@
/** /**
* Rate Limiting Middleware * Rate Limiting Middleware
* *
* Implements session-based rate limiting to prevent abuse. * Implements both session-based and IP-based rate limiting to prevent abuse.
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
*/ */
class RateLimitMiddleware { class RateLimitMiddleware {
// Default limits // Default limits
const DEFAULT_LIMIT = 100; // requests per window const DEFAULT_LIMIT = 100; // requests per window (session)
const API_LIMIT = 60; // API requests per window const API_LIMIT = 60; // API requests per window (session)
const IP_LIMIT = 300; // IP-based requests per window (more generous)
const IP_API_LIMIT = 120; // IP-based API requests per window
const WINDOW_SECONDS = 60; // 1 minute window const WINDOW_SECONDS = 60; // 1 minute window
// Directory for IP rate limit storage
private static $rateLimitDir = null;
/** /**
* Check rate limit for current request * Get the rate limit storage directory
*
* @return string Path to rate limit storage directory
*/
private static function getRateLimitDir() {
if (self::$rateLimitDir === null) {
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
if (!is_dir(self::$rateLimitDir)) {
mkdir(self::$rateLimitDir, 0755, true);
}
}
return self::$rateLimitDir;
}
/**
* Get the client's IP address
*
* @return string Client IP address
*/
private static function getClientIp() {
// Check for forwarded IP (behind proxy/load balancer)
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
// Take the first IP in a comma-separated list
$ips = explode(',', $_SERVER[$header]);
$ip = trim($ips[0]);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $ip;
}
}
}
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
}
/**
* Check IP-based rate limit
*
* @param string $type 'default' or 'api'
* @return bool True if request is allowed, false if rate limited
*/
private static function checkIpRateLimit($type = 'default') {
$ip = self::getClientIp();
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
$now = time();
// Create a hash of the IP for the filename (security + filesystem safety)
$ipHash = md5($ip . '_' . $type);
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
// Load existing rate data
$rateData = ['count' => 0, 'window_start' => $now];
if (file_exists($filePath)) {
$content = @file_get_contents($filePath);
if ($content !== false) {
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$rateData = $decoded;
}
}
}
// Check if window has expired
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
$rateData = ['count' => 0, 'window_start' => $now];
}
// Increment count
$rateData['count']++;
// Save updated data
@file_put_contents($filePath, json_encode($rateData), LOCK_EX);
// Check if over limit
return $rateData['count'] <= $limit;
}
/**
* Clean up old rate limit files (call periodically)
*/
public static function cleanupOldFiles() {
$dir = self::getRateLimitDir();
$files = glob($dir . '/*.json');
$now = time();
$maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows
foreach ($files as $file) {
if ($now - filemtime($file) > $maxAge) {
@unlink($file);
}
}
}
/**
* Check rate limit for current request (both session and IP)
* *
* @param string $type 'default' or 'api' * @param string $type 'default' or 'api'
* @return bool True if request is allowed, false if rate limited * @return bool True if request is allowed, false if rate limited
*/ */
public static function check($type = 'default') { public static function check($type = 'default') {
// First check IP-based rate limit (prevents session bypass)
if (!self::checkIpRateLimit($type)) {
return false;
}
// Then check session-based rate limit
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
session_start(); session_start();
} }
@@ -59,6 +165,11 @@ class RateLimitMiddleware {
* @param string $type 'default' or 'api' * @param string $type 'default' or 'api'
*/ */
public static function apply($type = 'default') { public static function apply($type = 'default') {
// Periodically clean up old rate limit files (1% chance per request)
if (mt_rand(1, 100) === 1) {
self::cleanupOldFiles();
}
if (!self::check($type)) { if (!self::check($type)) {
http_response_code(429); http_response_code(429);
header('Content-Type: application/json'); header('Content-Type: application/json');

View File

@@ -5,12 +5,29 @@
* Applies security-related HTTP headers to all responses. * Applies security-related HTTP headers to all responses.
*/ */
class SecurityHeadersMiddleware { class SecurityHeadersMiddleware {
private static $nonce = null;
/**
* Generate or retrieve the CSP nonce for this request
*
* @return string The nonce value
*/
public static function getNonce() {
if (self::$nonce === null) {
self::$nonce = base64_encode(random_bytes(16));
}
return self::$nonce;
}
/** /**
* Apply security headers to the response * Apply security headers to the response
*/ */
public static function apply() { public static function apply() {
$nonce = self::getNonce();
// Content Security Policy - restricts where resources can be loaded from // Content Security Policy - restricts where resources can be loaded from
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';"); // Using nonce for inline scripts instead of unsafe-inline for better security
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';");
// Prevent clickjacking by disallowing framing // Prevent clickjacking by disallowing framing
header("X-Frame-Options: DENY"); header("X-Frame-Options: DENY");

View File

@@ -258,8 +258,35 @@ class TicketModel {
} }
public function createTicket($ticketData, $createdBy = null) { public function createTicket($ticketData, $createdBy = null) {
// Generate ticket ID (9-digit format with leading zeros) // Generate unique ticket ID (9-digit format with leading zeros)
$ticket_id = sprintf('%09d', mt_rand(1, 999999999)); // Loop until we find an ID that doesn't exist to prevent collisions
$maxAttempts = 10;
$attempts = 0;
$ticket_id = null;
do {
$candidate_id = sprintf('%09d', mt_rand(100000000, 999999999));
// Check if this ID already exists
$checkSql = "SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1";
$checkStmt = $this->conn->prepare($checkSql);
$checkStmt->bind_param("s", $candidate_id);
$checkStmt->execute();
$checkResult = $checkStmt->get_result();
if ($checkResult->num_rows === 0) {
$ticket_id = $candidate_id;
}
$checkStmt->close();
$attempts++;
} while ($ticket_id === null && $attempts < $maxAttempts);
if ($ticket_id === null) {
return [
'success' => false,
'error' => 'Failed to generate unique ticket ID after ' . $maxAttempts . ' attempts'
];
}
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by, visibility, visibility_groups) $sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by, visibility, visibility_groups)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
@@ -280,8 +307,16 @@ class TicketModel {
$visibility = 'public'; $visibility = 'public';
} }
// Clear visibility_groups if not internal // Validate internal visibility requires groups
if ($visibility !== 'internal') { if ($visibility === 'internal') {
if (empty($visibilityGroups) || trim($visibilityGroups) === '') {
return [
'success' => false,
'error' => 'Internal visibility requires at least one group to be specified'
];
}
} else {
// Clear visibility_groups if not internal
$visibilityGroups = null; $visibilityGroups = null;
} }
@@ -529,8 +564,13 @@ class TicketModel {
$visibility = 'public'; $visibility = 'public';
} }
// Clear visibility_groups if not internal // Validate internal visibility requires groups
if ($visibility !== 'internal') { if ($visibility === 'internal') {
if (empty($visibilityGroups) || trim($visibilityGroups) === '') {
return false; // Internal visibility requires groups
}
} else {
// Clear visibility_groups if not internal
$visibilityGroups = null; $visibilityGroups = null;
} }

View File

@@ -1,5 +1,8 @@
<?php <?php
// This file contains the HTML template for creating a new ticket // This file contains the HTML template for creating a new ticket
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -10,13 +13,10 @@
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e">
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
<script> <script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests // CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
</script> </script>
</head> </head>
<body> <body>
@@ -239,7 +239,7 @@
</div> </div>
<!-- END OUTER FRAME --> <!-- END OUTER FRAME -->
<script> <script nonce="<?php echo $nonce; ?>">
// Duplicate detection with debounce // Duplicate detection with debounce
let duplicateCheckTimeout = null; let duplicateCheckTimeout = null;

View File

@@ -1,6 +1,9 @@
<?php <?php
// This file contains the HTML template for the dashboard // This file contains the HTML template for the dashboard
// It receives $tickets, $totalTickets, $totalPages, $page, $status, $categories, $types variables from the controller // It receives $tickets, $totalTickets, $totalPages, $page, $status, $categories, $types variables from the controller
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -10,16 +13,13 @@
<title>Ticket Dashboard</title> <title>Ticket Dashboard</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c">
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260124e"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260124e"></script>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
<script> <script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests // CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
// Timezone configuration (from server) // Timezone configuration (from server)
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>'; window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
@@ -32,7 +32,7 @@
<div id="boot-sequence" class="boot-overlay"> <div id="boot-sequence" class="boot-overlay">
<pre id="boot-text"></pre> <pre id="boot-text"></pre>
</div> </div>
<script> <script nonce="<?php echo $nonce; ?>">
function showBootSequence() { function showBootSequence() {
const bootText = document.getElementById('boot-text'); const bootText = document.getElementById('boot-text');
const bootOverlay = document.getElementById('boot-sequence'); const bootOverlay = document.getElementById('boot-sequence');
@@ -108,7 +108,7 @@
</button> </button>
<div id="ascii-banner-container" class="banner-content"></div> <div id="ascii-banner-container" class="banner-content"></div>
</div> </div>
<script> <script nonce="<?php echo $nonce; ?>">
function toggleBanner() { function toggleBanner() {
const wrapper = document.querySelector('.ascii-banner-wrapper'); const wrapper = document.querySelector('.ascii-banner-wrapper');
const icon = document.querySelector('.toggle-icon'); const icon = document.querySelector('.toggle-icon');
@@ -764,10 +764,10 @@
</div> </div>
</div> </div>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js"></script>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js"></script>
<script> <script nonce="<?php echo $nonce; ?>">
// Admin dropdown toggle // Admin dropdown toggle
function toggleAdminMenu(event) { function toggleAdminMenu(event) {
event.stopPropagation(); event.stopPropagation();

View File

@@ -38,6 +38,10 @@ function formatDetails($details, $actionType) {
} }
return ''; return '';
} }
<?php
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -48,22 +52,19 @@ function formatDetails($details, $actionType) {
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e">
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260124e"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260124e"></script>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260124e"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260124e"></script>
<script> <script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests // CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
// Timezone configuration (from server) // Timezone configuration (from server)
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>'; window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>'; window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>';
</script> </script>
<script> <script nonce="<?php echo $nonce; ?>">
// Store ticket data in a global variable (using json_encode for XSS safety) // Store ticket data in a global variable (using json_encode for XSS safety)
window.ticketData = { window.ticketData = {
ticket_id: <?php echo json_encode($ticket['ticket_id']); ?>, ticket_id: <?php echo json_encode($ticket['ticket_id']); ?>,
@@ -445,7 +446,7 @@ function formatDetails($details, $actionType) {
</div> </div>
</div> </div>
<!-- END OUTER FRAME --> <!-- END OUTER FRAME -->
<script> <script nonce="<?php echo $nonce; ?>">
// Initialize the ticket view // Initialize the ticket view
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (typeof showTab === 'function') { if (typeof showTab === 'function') {
@@ -455,7 +456,7 @@ function formatDetails($details, $actionType) {
} }
}); });
</script> </script>
<script> <script nonce="<?php echo $nonce; ?>">
// Ticket data already initialized in head, add id alias for compatibility // Ticket data already initialized in head, add id alias for compatibility
window.ticketData.id = window.ticketData.ticket_id; window.ticketData.id = window.ticketData.ticket_id;
console.log('Ticket data loaded:', window.ticketData); console.log('Ticket data loaded:', window.ticketData);
@@ -597,6 +598,6 @@ function formatDetails($details, $actionType) {
</div> </div>
</div> </div>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,6 +1,9 @@
<?php <?php
// Admin view for managing API keys // Admin view for managing API keys
// Receives $apiKeys from controller // Receives $apiKeys from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -11,12 +14,9 @@
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
</script> </script>
</head> </head>
<body> <body>
@@ -161,7 +161,7 @@
</div> </div>
</div> </div>
<script> <script nonce="<?php echo $nonce; ?>">
document.getElementById('generateKeyForm').addEventListener('submit', async function(e) { document.getElementById('generateKeyForm').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();

View File

@@ -1,6 +1,9 @@
<?php <?php
// Admin view for managing custom fields // Admin view for managing custom fields
// Receives $customFields from controller // Receives $customFields from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -11,11 +14,8 @@
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
</script> </script>
</head> </head>
<body> <body>
@@ -154,8 +154,8 @@
</div> </div>
</div> </div>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script> <script nonce="<?php echo $nonce; ?>">
function showCreateModal() { function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Custom Field'; document.getElementById('modalTitle').textContent = 'Create Custom Field';
document.getElementById('fieldForm').reset(); document.getElementById('fieldForm').reset();

View File

@@ -1,6 +1,9 @@
<?php <?php
// Admin view for managing recurring tickets // Admin view for managing recurring tickets
// Receives $recurringTickets from controller // Receives $recurringTickets from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -11,11 +14,8 @@
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
</script> </script>
</head> </head>
<body> <body>
@@ -189,8 +189,8 @@
</div> </div>
</div> </div>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script> <script nonce="<?php echo $nonce; ?>">
function showCreateModal() { function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket'; document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
document.getElementById('recurringForm').reset(); document.getElementById('recurringForm').reset();

View File

@@ -1,6 +1,9 @@
<?php <?php
// Admin view for managing ticket templates // Admin view for managing ticket templates
// Receives $templates from controller // Receives $templates from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -11,11 +14,8 @@
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
</script> </script>
</head> </head>
<body> <body>
@@ -160,8 +160,8 @@
</div> </div>
</div> </div>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script> <script nonce="<?php echo $nonce; ?>">
const templates = <?php echo json_encode($templates ?? []); ?>; const templates = <?php echo json_encode($templates ?? []); ?>;
function showCreateModal() { function showCreateModal() {

View File

@@ -1,6 +1,9 @@
<?php <?php
// Admin view for workflow/status transitions designer // Admin view for workflow/status transitions designer
// Receives $workflows from controller // Receives $workflows from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -11,11 +14,8 @@
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
</script> </script>
</head> </head>
<body> <body>
@@ -177,8 +177,8 @@
</div> </div>
</div> </div>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script> <script nonce="<?php echo $nonce; ?>">
const workflows = <?php echo json_encode($workflows ?? []); ?>; const workflows = <?php echo json_encode($workflows ?? []); ?>;
function showCreateModal() { function showCreateModal() {