Security / PHP Security (semgrep) (push) Successful in 1m45s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (push) Successful in 21s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 26s
- SynapseHelper: memoize username->Matrix-ID lookups per-request (incl. negative
results) and add an overall time budget to resolveUsernames() plus a 2s connect
timeout, so notifying N watchers with a slow/unreachable Synapse can't stall the
request for N x 5s. (Chosen over async/queue per maintainer.)
- DependencyModel: fix cycle detection treating 'blocks' and 'blocked_by' as the
same edge direction. They are inverse relationships (single row each, no mirror
row), so the traversal now walks a unified precedence graph (blocks: ticket->
depends_on; blocked_by: depends_on->ticket) and wouldCreateCycle normalizes the
new edge's direction. Prevents both false-positive and missed cycles.
- CacheHelper: anchor prefix-delete to exact key boundaries (bare prefix or
prefix + '_' + md5) so delete('workflow') can't wipe a 'workflow_rules' cache.
- RateLimitMiddleware: hold an exclusive flock across the per-IP counter's
read-modify-write so concurrent requests can't both read N and write N+1
(undercounting past the limit). Fails open if the file can't be locked.
- dashboard.js: kanban status update now uses lt.api.post (per no-raw-fetch
convention) and reverts the card AND the optimistic column counts on failure
(the old raw-fetch catch left the card moved without reverting).
- BulkOperationsModel: document that bulk_status/bulk_close intentionally bypass
workflow transition validation (admin override, by design).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
327 lines
10 KiB
PHP
327 lines
10 KiB
PHP
<?php
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
class RateLimitMiddleware
|
|
{
|
|
// Default limits
|
|
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 WINDOW_SECONDS = 60; // 1 minute window
|
|
|
|
// Directory for IP rate limit storage
|
|
private static ?string $rateLimitDir = null;
|
|
|
|
/**
|
|
* Get the rate limit storage directory
|
|
*
|
|
* @return string Path to rate limit storage directory
|
|
*/
|
|
private static function getRateLimitDir(): string
|
|
{
|
|
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(): string
|
|
{
|
|
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
|
|
|
// Forwarded headers are client-controlled, so only believe them when the
|
|
// request actually came from a trusted reverse proxy. Otherwise a client
|
|
// could rotate X-Forwarded-For each request to escape the per-IP limit.
|
|
$trusted = $GLOBALS['config']['TRUSTED_PROXIES'] ?? [];
|
|
if (empty($trusted) || !in_array($remoteAddr, $trusted, true)) {
|
|
return $remoteAddr;
|
|
}
|
|
|
|
// The trusted proxy appends the connecting client to X-Forwarded-For, so
|
|
// the RIGHTMOST entry is the IP it observed (a client-supplied prefix is
|
|
// not trustworthy). X-Real-IP is set by the proxy itself.
|
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
|
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
|
$ip = trim(end($ips));
|
|
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
|
return $ip;
|
|
}
|
|
}
|
|
if (!empty($_SERVER['HTTP_X_REAL_IP']) && filter_var($_SERVER['HTTP_X_REAL_IP'], FILTER_VALIDATE_IP)) {
|
|
return trim($_SERVER['HTTP_X_REAL_IP']);
|
|
}
|
|
|
|
return $remoteAddr;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$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 = hash('sha256', $ip . '_' . $type);
|
|
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.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).
|
|
$fh = @fopen($filePath, 'c+');
|
|
if ($fh === false) {
|
|
// Can't open the counter file — fail open (don't block legitimate traffic).
|
|
return true;
|
|
}
|
|
|
|
if (!flock($fh, LOCK_EX)) {
|
|
fclose($fh);
|
|
return true;
|
|
}
|
|
|
|
$content = stream_get_contents($fh);
|
|
$rateData = ['count' => 0, 'window_start' => $now];
|
|
if ($content !== false && $content !== '') {
|
|
$decoded = json_decode($content, true);
|
|
if (is_array($decoded)) {
|
|
$rateData = $decoded;
|
|
}
|
|
}
|
|
|
|
// Reset when the window has expired
|
|
if ($now - ($rateData['window_start'] ?? $now) >= self::WINDOW_SECONDS) {
|
|
$rateData = ['count' => 0, 'window_start' => $now];
|
|
}
|
|
|
|
$rateData['count']++;
|
|
|
|
// Rewrite the file in place while still holding the lock
|
|
rewind($fh);
|
|
ftruncate($fh, 0);
|
|
fwrite($fh, json_encode($rateData));
|
|
fflush($fh);
|
|
flock($fh, LOCK_UN);
|
|
fclose($fh);
|
|
|
|
// Check if over limit
|
|
return $rateData['count'] <= $limit;
|
|
}
|
|
|
|
/**
|
|
* Clean up old rate limit files (call periodically)
|
|
*
|
|
* Uses DirectoryIterator instead of glob() for better memory efficiency.
|
|
* A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup.
|
|
*/
|
|
public static function cleanupOldFiles(): void
|
|
{
|
|
$dir = self::getRateLimitDir();
|
|
$lockFile = $dir . '/.cleanup.lock';
|
|
$now = time();
|
|
$maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows
|
|
$maxLockAge = 60; // Release stale locks after 60 seconds
|
|
|
|
// Check for existing lock to prevent concurrent cleanups
|
|
if (file_exists($lockFile)) {
|
|
$lockAge = $now - filemtime($lockFile);
|
|
if ($lockAge < $maxLockAge) {
|
|
return; // Cleanup already in progress
|
|
}
|
|
@unlink($lockFile); // Stale lock
|
|
}
|
|
|
|
// Try to acquire lock
|
|
if (!@touch($lockFile)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$iterator = new DirectoryIterator($dir);
|
|
$deleted = 0;
|
|
$maxDeletes = 50; // Limit deletions per request to avoid blocking
|
|
|
|
foreach ($iterator as $file) {
|
|
if ($deleted >= $maxDeletes) {
|
|
break; // Let cron handle the rest
|
|
}
|
|
|
|
if ($file->isDot() || !$file->isFile()) {
|
|
continue;
|
|
}
|
|
|
|
$filename = $file->getFilename();
|
|
if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) {
|
|
continue;
|
|
}
|
|
|
|
if ($now - $file->getMTime() > $maxAge) {
|
|
if (@unlink($file->getPathname())) {
|
|
$deleted++;
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
@unlink($lockFile);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check rate limit for current request (both session and IP)
|
|
*
|
|
* @param string $type 'default' or 'api'
|
|
* @return bool True if request is allowed, false if rate limited
|
|
*/
|
|
public static function check(string $type = 'default'): bool
|
|
{
|
|
// 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) {
|
|
session_start();
|
|
}
|
|
|
|
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
|
$key = 'rate_limit_' . $type;
|
|
$now = time();
|
|
|
|
// Initialize rate limit tracking
|
|
if (!isset($_SESSION[$key])) {
|
|
$_SESSION[$key] = [
|
|
'count' => 0,
|
|
'window_start' => $now
|
|
];
|
|
}
|
|
|
|
$rateData = &$_SESSION[$key];
|
|
|
|
// Check if window has expired
|
|
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
|
// Reset for new window
|
|
$rateData['count'] = 0;
|
|
$rateData['window_start'] = $now;
|
|
}
|
|
|
|
// Increment request count
|
|
$rateData['count']++;
|
|
|
|
// Check if over limit
|
|
if ($rateData['count'] > $limit) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Apply rate limiting and send error response if exceeded
|
|
*
|
|
* @param string $type 'default' or 'api'
|
|
* @param bool $addHeaders Whether to add rate limit headers to response
|
|
*/
|
|
public static function apply(string $type = 'default', bool $addHeaders = true): void
|
|
{
|
|
// Periodically clean up old rate limit files (2% chance per request)
|
|
// Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
|
|
if (mt_rand(1, 50) === 1) {
|
|
self::cleanupOldFiles();
|
|
}
|
|
|
|
if (!self::check($type)) {
|
|
http_response_code(429);
|
|
header('Content-Type: application/json');
|
|
header('Retry-After: ' . self::WINDOW_SECONDS);
|
|
if ($addHeaders) {
|
|
self::addHeaders($type);
|
|
}
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Rate limit exceeded. Please try again later.',
|
|
'retry_after' => self::WINDOW_SECONDS
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Add rate limit headers to successful responses
|
|
if ($addHeaders) {
|
|
self::addHeaders($type);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current rate limit status
|
|
*
|
|
* @param string $type 'default' or 'api'
|
|
* @return array Rate limit status
|
|
*/
|
|
public static function getStatus(string $type = 'default'): array
|
|
{
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
|
|
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
|
$key = 'rate_limit_' . $type;
|
|
$now = time();
|
|
|
|
if (!isset($_SESSION[$key])) {
|
|
return [
|
|
'limit' => $limit,
|
|
'remaining' => $limit,
|
|
'reset' => $now + self::WINDOW_SECONDS
|
|
];
|
|
}
|
|
|
|
$rateData = $_SESSION[$key];
|
|
|
|
// Check if window has expired
|
|
if ($now - $rateData['window_start'] >= 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
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Add rate limit headers to response
|
|
*
|
|
* @param string $type 'default' or 'api'
|
|
*/
|
|
public static function addHeaders(string $type = 'default'): void
|
|
{
|
|
$status = self::getStatus($type);
|
|
header('X-RateLimit-Limit: ' . $status['limit']);
|
|
header('X-RateLimit-Remaining: ' . $status['remaining']);
|
|
header('X-RateLimit-Reset: ' . $status['reset']);
|
|
}
|
|
}
|