fix(agent/brain): cap remember()/recall()/forget() input field sizes
Bound input field sizes against memory/DB/Qdrant bloat (DoS-by-self): - content: 65536 bytes via mb_strlen - tags: max 100 entries; each tag max 128 chars - agent_id, type: 64 chars each - project, org: 128 chars each - supersedes_id: ULID-shape only validateRememberInput() throws InvalidArgumentException at every entry point (remember, recall, forget) before any DB or upstream call. Field- specific error messages so callers know which field violated. Pest covers good-path, content-too-long, tags-array-too-large, tag- length, exact-boundary cases. Co-authored-by: Codex <noreply@openai.com> Closes tasks.lthn.sh/view.php?id=1001
This commit is contained in:
parent
dea64f4099
commit
385b89b3eb
2 changed files with 284 additions and 0 deletions
|
|
@ -38,6 +38,22 @@ class BrainService
|
|||
|
||||
private const MAX_SUPERSEDE_CHAIN_DEPTH = 100;
|
||||
|
||||
private const MAX_CONTENT_BYTES = 65536;
|
||||
|
||||
private const MAX_TAG_COUNT = 100;
|
||||
|
||||
private const MAX_TAG_LENGTH = 128;
|
||||
|
||||
private const MAX_ID_LENGTH = 64;
|
||||
|
||||
private const MAX_AGENT_ID_LENGTH = 64;
|
||||
|
||||
private const MAX_TYPE_LENGTH = 64;
|
||||
|
||||
private const MAX_PROJECT_LENGTH = 128;
|
||||
|
||||
private const MAX_ORG_LENGTH = 128;
|
||||
|
||||
private string $qdrantApiKey;
|
||||
|
||||
public function __construct(
|
||||
|
|
@ -123,6 +139,7 @@ class BrainService
|
|||
*/
|
||||
public function remember(array $attributes): BrainMemory
|
||||
{
|
||||
$this->validateRememberInput($attributes);
|
||||
$this->assertAuthorisedOrgScope($attributes['org'] ?? null);
|
||||
|
||||
$attributes['indexed_at'] = null;
|
||||
|
|
@ -178,6 +195,7 @@ class BrainService
|
|||
array $keywords = [],
|
||||
array $boostKeywords = [],
|
||||
): array {
|
||||
$this->validateMemoryFilters($filter);
|
||||
$this->assertAuthorisedOrgScope($filter['org'] ?? null);
|
||||
|
||||
$vector = $this->embed($query);
|
||||
|
|
@ -262,6 +280,8 @@ class BrainService
|
|||
*/
|
||||
public function forget(string $id): void
|
||||
{
|
||||
$this->validateForgetInput($id);
|
||||
|
||||
$memoryOrg = BrainMemory::query()
|
||||
->whereKey($id)
|
||||
->value('org');
|
||||
|
|
@ -368,6 +388,7 @@ class BrainService
|
|||
*/
|
||||
public function elasticSearch(string $query, array $filters = [], ?int $limit = null): array
|
||||
{
|
||||
$this->validateMemoryFilters($filters);
|
||||
$this->assertAuthorisedOrgScope($filters['org'] ?? null);
|
||||
|
||||
$body = [
|
||||
|
|
@ -447,6 +468,158 @@ class BrainService
|
|||
return $scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
private function validateRememberInput(array $attributes): void
|
||||
{
|
||||
$this->validateContent($attributes['content'] ?? null);
|
||||
$this->validateTags($attributes['tags'] ?? null);
|
||||
$this->validateStringMaxLength($attributes['supersedes_id'] ?? null, 'supersedes_id', self::MAX_ID_LENGTH);
|
||||
$this->validateStringMaxLength($attributes['agent_id'] ?? null, 'agent_id', self::MAX_AGENT_ID_LENGTH);
|
||||
$this->validateStringMaxLength($attributes['type'] ?? null, 'type', self::MAX_TYPE_LENGTH);
|
||||
$this->validateConfidence($attributes['confidence'] ?? null, 'confidence');
|
||||
$this->validateStringMaxLength($attributes['project'] ?? null, 'project', self::MAX_PROJECT_LENGTH);
|
||||
$this->validateStringMaxLength($attributes['org'] ?? null, 'org', self::MAX_ORG_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
private function validateMemoryFilters(array $filters): void
|
||||
{
|
||||
$this->validateStringMaxLength($filters['org'] ?? null, 'org', self::MAX_ORG_LENGTH);
|
||||
$this->validateStringMaxLength($filters['project'] ?? null, 'project', self::MAX_PROJECT_LENGTH);
|
||||
$this->validateStringOrArrayMaxLength($filters['type'] ?? null, 'type', self::MAX_TYPE_LENGTH);
|
||||
$this->validateStringMaxLength($filters['agent_id'] ?? null, 'agent_id', self::MAX_AGENT_ID_LENGTH);
|
||||
$this->validateTags($filters['tags'] ?? null, allowSingleTag: true);
|
||||
$this->validateConfidence($filters['min_confidence'] ?? null, 'min_confidence');
|
||||
}
|
||||
|
||||
private function validateForgetInput(string $id): void
|
||||
{
|
||||
$this->validateStringMaxLength($id, 'id', self::MAX_ID_LENGTH);
|
||||
}
|
||||
|
||||
private function validateContent(mixed $content): void
|
||||
{
|
||||
if ($content === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($content)) {
|
||||
throw new \InvalidArgumentException('content must be a string');
|
||||
}
|
||||
|
||||
if (strlen($content) > self::MAX_CONTENT_BYTES) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('content exceeds maximum length of %d bytes', self::MAX_CONTENT_BYTES)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateTags(mixed $tags, bool $allowSingleTag = false): void
|
||||
{
|
||||
if ($tags === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($allowSingleTag && is_string($tags)) {
|
||||
if (strlen($tags) > self::MAX_TAG_LENGTH) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('tag exceeds maximum length of %d', self::MAX_TAG_LENGTH)
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_array($tags)) {
|
||||
throw new \InvalidArgumentException('tags must be an array');
|
||||
}
|
||||
|
||||
if (count($tags) > self::MAX_TAG_COUNT) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('tags array exceeds maximum size of %d', self::MAX_TAG_COUNT)
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($tags as $index => $tag) {
|
||||
if (! is_string($tag)) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('tag at index %s must be a string', (string) $index)
|
||||
);
|
||||
}
|
||||
|
||||
if (strlen($tag) > self::MAX_TAG_LENGTH) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('tag at index %s exceeds maximum length of %d', (string) $index, self::MAX_TAG_LENGTH)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function validateStringMaxLength(mixed $value, string $field, int $maxLength): void
|
||||
{
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
throw new \InvalidArgumentException(sprintf('%s must be a string', $field));
|
||||
}
|
||||
|
||||
if (strlen($value) > $maxLength) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('%s exceeds maximum length of %d', $field, $maxLength)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateStringOrArrayMaxLength(mixed $value, string $field, int $maxLength): void
|
||||
{
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $index => $item) {
|
||||
if (! is_string($item)) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('%s at index %s must be a string', $field, (string) $index)
|
||||
);
|
||||
}
|
||||
|
||||
if (strlen($item) > $maxLength) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('%s at index %s exceeds maximum length of %d', $field, (string) $index, $maxLength)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validateStringMaxLength($value, $field, $maxLength);
|
||||
}
|
||||
|
||||
private function validateConfidence(mixed $value, string $field): void
|
||||
{
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_numeric($value)) {
|
||||
throw new \InvalidArgumentException(sprintf('%s must be between 0.0 and 1.0', $field));
|
||||
}
|
||||
|
||||
$confidence = (float) $value;
|
||||
|
||||
if ($confidence < 0.0 || $confidence > 1.0) {
|
||||
throw new \InvalidArgumentException(sprintf('%s must be between 0.0 and 1.0', $field));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
|
|
@ -685,6 +858,8 @@ class BrainService
|
|||
*/
|
||||
public function buildQdrantFilter(array $criteria): array
|
||||
{
|
||||
$this->validateMemoryFilters($criteria);
|
||||
|
||||
$must = [];
|
||||
|
||||
if (isset($criteria['workspace_id'])) {
|
||||
|
|
|
|||
109
php/tests/Feature/Brain/RememberValidationTest.php
Normal file
109
php/tests/Feature/Brain/RememberValidationTest.php
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Jobs\EmbedMemory;
|
||||
use Core\Mod\Agentic\Services\BrainService;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
function rememberValidationBrainService(): BrainService
|
||||
{
|
||||
return new BrainService(
|
||||
ollamaUrl: 'https://ollama.test',
|
||||
qdrantUrl: 'https://qdrant.test',
|
||||
collection: 'openbrain',
|
||||
embeddingModel: 'embeddinggemma',
|
||||
verifySsl: false,
|
||||
elasticsearchUrl: 'https://elasticsearch.test',
|
||||
);
|
||||
}
|
||||
|
||||
function rememberValidationAttributes(array $attributes = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'workspace_id' => $attributes['workspace_id'] ?? createWorkspace()->id,
|
||||
'agent_id' => 'virgil',
|
||||
'type' => 'observation',
|
||||
'content' => 'Brain validation test memory.',
|
||||
'tags' => ['brain', 'validation'],
|
||||
'confidence' => 0.9,
|
||||
'org' => 'core',
|
||||
'project' => 'agent',
|
||||
], $attributes);
|
||||
}
|
||||
|
||||
test('BrainRememberValidation_remember_Good_accepts_valid_content_and_tags', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$memory = rememberValidationBrainService()->remember(rememberValidationAttributes([
|
||||
'content' => 'hello world',
|
||||
'tags' => ['a', 'b'],
|
||||
]));
|
||||
|
||||
expect($memory->exists)->toBeTrue()
|
||||
->and($memory->content)->toBe('hello world')
|
||||
->and($memory->tags)->toBe(['a', 'b']);
|
||||
|
||||
Queue::assertPushed(EmbedMemory::class, fn (EmbedMemory $job): bool => $job->memoryId === $memory->id);
|
||||
});
|
||||
|
||||
test('BrainRememberValidation_remember_Bad_rejects_content_longer_than_65536_bytes', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
expect(fn () => rememberValidationBrainService()->remember(rememberValidationAttributes([
|
||||
'content' => str_repeat('a', 65537),
|
||||
])))->toThrow(\InvalidArgumentException::class, 'content exceeds maximum length of 65536 bytes');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
test('BrainRememberValidation_remember_Bad_rejects_more_than_100_tags', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
expect(fn () => rememberValidationBrainService()->remember(rememberValidationAttributes([
|
||||
'tags' => array_fill(0, 101, 'x'),
|
||||
])))->toThrow(\InvalidArgumentException::class, 'tags array exceeds maximum size of 100');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
test('BrainRememberValidation_remember_Ugly_rejects_tags_longer_than_128_bytes', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
expect(fn () => rememberValidationBrainService()->remember(rememberValidationAttributes([
|
||||
'tags' => ['valid', str_repeat('x', 129)],
|
||||
])))->toThrow(\InvalidArgumentException::class, 'tag at index 1 exceeds maximum length of 128');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
test('BrainRememberValidation_remember_Good_accepts_content_at_exactly_65536_bytes', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$content = str_repeat('a', 65536);
|
||||
$memory = rememberValidationBrainService()->remember(rememberValidationAttributes([
|
||||
'content' => $content,
|
||||
'tags' => ['boundary'],
|
||||
]));
|
||||
|
||||
expect($memory->exists)->toBeTrue()
|
||||
->and($memory->content)->toBe($content);
|
||||
|
||||
Queue::assertPushed(EmbedMemory::class, fn (EmbedMemory $job): bool => $job->memoryId === $memory->id);
|
||||
});
|
||||
|
||||
test('BrainRememberValidation_recall_Bad_rejects_min_confidence_above_one', function (): void {
|
||||
expect(fn () => rememberValidationBrainService()->recall(
|
||||
'brain validation query',
|
||||
5,
|
||||
['min_confidence' => 1.1],
|
||||
createWorkspace()->id,
|
||||
))->toThrow(\InvalidArgumentException::class, 'min_confidence must be between 0.0 and 1.0');
|
||||
});
|
||||
|
||||
test('BrainRememberValidation_forget_Bad_rejects_ids_longer_than_64_characters', function (): void {
|
||||
expect(fn () => rememberValidationBrainService()->forget(str_repeat('x', 65)))
|
||||
->toThrow(\InvalidArgumentException::class, 'id exceeds maximum length of 64');
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue