Skip to content

Commit 11f5c2b

Browse files
Merge pull request #12 from run-as-root/fix/security-audit-remediations
fix(security): comprehensive security audit remediations
2 parents ed1f1f9 + d90d553 commit 11f5c2b

35 files changed

Lines changed: 396 additions & 146 deletions

Controller/Adminhtml/Assistant/Chat.php

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Magento\Backend\App\Action;
88
use Magento\Backend\App\Action\Context;
99
use Magento\Framework\App\Action\HttpPostActionInterface;
10+
use Magento\Framework\App\CacheInterface;
1011
use Magento\Framework\Controller\Result\Json;
1112
use Magento\Framework\Controller\Result\JsonFactory;
1213
use Psr\Log\LoggerInterface;
@@ -17,12 +18,16 @@ class Chat extends Action implements HttpPostActionInterface
1718
{
1819
public const ADMIN_RESOURCE = 'RunAsRoot_TypeSense::ai_assistant';
1920

21+
private const RATE_LIMIT_MAX_REQUESTS = 100;
22+
private const RATE_LIMIT_WINDOW_SECONDS = 3600;
23+
2024
public function __construct(
2125
Context $context,
2226
private readonly JsonFactory $jsonFactory,
2327
private readonly AgentLoop $agentLoop,
2428
private readonly LoggerInterface $logger,
2529
private readonly TypeSenseConfigInterface $config,
30+
private readonly CacheInterface $cache,
2631
) {
2732
parent::__construct($context);
2833
}
@@ -35,6 +40,11 @@ public function execute(): Json
3540
return $result->setData(['success' => false, 'error' => 'AI Assistant is not enabled.']);
3641
}
3742

43+
$rateLimitError = $this->checkRateLimit();
44+
if ($rateLimitError !== null) {
45+
return $result->setData(['success' => false, 'error' => $rateLimitError]);
46+
}
47+
3848
try {
3949
$query = (string) $this->getRequest()->getParam('query', '');
4050
$historyJson = (string) $this->getRequest()->getParam('history', '');
@@ -47,7 +57,7 @@ public function execute(): Json
4757
if ($historyJson !== '') {
4858
$decoded = json_decode($historyJson, true);
4959
if (is_array($decoded)) {
50-
$history = $decoded;
60+
$history = $this->sanitizeHistory($decoded);
5161
}
5262
}
5363

@@ -56,7 +66,7 @@ public function execute(): Json
5666
return $result->setData([
5767
'success' => true,
5868
'answer' => $response['answer'],
59-
'messages' => $response['messages'],
69+
'messages' => $this->filterResponseMessages($response['messages']),
6070
]);
6171
} catch (\Exception $e) {
6272
$this->logger->error('Admin AI Assistant error: ' . $e->getMessage());
@@ -67,4 +77,84 @@ public function execute(): Json
6777
]);
6878
}
6979
}
80+
81+
private function checkRateLimit(): ?string
82+
{
83+
$adminId = (string) $this->_auth->getUser()->getId();
84+
$cacheKey = 'ai_assistant_rate_' . $adminId;
85+
86+
$count = (int) $this->cache->load($cacheKey);
87+
88+
if ($count >= self::RATE_LIMIT_MAX_REQUESTS) {
89+
$this->logger->warning('AI Assistant rate limit exceeded for admin ' . $adminId);
90+
return 'Rate limit exceeded. Maximum ' . self::RATE_LIMIT_MAX_REQUESTS
91+
. ' requests per hour. Please try again later.';
92+
}
93+
94+
$this->cache->save(
95+
(string) ($count + 1),
96+
$cacheKey,
97+
[],
98+
self::RATE_LIMIT_WINDOW_SECONDS
99+
);
100+
101+
return null;
102+
}
103+
104+
/**
105+
* Filter response messages to prevent leaking system prompt and tool internals.
106+
*
107+
* @param array<int, array<string, mixed>> $messages
108+
* @return array<int, array{role: string, content: string}>
109+
*/
110+
private function filterResponseMessages(array $messages): array
111+
{
112+
$exposedRoles = ['user', 'assistant'];
113+
$filtered = [];
114+
115+
foreach ($messages as $message) {
116+
$role = (string) ($message['role'] ?? '');
117+
if (!in_array($role, $exposedRoles, true)) {
118+
continue;
119+
}
120+
121+
$filtered[] = [
122+
'role' => $role,
123+
'content' => (string) ($message['content'] ?? ''),
124+
];
125+
}
126+
127+
return $filtered;
128+
}
129+
130+
/**
131+
* Sanitize conversation history from client input.
132+
* Only allow user and assistant roles. Strip tool_calls and system messages.
133+
*
134+
* @param array<int, mixed> $history
135+
* @return array<int, array{role: string, content: string}>
136+
*/
137+
private function sanitizeHistory(array $history): array
138+
{
139+
$allowedRoles = ['user', 'assistant'];
140+
$sanitized = [];
141+
142+
foreach ($history as $message) {
143+
if (!is_array($message)) {
144+
continue;
145+
}
146+
147+
$role = (string) ($message['role'] ?? '');
148+
if (!in_array($role, $allowedRoles, true)) {
149+
continue;
150+
}
151+
152+
$sanitized[] = [
153+
'role' => $role,
154+
'content' => (string) ($message['content'] ?? ''),
155+
];
156+
}
157+
158+
return $sanitized;
159+
}
70160
}

