Skip to content

Commit e77c7a8

Browse files
wip
1 parent 5d82a47 commit e77c7a8

10 files changed

Lines changed: 1310 additions & 100 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cboxdk\StatamicMcp\Mcp\Middleware;
6+
7+
use Cboxdk\StatamicMcp\Mcp\Support\ErrorCodes;
8+
use Cboxdk\StatamicMcp\Mcp\Support\ToolLogger;
9+
use Cboxdk\StatamicMcp\Mcp\Support\ToolResponse;
10+
use Illuminate\Support\Facades\RateLimiter;
11+
12+
/**
13+
* Rate limiting middleware for MCP tools.
14+
*/
15+
class RateLimitMiddleware
16+
{
17+
/**
18+
* Default rate limits per tool category.
19+
*
20+
* @var array<string, array<string, int>>
21+
*/
22+
private const DEFAULT_LIMITS = [
23+
'high_cost' => ['attempts' => 10, 'decay' => 60], // 10 per minute for expensive operations
24+
'medium_cost' => ['attempts' => 30, 'decay' => 60], // 30 per minute for moderate operations
25+
'low_cost' => ['attempts' => 60, 'decay' => 60], // 60 per minute for cheap operations
26+
'global' => ['attempts' => 100, 'decay' => 60], // 100 per minute global limit
27+
];
28+
29+
/**
30+
* Tool categories based on computational cost.
31+
*
32+
* @var array<string, array<string>>
33+
*/
34+
private const TOOL_CATEGORIES = [
35+
'high_cost' => [
36+
'statamic.system.tools.discover',
37+
'statamic.development.detect_unused_templates',
38+
'statamic.development.analyze_template_performance',
39+
'statamic.system.health_check',
40+
'statamic.blueprints.scan',
41+
],
42+
'medium_cost' => [
43+
'statamic.entries.list',
44+
'statamic.blueprints.list',
45+
'statamic.collections.list',
46+
'statamic.users.list',
47+
'statamic.assets.list',
48+
],
49+
'low_cost' => [
50+
'statamic.blueprints.get',
51+
'statamic.entries.get',
52+
'statamic.collections.get',
53+
'statamic.users.get',
54+
],
55+
];
56+
57+
/**
58+
* Check rate limits for a tool execution.
59+
*
60+
* @param string|null $identifier User ID, IP, or other identifier
61+
*
62+
* @return array<string, mixed>|null Returns error response if rate limited, null if allowed
63+
*/
64+
public static function checkRateLimit(string $toolName, ?string $identifier = null): ?array
65+
{
66+
if (! $identifier) {
67+
$identifier = self::getDefaultIdentifier();
68+
}
69+
70+
// Check global rate limit first
71+
if (self::isRateLimited('global', $identifier)) {
72+
ToolLogger::rateLimitEvent($toolName, 'global_rate_limit_exceeded', [
73+
'identifier' => $identifier,
74+
'limit_type' => 'global',
75+
]);
76+
77+
return ToolResponse::error(ErrorCodes::RATE_LIMITED, [
78+
'limit_type' => 'global',
79+
'retry_after' => 60,
80+
]);
81+
}
82+
83+
// Check tool-specific rate limit
84+
$category = self::getToolCategory($toolName);
85+
if (self::isRateLimited($category, $identifier)) {
86+
ToolLogger::rateLimitEvent($toolName, 'tool_rate_limit_exceeded', [
87+
'identifier' => $identifier,
88+
'limit_type' => $category,
89+
'tool' => $toolName,
90+
]);
91+
92+
return ToolResponse::error(ErrorCodes::RATE_LIMITED, [
93+
'limit_type' => $category,
94+
'tool' => $toolName,
95+
'retry_after' => self::DEFAULT_LIMITS[$category]['decay'],
96+
]);
97+
}
98+
99+
// All checks passed - increment counters
100+
self::incrementCounters($toolName, $category, $identifier);
101+
102+
return null;
103+
}
104+
105+
/**
106+
* Check if a specific rate limit is exceeded.
107+
*/
108+
private static function isRateLimited(string $category, string $identifier): bool
109+
{
110+
$key = self::getRateLimitKey($category, $identifier);
111+
$limits = self::DEFAULT_LIMITS[$category];
112+
113+
return RateLimiter::tooManyAttempts($key, $limits['attempts']);
114+
}
115+
116+
/**
117+
* Increment rate limit counters.
118+
*/
119+
private static function incrementCounters(string $toolName, string $category, string $identifier): void
120+
{
121+
// Increment global counter
122+
$globalKey = self::getRateLimitKey('global', $identifier);
123+
RateLimiter::hit($globalKey, self::DEFAULT_LIMITS['global']['decay']);
124+
125+
// Increment category-specific counter
126+
$categoryKey = self::getRateLimitKey($category, $identifier);
127+
RateLimiter::hit($categoryKey, self::DEFAULT_LIMITS[$category]['decay']);
128+
129+
ToolLogger::rateLimitEvent($toolName, 'rate_limit_tracked', [
130+
'identifier' => $identifier,
131+
'category' => $category,
132+
'global_remaining' => RateLimiter::remaining($globalKey, self::DEFAULT_LIMITS['global']['attempts']),
133+
'category_remaining' => RateLimiter::remaining($categoryKey, self::DEFAULT_LIMITS[$category]['attempts']),
134+
]);
135+
}
136+
137+
/**
138+
* Get the category for a specific tool.
139+
*/
140+
private static function getToolCategory(string $toolName): string
141+
{
142+
foreach (self::TOOL_CATEGORIES as $category => $tools) {
143+
if (in_array($toolName, $tools, true)) {
144+
return $category;
145+
}
146+
}
147+
148+
return 'low_cost'; // Default to low cost for unknown tools
149+
}
150+
151+
/**
152+
* Generate rate limit key.
153+
*/
154+
private static function getRateLimitKey(string $category, string $identifier): string
155+
{
156+
return "mcp_rate_limit:{$category}:{$identifier}";
157+
}
158+
159+
/**
160+
* Get default identifier for rate limiting.
161+
*/
162+
private static function getDefaultIdentifier(): string
163+
{
164+
// Try user ID first, fall back to IP address
165+
if (auth()->check()) {
166+
return 'user:' . auth()->id();
167+
}
168+
169+
$request = request();
170+
if ($request && $request->ip()) {
171+
return 'ip:' . $request->ip();
172+
}
173+
174+
// Fallback to session ID
175+
return 'session:' . (session()->getId() ?: 'anonymous');
176+
}
177+
178+
/**
179+
* Get rate limit status for a tool.
180+
*
181+
*
182+
* @return array<string, mixed>
183+
*/
184+
public static function getRateLimitStatus(string $toolName, ?string $identifier = null): array
185+
{
186+
if (! $identifier) {
187+
$identifier = self::getDefaultIdentifier();
188+
}
189+
190+
$category = self::getToolCategory($toolName);
191+
$globalKey = self::getRateLimitKey('global', $identifier);
192+
$categoryKey = self::getRateLimitKey($category, $identifier);
193+
194+
$globalLimits = self::DEFAULT_LIMITS['global'];
195+
$categoryLimits = self::DEFAULT_LIMITS[$category];
196+
197+
return [
198+
'global' => [
199+
'limit' => $globalLimits['attempts'],
200+
'remaining' => RateLimiter::remaining($globalKey, $globalLimits['attempts']),
201+
'reset_in' => RateLimiter::availableIn($globalKey),
202+
],
203+
'category' => [
204+
'name' => $category,
205+
'limit' => $categoryLimits['attempts'],
206+
'remaining' => RateLimiter::remaining($categoryKey, $categoryLimits['attempts']),
207+
'reset_in' => RateLimiter::availableIn($categoryKey),
208+
],
209+
'identifier' => $identifier,
210+
];
211+
}
212+
213+
/**
214+
* Clear rate limits for an identifier (admin function).
215+
*/
216+
public static function clearRateLimits(string $identifier): void
217+
{
218+
foreach (array_keys(self::DEFAULT_LIMITS) as $category) {
219+
$key = self::getRateLimitKey($category, $identifier);
220+
RateLimiter::clear($key);
221+
}
222+
223+
ToolLogger::rateLimitEvent('system', 'rate_limit_cleared', [
224+
'identifier' => $identifier,
225+
]);
226+
}
227+
}

