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

View File

@@ -2,21 +2,127 @@
/**
* 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 {
// Default limits
const DEFAULT_LIMIT = 100; // requests per window
const API_LIMIT = 60; // API requests per window
const DEFAULT_LIMIT = 100; // requests per window (session)
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
// 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'
* @return bool True if request is allowed, false if rate limited
*/
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) {
session_start();
}
@@ -59,6 +165,11 @@ class RateLimitMiddleware {
* @param string $type 'default' or 'api'
*/
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)) {
http_response_code(429);
header('Content-Type: application/json');