Controller/Adminhtml/CategoryMerchandiser/Save.php

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
use Magento\Backend\App\Action;
88
use Magento\Backend\App\Action\Context;
99
use Magento\Framework\Api\SearchCriteriaBuilder;
10-
use Magento\Framework\App\Action\HttpGetActionInterface;
1110
use Magento\Framework\App\Action\HttpPostActionInterface;
1211
use Magento\Framework\Controller\Result\Json;
1312
use Magento\Framework\Controller\Result\JsonFactory;
@@ -17,7 +16,7 @@
1716
use RunAsRoot\TypeSense\Model\Curation\CategoryMerchandisingSync;
1817
use RunAsRoot\TypeSense\Model\Merchandising\CategoryMerchandisingFactory;
1918

20-
class Save extends Action implements HttpPostActionInterface, HttpGetActionInterface
19+
class Save extends Action implements HttpPostActionInterface
2120
{
2221
public const ADMIN_RESOURCE = 'RunAsRoot_TypeSense::overrides';
2322

@@ -38,45 +37,6 @@ public function execute(): Json
3837
{
3938
$resultJson = $this->jsonFactory->create();
4039

41-
// Handle GET: return existing rules for a category
42-
if ($this->getRequest()->isGet()) {
43-
return $this->handleGet($resultJson);
44-
}
45-
46-
return $this->handlePost($resultJson);
47-
}
48-
49-
private function handleGet(Json $resultJson): Json
50-
{
51-
$categoryId = (int) $this->getRequest()->getParam('category_id', 0);
52-
53-
if ($categoryId === 0) {
54-
return $resultJson->setData(['rules' => []]);
55-
}
56-
57-
$searchCriteria = $this->searchCriteriaBuilder
58-
->addFilter('category_id', $categoryId)
59-
->create();
60-
61-
$searchResults = $this->repository->getList($searchCriteria);
62-
$rules = [];
63-
64-
foreach ($searchResults->getItems() as $item) {
65-
$rules[] = [
66-
'product_id' => $item->getProductId(),
67-
'position' => $item->getPosition(),
68-
'action' => $item->getAction(),
69-
'name' => '',
70-
'sku' => '',
71-
'image_url' => '',
72-
];
73-
}
74-
75-
return $resultJson->setData(['rules' => $rules]);
76-
}
77-
78-
private function handlePost(Json $resultJson): Json
79-
{
8040
try {
8141
$raw = $this->getRequest()->getParam('payload') ?: $this->getRequest()->getContent();
8242
$payload = json_decode((string) $raw, true, 512, JSON_THROW_ON_ERROR);

Model/Assistant/AgentLoop.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
class AgentLoop
1111
{
1212
private const MAX_ITERATIONS = 10;
13+
private const MAX_EXECUTION_SECONDS = 60;
1314

1415
public function __construct(
1516
private readonly OpenAiClientFactory $openAiClientFactory,
@@ -41,8 +42,19 @@ public function run(string $userQuery, array $conversationHistory = []): array
4142

4243
$tools = $this->toolRegistry->getToolDefinitions();
4344
$client = $this->openAiClientFactory->create();
45+
$startTime = microtime(true);
4446

4547
for ($i = 0; $i < self::MAX_ITERATIONS; $i++) {
48+
if ((microtime(true) - $startTime) > self::MAX_EXECUTION_SECONDS) {
49+
$this->logger->warning('AI Assistant agent loop timed out', [
50+
'elapsed_seconds' => round(microtime(true) - $startTime, 1),
51+
'iterations' => $i,
52+
]);
53+
return [
54+
'answer' => 'The request took too long to process. Please try a simpler question.',
55+
'messages' => $messages,
56+
];
57+
}
4658
$response = $client->chat()->create([
4759
'model' => $this->resolveModel(),
4860
'messages' => $messages,

Model/Assistant/Tool/DescribeDatabaseTool.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ private function describeTable(string $tableName): string
100100

101101
return json_encode(['table' => $tableName, 'columns' => $columns]);
102102
} catch (\Exception $e) {
103-
return json_encode(['error' => 'Could not describe table: ' . $e->getMessage()]);
103+
return json_encode(['error' => 'Could not describe table. Please check the table name and try again.']);
104104
}
105105
}
106106

@@ -143,7 +143,7 @@ private function showRelationships(string $tableName): string
143143
'referenced_by' => $incoming,
144144
]);
145145
} catch (\Exception $e) {
146-
return json_encode(['error' => 'Could not fetch relationships: ' . $e->getMessage()]);
146+
return json_encode(['error' => 'Could not fetch relationships. Please check the table name and try again.']);
147147
}
148148
}
149149
}

Model/Assistant/Tool/ExecuteSqlTool.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace RunAsRoot\TypeSense\Model\Assistant\Tool;
66

77
use Magento\Framework\App\ResourceConnection;
8+
use Psr\Log\LoggerInterface;
89

910
class ExecuteSqlTool implements ToolInterface
1011
{
@@ -13,6 +14,7 @@ class ExecuteSqlTool implements ToolInterface
1314
public function __construct(
1415
private readonly ResourceConnection $resource,
1516
private readonly SqlSandbox $sandbox,
17+
private readonly LoggerInterface $logger,
1618
) {
1719
}
1820

@@ -23,7 +25,7 @@ public function getName(): string
2325

2426
public function getDescription(): string
2527
{
26-
return 'Execute a read-only SELECT query on the Magento MySQL database. Returns up to 100 rows. Use describe_database to explore table/column names first. Blocked tables: admin_user, oauth_token, authorization_role. Blocked columns: password_hash.';
28+
return 'Execute a read-only SELECT query on the Magento MySQL database. Returns up to 100 rows. Use describe_database to explore table/column names first. Many tables and columns are blocked for security (admin_user, oauth_token, authorization_role, integration, vault_payment_token, etc). UNION, subqueries into blocked tables, and SQL comments are not allowed.';
2729
}
2830

2931
public function getParametersSchema(): array
@@ -48,9 +50,15 @@ public function execute(array $arguments): string
4850
return json_encode(['error' => 'Query cannot be empty.']);
4951
}
5052

53+
$this->logger->info('AI Assistant SQL query', ['query' => mb_substr($query, 0, 500)]);
54+
5155
try {
5256
$this->sandbox->validate($query);
5357
} catch (\InvalidArgumentException $e) {
58+
$this->logger->warning('AI Assistant blocked SQL query', [
59+
'query' => mb_substr($query, 0, 500),
60+
'reason' => $e->getMessage(),
61+
]);
5462
return json_encode(['error' => $e->getMessage()]);
5563
}
5664

@@ -73,7 +81,11 @@ public function execute(array $arguments): string
7381
'count' => count($rows),
7482
]);
7583
} catch (\Exception $e) {
76-
return json_encode(['error' => 'SQL error: ' . $e->getMessage()]);
84+
$this->logger->error('AI Assistant SQL execution error', [
85+
'query' => mb_substr($query, 0, 500),
86+
'error' => $e->getMessage(),
87+
]);
88+
return json_encode(['error' => 'Query execution failed. Please check your SQL syntax and try again.']);
7789
}
7890
}
7991
}

0 commit comments

Comments
 (0)