Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa8173941a | ||
|
|
1b1801696f | ||
|
|
09cea2b388 | ||
|
|
9702aafacd | ||
|
|
66bf82bf46 | ||
|
|
fca0b42726 | ||
|
|
ae12fcd6fd | ||
|
|
5b96e75ff6 | ||
|
|
3db3749c46 | ||
|
|
9e83f8903a |
@@ -84,3 +84,9 @@ LDAP_BASE_DN="dc=example,dc=com"
|
||||
LDAP_USER_BASE="ou=people,dc=example,dc=com"
|
||||
; How long to cache avatar images locally (seconds, default 3600)
|
||||
AVATAR_CACHE_TTL=3600
|
||||
|
||||
; Session-based rate limits (requests per 60s window). These govern
|
||||
; browser/session traffic on general and API endpoints respectively;
|
||||
; Bearer-key API traffic is rate-limited separately, per API key.
|
||||
RATE_LIMIT_DEFAULT=100
|
||||
RATE_LIMIT_API=60
|
||||
|
||||
+40
-1
@@ -15,8 +15,10 @@
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
$prefsModel = new UserPreferencesModel($conn);
|
||||
$ticketModel = new TicketModel($conn);
|
||||
|
||||
// ── POST: mark all read (update last_seen timestamp) ──────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
@@ -204,7 +206,44 @@ foreach (array_merge($assignRows, $commentRows, $statusRows, $mentionRows) as $r
|
||||
$all[] = $row;
|
||||
}
|
||||
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
|
||||
$all = array_slice($all, 0, 30);
|
||||
|
||||
// Re-check current ticket visibility before surfacing anything: a
|
||||
// notification's audit_log entry reflects historical activity, but the
|
||||
// ticket's visibility (or the user's group/watcher standing) may have
|
||||
// tightened since. Without this, a notification still discloses the
|
||||
// ticket's title and that activity occurred to someone who currently
|
||||
// shouldn't see it, even though the ticket view's own access check would
|
||||
// correctly reject them from opening it.
|
||||
$candidateTicketIds = [];
|
||||
foreach ($all as $row) {
|
||||
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||
? 'comment'
|
||||
: $row['action_type'];
|
||||
$tid = ($actionType === 'comment' || $actionType === 'mention')
|
||||
? ($details['ticket_id'] ?? 0)
|
||||
: $row['entity_id'];
|
||||
if ($tid) {
|
||||
$candidateTicketIds[(string)$tid] = true;
|
||||
}
|
||||
}
|
||||
$ticketsById = !empty($candidateTicketIds)
|
||||
? $ticketModel->getTicketsByIds(array_keys($candidateTicketIds))
|
||||
: [];
|
||||
|
||||
$all = array_filter($all, function ($row) use ($ticketsById, $currentUser, $ticketModel) {
|
||||
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||
? 'comment'
|
||||
: $row['action_type'];
|
||||
$tid = (string)(($actionType === 'comment' || $actionType === 'mention')
|
||||
? ($details['ticket_id'] ?? 0)
|
||||
: $row['entity_id']);
|
||||
$ticket = $ticketsById[$tid] ?? null;
|
||||
return $ticket && $ticketModel->canUserAccessTicket($ticket, $currentUser);
|
||||
});
|
||||
|
||||
$all = array_slice(array_values($all), 0, 30);
|
||||
|
||||
// Format for response
|
||||
$notifications = [];
|
||||
|
||||
@@ -104,7 +104,10 @@ try {
|
||||
'update',
|
||||
'comment',
|
||||
(string)$commentId,
|
||||
['comment_text_preview' => substr($commentText, 0, 100)]
|
||||
[
|
||||
'ticket_id' => $comment['ticket_id'] ?? null,
|
||||
'comment_text_preview' => substr($commentText, 0, 100),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -2801,7 +2801,7 @@
|
||||
};
|
||||
|
||||
// Patch lt.api — auth-aware wrapper (renamed to avoid strict-mode duplicate declaration)
|
||||
async function _apiFetchAuth(method, url, body) {
|
||||
async function _apiFetchAuth(method, url, body, retried) {
|
||||
if (_authAccess && auth.isExpiringSoon()) await auth.refresh();
|
||||
const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) };
|
||||
if (_authAccess) opts.headers['Authorization'] = 'Bearer ' + _authAccess;
|
||||
@@ -2821,6 +2821,15 @@
|
||||
// Resync CSRF token from any response body that carries a fresh one
|
||||
// (bootstrap rotates on success and returns the current token on rejection).
|
||||
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
|
||||
// Auto-retry once on a stale-CSRF-token 403: the token lifetime (1h) is
|
||||
// shorter than the session idle timeout (5h), so this is a routine,
|
||||
// recoverable case (an hour of inactivity, or a write in another tab
|
||||
// rotating the shared token) rather than a real rejection — resyncing
|
||||
// above already has the fresh token, so silently resending once succeeds
|
||||
// transparently instead of surfacing a confusing error on the first try.
|
||||
if (resp.status === 403 && !retried && data && data.csrf_token) {
|
||||
return _apiFetchAuth(method, url, body, true);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
|
||||
err.data = data;
|
||||
|
||||
+3
-3
@@ -141,9 +141,9 @@ $GLOBALS['config'] = [
|
||||
],
|
||||
'UPLOAD_DIR' => __DIR__ . '/../uploads',
|
||||
|
||||
// Rate limiting
|
||||
'RATE_LIMIT_DEFAULT' => 100, // Requests per minute for general
|
||||
'RATE_LIMIT_API' => 60, // Requests per minute for API
|
||||
// Rate limiting (requests per minute; read by RateLimitMiddleware)
|
||||
'RATE_LIMIT_DEFAULT' => (int)($envVars['RATE_LIMIT_DEFAULT'] ?? 100), // Session-based, general endpoints
|
||||
'RATE_LIMIT_API' => (int)($envVars['RATE_LIMIT_API'] ?? 60), // Session-based, API endpoints
|
||||
|
||||
// Audit log settings
|
||||
'AUDIT_LOG_RETENTION_DAYS' => 90,
|
||||
|
||||
@@ -20,6 +20,12 @@ class NotificationHelper
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
// A slow-but-not-fully-hung hookshot endpoint could otherwise add up
|
||||
// to the full CURLOPT_TIMEOUT per fire() call, and a single request
|
||||
// can call fire() (via notifyWatchers/sendCommentNotification/etc.)
|
||||
// more than once sequentially — capping just the connect phase keeps
|
||||
// that from stacking into tens of seconds of added latency.
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
@@ -3,21 +3,34 @@
|
||||
/**
|
||||
* Rate Limiting Middleware
|
||||
*
|
||||
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
||||
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
||||
* Implements session-based, IP-based, and (for Bearer-authenticated
|
||||
* requests) API-key-based rate limiting to prevent abuse.
|
||||
* IP-based limiting prevents attackers from bypassing limits by creating new
|
||||
* sessions; API-key-based limiting keeps distinct Bearer clients from
|
||||
* starving each other's shared IP bucket.
|
||||
*/
|
||||
class RateLimitMiddleware
|
||||
{
|
||||
// Default limits
|
||||
// Fallback limits, used only if $GLOBALS['config'] isn't populated
|
||||
// (e.g. very early in bootstrap, or a test harness). Normal requests read
|
||||
// RATE_LIMIT_DEFAULT/RATE_LIMIT_API from config (backed by .env).
|
||||
public const DEFAULT_LIMIT = 100; // requests per window (session)
|
||||
public const API_LIMIT = 60; // API requests per window (session)
|
||||
public const IP_LIMIT = 300; // IP-based requests per window (more generous)
|
||||
public const IP_API_LIMIT = 120; // IP-based API requests per window
|
||||
public const API_KEY_LIMIT = 120; // Per-Bearer-token requests per window
|
||||
public const WINDOW_SECONDS = 60; // 1 minute window
|
||||
|
||||
// Directory for IP rate limit storage
|
||||
private static ?string $rateLimitDir = null;
|
||||
|
||||
private static function sessionLimit(string $type): int
|
||||
{
|
||||
$configKey = $type === 'api' ? 'RATE_LIMIT_API' : 'RATE_LIMIT_DEFAULT';
|
||||
$fallback = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||
return (int)($GLOBALS['config'][$configKey] ?? $fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limit storage directory
|
||||
*
|
||||
@@ -69,24 +82,47 @@ class RateLimitMiddleware
|
||||
}
|
||||
|
||||
/**
|
||||
* Check IP-based rate limit
|
||||
* Extract the raw Bearer token from the Authorization header, if present.
|
||||
* Deliberately independent of ApiKeyAuth: rate limiting must be cheap and
|
||||
* must not require a DB round-trip to validate the key before counting
|
||||
* the request, and needs to run whether or not the token turns out to be
|
||||
* valid. The raw token string (not the validated api_key_id) is hashed as
|
||||
* the bucket identifier — good enough to isolate distinct keys/clients
|
||||
* from each other without needing to authenticate first.
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
* @return string|null
|
||||
*/
|
||||
private static function checkIpRateLimit(string $type = 'default'): bool
|
||||
private static function getBearerToken(): ?string
|
||||
{
|
||||
$ip = self::getClientIp();
|
||||
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
||||
$now = time();
|
||||
$header = $_SERVER['HTTP_AUTHORIZATION']
|
||||
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
||||
?? null;
|
||||
if ($header === null && function_exists('getallheaders')) {
|
||||
$headers = getallheaders();
|
||||
$header = $headers['Authorization'] ?? null;
|
||||
}
|
||||
if ($header && preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a hash of the IP for the filename (security + filesystem safety)
|
||||
$ipHash = hash('sha256', $ip . '_' . $type);
|
||||
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
|
||||
/**
|
||||
* Generic file-based sliding-window counter, shared by the IP-based and
|
||||
* API-key-based buckets below.
|
||||
*
|
||||
* @param string $bucketKey Stable identifier for this bucket (already hashed)
|
||||
* @param int $limit Max requests allowed per window
|
||||
* @return bool True if this request is within the limit
|
||||
*/
|
||||
private static function checkCounter(string $bucketKey, int $limit): bool
|
||||
{
|
||||
$now = time();
|
||||
$filePath = self::getRateLimitDir() . '/' . $bucketKey . '.json';
|
||||
|
||||
// Hold an exclusive lock across the whole read-modify-write so concurrent
|
||||
// requests from the same IP can't both read the same count and each write
|
||||
// count+1 (which would undercount and let the limit be exceeded).
|
||||
// requests from the same bucket can't both read the same count and each
|
||||
// write count+1 (which would undercount and let the limit be exceeded).
|
||||
$fh = @fopen($filePath, 'c+');
|
||||
if ($fh === false) {
|
||||
// Can't open the counter file — fail open (don't block legitimate traffic).
|
||||
@@ -122,10 +158,60 @@ class RateLimitMiddleware
|
||||
flock($fh, LOCK_UN);
|
||||
fclose($fh);
|
||||
|
||||
// Check if over limit
|
||||
return $rateData['count'] <= $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read (without incrementing) the current state of a counter bucket, for
|
||||
* status/header reporting.
|
||||
*/
|
||||
private static function peekCounter(string $bucketKey, int $limit): array
|
||||
{
|
||||
$now = time();
|
||||
$filePath = self::getRateLimitDir() . '/' . $bucketKey . '.json';
|
||||
|
||||
$rateData = null;
|
||||
$content = @file_get_contents($filePath);
|
||||
if ($content !== false && $content !== '') {
|
||||
$decoded = json_decode($content, true);
|
||||
if (is_array($decoded)) {
|
||||
$rateData = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if ($rateData === null || $now - ($rateData['window_start'] ?? $now) >= self::WINDOW_SECONDS) {
|
||||
return ['limit' => $limit, 'remaining' => $limit, 'reset' => $now + self::WINDOW_SECONDS];
|
||||
}
|
||||
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => max(0, $limit - $rateData['count']),
|
||||
'reset' => $rateData['window_start'] + self::WINDOW_SECONDS,
|
||||
];
|
||||
}
|
||||
|
||||
private static function ipBucketKey(string $type): string
|
||||
{
|
||||
return hash('sha256', self::getClientIp() . '_' . $type);
|
||||
}
|
||||
|
||||
private static function apiKeyBucketKey(string $token): string
|
||||
{
|
||||
return hash('sha256', 'apikey_' . $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(string $type = 'default'): bool
|
||||
{
|
||||
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
||||
return self::checkCounter(self::ipBucketKey($type), $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old rate limit files (call periodically)
|
||||
*
|
||||
@@ -185,7 +271,14 @@ class RateLimitMiddleware
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for current request (both session and IP)
|
||||
* Check rate limit for current request.
|
||||
*
|
||||
* Bearer-authenticated requests (Authorization: Bearer ...) are limited
|
||||
* by a per-token bucket instead of a session — a stateless API client
|
||||
* never sends a session cookie back, so the session-based counter never
|
||||
* accumulates and starting a session for it is pure overhead. The
|
||||
* IP-based bucket still applies underneath as defense-in-depth against
|
||||
* volumetric abuse from one network path.
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
@@ -197,12 +290,17 @@ class RateLimitMiddleware
|
||||
return false;
|
||||
}
|
||||
|
||||
$token = self::getBearerToken();
|
||||
if ($token !== null) {
|
||||
return self::checkCounter(self::apiKeyBucketKey($token), self::API_KEY_LIMIT);
|
||||
}
|
||||
|
||||
// Then check session-based rate limit
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||
$limit = self::sessionLimit($type);
|
||||
$key = 'rate_limit_' . $type;
|
||||
$now = time();
|
||||
|
||||
@@ -270,18 +368,28 @@ class RateLimitMiddleware
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit status
|
||||
* Get current rate limit status.
|
||||
*
|
||||
* For a Bearer-authenticated request, reports the per-API-key bucket
|
||||
* (the one that actually governs it) rather than the session-based
|
||||
* counter, which is meaningless for a client that never sends a session
|
||||
* cookie back.
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return array Rate limit status
|
||||
*/
|
||||
public static function getStatus(string $type = 'default'): array
|
||||
{
|
||||
$token = self::getBearerToken();
|
||||
if ($token !== null) {
|
||||
return self::peekCounter(self::apiKeyBucketKey($token), self::API_KEY_LIMIT);
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||
$limit = self::sessionLimit($type);
|
||||
$key = 'rate_limit_' . $type;
|
||||
$now = time();
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ class CustomFieldModel
|
||||
{
|
||||
private $conn;
|
||||
|
||||
// Must match custom_field_definitions.field_type's enum() in the schema.
|
||||
private const ALLOWED_FIELD_TYPES = ['text', 'textarea', 'select', 'checkbox', 'date', 'number'];
|
||||
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
@@ -87,6 +90,10 @@ class CustomFieldModel
|
||||
*/
|
||||
public function createDefinition($data)
|
||||
{
|
||||
if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) {
|
||||
return ['success' => false, 'error' => 'Invalid field_type'];
|
||||
}
|
||||
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
@@ -129,6 +136,10 @@ class CustomFieldModel
|
||||
*/
|
||||
public function updateDefinition($fieldId, $data)
|
||||
{
|
||||
if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) {
|
||||
return ['success' => false, 'error' => 'Invalid field_type'];
|
||||
}
|
||||
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
|
||||
@@ -773,9 +773,68 @@ class TicketModel
|
||||
$stmt->bind_param("ssis", $visibility, $visibilityGroups, $updatedBy, $ticketId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
if ($result) {
|
||||
$this->pruneWatchersForVisibility($ticketId, $visibility, $visibilityGroups);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any watchers who no longer qualify for a ticket's access rules
|
||||
* after its visibility was tightened. Without this, a user watching a
|
||||
* ticket that's later made confidential/internal (and who isn't
|
||||
* creator/assignee/admin/in the new visibility_groups) keeps receiving
|
||||
* Matrix notifications about a ticket canUserAccessTicket() would now
|
||||
* reject them from opening directly.
|
||||
*/
|
||||
private function pruneWatchersForVisibility(string $ticketId, string $visibility, ?string $visibilityGroups): void
|
||||
{
|
||||
$ticket = $this->getTicketById($ticketId);
|
||||
if (!$ticket) {
|
||||
return;
|
||||
}
|
||||
// getTicketById() reflects the just-committed UPDATE, but set these
|
||||
// explicitly so pruning is correct even if a caller reorders things.
|
||||
$ticket['visibility'] = $visibility;
|
||||
$ticket['visibility_groups'] = $visibilityGroups;
|
||||
|
||||
$sql = "SELECT tw.user_id, u.is_admin, u.`groups`
|
||||
FROM ticket_watchers tw
|
||||
JOIN users u ON tw.user_id = u.user_id
|
||||
WHERE tw.ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('s', $ticketId);
|
||||
$stmt->execute();
|
||||
$watchers = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||
$stmt->close();
|
||||
|
||||
$toRemove = [];
|
||||
foreach ($watchers as $watcher) {
|
||||
$watcherUser = [
|
||||
'user_id' => $watcher['user_id'],
|
||||
'is_admin' => $watcher['is_admin'],
|
||||
'groups' => $watcher['groups'],
|
||||
];
|
||||
if (!$this->canUserAccessTicket($ticket, $watcherUser)) {
|
||||
$toRemove[] = $watcher['user_id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($toRemove)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($toRemove), '?'));
|
||||
$delSql = "DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id IN ($placeholders)";
|
||||
$delStmt = $this->conn->prepare($delSql);
|
||||
$types = 's' . str_repeat('i', count($toRemove));
|
||||
$delStmt->bind_param($types, $ticketId, ...$toRemove);
|
||||
$delStmt->execute();
|
||||
$delStmt->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a ticket and all its associated records.
|
||||
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
|
||||
|
||||
@@ -235,8 +235,7 @@
|
||||
}
|
||||
|
||||
function loadNotifications() {
|
||||
return fetch('/api/notifications.php', { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
return lt.api.get('/api/notifications.php')
|
||||
.then(function(data) { renderNotifications(data); return true; })
|
||||
.catch(function() {
|
||||
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
|
||||
@@ -251,11 +250,7 @@
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', function() {
|
||||
fetch('/api/notifications.php', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
|
||||
body: JSON.stringify({ action: 'mark_read' })
|
||||
}).then(loadNotifications);
|
||||
lt.api.post('/api/notifications.php', { action: 'mark_read' }).then(loadNotifications);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user