Merge development into main: webhook timeout, comment-edit timeline fix, Bearer rate-limiting overhaul (#77, #80, #81, #82, #83, #87)
- Add connect-timeout to Matrix webhook calls (#77) - Include ticket_id in comment-edit audit log so it appears on the timeline (#87) - Overhaul Bearer API rate limiting: real config, per-key isolation, skip session (#80, #81, #82, #83)
This commit is contained in:
@@ -84,3 +84,9 @@ LDAP_BASE_DN="dc=example,dc=com"
|
|||||||
LDAP_USER_BASE="ou=people,dc=example,dc=com"
|
LDAP_USER_BASE="ou=people,dc=example,dc=com"
|
||||||
; How long to cache avatar images locally (seconds, default 3600)
|
; How long to cache avatar images locally (seconds, default 3600)
|
||||||
AVATAR_CACHE_TTL=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
|
||||||
|
|||||||
@@ -104,7 +104,10 @@ try {
|
|||||||
'update',
|
'update',
|
||||||
'comment',
|
'comment',
|
||||||
(string)$commentId,
|
(string)$commentId,
|
||||||
['comment_text_preview' => substr($commentText, 0, 100)]
|
[
|
||||||
|
'ticket_id' => $comment['ticket_id'] ?? null,
|
||||||
|
'comment_text_preview' => substr($commentText, 0, 100),
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -141,9 +141,9 @@ $GLOBALS['config'] = [
|
|||||||
],
|
],
|
||||||
'UPLOAD_DIR' => __DIR__ . '/../uploads',
|
'UPLOAD_DIR' => __DIR__ . '/../uploads',
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting (requests per minute; read by RateLimitMiddleware)
|
||||||
'RATE_LIMIT_DEFAULT' => 100, // Requests per minute for general
|
'RATE_LIMIT_DEFAULT' => (int)($envVars['RATE_LIMIT_DEFAULT'] ?? 100), // Session-based, general endpoints
|
||||||
'RATE_LIMIT_API' => 60, // Requests per minute for API
|
'RATE_LIMIT_API' => (int)($envVars['RATE_LIMIT_API'] ?? 60), // Session-based, API endpoints
|
||||||
|
|
||||||
// Audit log settings
|
// Audit log settings
|
||||||
'AUDIT_LOG_RETENTION_DAYS' => 90,
|
'AUDIT_LOG_RETENTION_DAYS' => 90,
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ class NotificationHelper
|
|||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
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);
|
$response = curl_exec($ch);
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
|||||||
@@ -3,21 +3,34 @@
|
|||||||
/**
|
/**
|
||||||
* Rate Limiting Middleware
|
* Rate Limiting Middleware
|
||||||
*
|
*
|
||||||
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
* Implements session-based, IP-based, and (for Bearer-authenticated
|
||||||
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
* 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
|
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 DEFAULT_LIMIT = 100; // requests per window (session)
|
||||||
public const API_LIMIT = 60; // API 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_LIMIT = 300; // IP-based requests per window (more generous)
|
||||||
public const IP_API_LIMIT = 120; // IP-based API requests per window
|
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
|
public const WINDOW_SECONDS = 60; // 1 minute window
|
||||||
|
|
||||||
// Directory for IP rate limit storage
|
// Directory for IP rate limit storage
|
||||||
private static ?string $rateLimitDir = null;
|
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
|
* 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 string|null
|
||||||
* @return bool True if request is allowed, false if rate limited
|
|
||||||
*/
|
*/
|
||||||
private static function checkIpRateLimit(string $type = 'default'): bool
|
private static function getBearerToken(): ?string
|
||||||
{
|
{
|
||||||
$ip = self::getClientIp();
|
$header = $_SERVER['HTTP_AUTHORIZATION']
|
||||||
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
||||||
$now = time();
|
?? 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);
|
* Generic file-based sliding-window counter, shared by the IP-based and
|
||||||
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
|
* 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
|
// 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
|
// requests from the same bucket can't both read the same count and each
|
||||||
// count+1 (which would undercount and let the limit be exceeded).
|
// write count+1 (which would undercount and let the limit be exceeded).
|
||||||
$fh = @fopen($filePath, 'c+');
|
$fh = @fopen($filePath, 'c+');
|
||||||
if ($fh === false) {
|
if ($fh === false) {
|
||||||
// Can't open the counter file — fail open (don't block legitimate traffic).
|
// Can't open the counter file — fail open (don't block legitimate traffic).
|
||||||
@@ -122,10 +158,60 @@ class RateLimitMiddleware
|
|||||||
flock($fh, LOCK_UN);
|
flock($fh, LOCK_UN);
|
||||||
fclose($fh);
|
fclose($fh);
|
||||||
|
|
||||||
// Check if over limit
|
|
||||||
return $rateData['count'] <= $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)
|
* 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'
|
* @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
|
||||||
@@ -197,12 +290,17 @@ class RateLimitMiddleware
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$token = self::getBearerToken();
|
||||||
|
if ($token !== null) {
|
||||||
|
return self::checkCounter(self::apiKeyBucketKey($token), self::API_KEY_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
// Then check session-based rate limit
|
// Then check session-based rate limit
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
|
|
||||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
$limit = self::sessionLimit($type);
|
||||||
$key = 'rate_limit_' . $type;
|
$key = 'rate_limit_' . $type;
|
||||||
$now = time();
|
$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'
|
* @param string $type 'default' or 'api'
|
||||||
* @return array Rate limit status
|
* @return array Rate limit status
|
||||||
*/
|
*/
|
||||||
public static function getStatus(string $type = 'default'): array
|
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) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
|
|
||||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
$limit = self::sessionLimit($type);
|
||||||
$key = 'rate_limit_' . $type;
|
$key = 'rate_limit_' . $type;
|
||||||
$now = time();
|
$now = time();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user