src/Mcp/Security/PathValidator.php

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cboxdk\StatamicMcp\Mcp\Security;
6+
7+
use InvalidArgumentException;
8+
9+
/**
10+
* Validates file paths to prevent path traversal attacks.
11+
*/
12+
class PathValidator
13+
{
14+
/**
15+
* Validate that a path is within allowed directories.
16+
*
17+
* @param string $path The path to validate
18+
* @param array<string> $allowedBasePaths Array of allowed base paths
19+
*
20+
* @throws InvalidArgumentException If path is invalid or outside allowed directories
21+
*/
22+
public static function validatePath(string $path, array $allowedBasePaths): string
23+
{
24+
// Resolve the path to prevent directory traversal
25+
$realPath = realpath($path);
26+
27+
if ($realPath === false) {
28+
throw new InvalidArgumentException("Path does not exist: {$path}");
29+
}
30+
31+
// Check if the resolved path is within any of the allowed base paths
32+
foreach ($allowedBasePaths as $basePath) {
33+
$realBasePath = realpath($basePath);
34+
35+
if ($realBasePath !== false && str_starts_with($realPath, $realBasePath . DIRECTORY_SEPARATOR)) {
36+
return $realPath;
37+
}
38+
}
39+
40+
throw new InvalidArgumentException("Path traversal attempt detected: {$path}");
41+
}
42+
43+
/**
44+
* Get default allowed template base paths for Statamic.
45+
*
46+
* @return array<string>
47+
*/
48+
public static function getAllowedTemplatePaths(): array
49+
{
50+
return [
51+
resource_path('views'),
52+
resource_path('blueprints'),
53+
resource_path('fieldsets'),
54+
];
55+
}
56+
57+
/**
58+
* Get default allowed asset paths for Statamic.
59+
*
60+
* @return array<string>
61+
*/
62+
public static function getAllowedAssetPaths(): array
63+
{
64+
return [
65+
public_path(),
66+
storage_path('app/public'),
67+
];
68+
}
69+
70+
/**
71+
* Sanitize a filename by removing dangerous characters.
72+
*/
73+
public static function sanitizeFilename(string $filename): string
74+
{
75+
// Remove path separators and null bytes
76+
$cleaned = str_replace(['/', '\\', "\0"], '', $filename);
77+
78+
// Remove relative path components
79+
$cleaned = str_replace(['..', '.'], '', $cleaned);
80+
81+
// Remove leading/trailing whitespace and dots
82+
$cleaned = trim($cleaned, " \t\n\r\0\x0B.");
83+
84+
if (empty($cleaned)) {
85+
throw new InvalidArgumentException('Invalid filename after sanitization');
86+
}
87+
88+
return $cleaned;
89+
}
90+
91+
/**
92+
* Validate file extension against allowed extensions.
93+
*
94+
* @param array<string> $allowedExtensions
95+
*
96+
* @throws InvalidArgumentException
97+
*/
98+
public static function validateFileExtension(string $filename, array $allowedExtensions): void
99+
{
100+
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
101+
102+
if (! in_array($extension, $allowedExtensions, true)) {
103+
throw new InvalidArgumentException("File extension '{$extension}' not allowed");
104+
}
105+
}
106+
107+
/**
108+
* Check if a path contains suspicious patterns.
109+
*/
110+
public static function containsSuspiciousPatterns(string $path): bool
111+
{
112+
$suspiciousPatterns = [
113+
'../',
114+
'..\\',
115+
'%2e%2e%2f',
116+
'%2e%2e\\',
117+
'..%2f',
118+
'..%5c',
119+
'%2e%2e/',
120+
'..\/',
121+
];
122+
123+
$lowercasePath = strtolower($path);
124+
125+
foreach ($suspiciousPatterns as $pattern) {
126+
if (str_contains($lowercasePath, $pattern)) {
127+
return true;
128+
}
129+
}
130+
131+
return false;
132+
}
133+
}

0 commit comments

Comments
 (0)