diff --git a/src/Mod/Hub/Controllers/TeapotController.php b/src/Mod/Hub/Controllers/TeapotController.php index d50113b..053e1af 100644 --- a/src/Mod/Hub/Controllers/TeapotController.php +++ b/src/Mod/Hub/Controllers/TeapotController.php @@ -75,7 +75,7 @@ class TeapotController } /** - * Remove sensitive headers before storing. + * Remove sensitive headers and enforce size limits before storing. */ protected function sanitizeHeaders(array $headers): array { @@ -85,6 +85,11 @@ class TeapotController unset($headers[$key]); } + // Enforce header count limit before passing to the model + if (count($headers) > HoneypotHit::HEADERS_MAX_COUNT) { + $headers = array_slice($headers, 0, HoneypotHit::HEADERS_MAX_COUNT, true); + } + return $headers; } diff --git a/src/Mod/Hub/Models/HoneypotHit.php b/src/Mod/Hub/Models/HoneypotHit.php index 5373e89..0a5ed8b 100644 --- a/src/Mod/Hub/Models/HoneypotHit.php +++ b/src/Mod/Hub/Models/HoneypotHit.php @@ -27,6 +27,61 @@ class HoneypotHit extends Model 'is_bot' => 'boolean', ]; + /** + * Maximum number of headers to store per hit. + */ + public const HEADERS_MAX_COUNT = 50; + + /** + * Maximum size in bytes for the serialised headers JSON (16 KB). + */ + public const HEADERS_MAX_SIZE = 16_384; + + /** + * Validate and set the headers attribute, enforcing count and size limits. + */ + public function setHeadersAttribute(mixed $value): void + { + if (is_null($value)) { + $this->attributes['headers'] = null; + + return; + } + + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $this->attributes['headers'] = null; + + return; + } + $value = $decoded; + } + + if (! is_array($value)) { + $this->attributes['headers'] = null; + + return; + } + + // Limit header count + if (count($value) > self::HEADERS_MAX_COUNT) { + $value = array_slice($value, 0, self::HEADERS_MAX_COUNT, true); + } + + // Check total size and truncate further if needed + $json = json_encode($value); + if (strlen($json) > self::HEADERS_MAX_SIZE) { + // Progressively reduce until under limit + while (strlen($json) > self::HEADERS_MAX_SIZE && count($value) > 0) { + array_pop($value); + $json = json_encode($value); + } + } + + $this->attributes['headers'] = $json; + } + /** * Severity levels for honeypot hits. * diff --git a/src/Mod/Hub/Models/Service.php b/src/Mod/Hub/Models/Service.php index edf4884..6e23a83 100644 --- a/src/Mod/Hub/Models/Service.php +++ b/src/Mod/Hub/Models/Service.php @@ -6,6 +6,7 @@ namespace Core\Mod\Hub\Models; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use InvalidArgumentException; class Service extends Model { @@ -121,6 +122,55 @@ class Service extends Model return null; } + /** + * Maximum size in bytes for the serialised metadata JSON (64 KB). + */ + public const METADATA_MAX_SIZE = 65_535; + + /** + * Maximum number of top-level keys allowed in metadata. + */ + public const METADATA_MAX_KEYS = 100; + + /** + * Validate and set the metadata attribute. + */ + public function setMetadataAttribute(mixed $value): void + { + if (is_null($value)) { + $this->attributes['metadata'] = null; + + return; + } + + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new InvalidArgumentException('Metadata must be valid JSON'); + } + $value = $decoded; + } + + if (! is_array($value)) { + throw new InvalidArgumentException('Metadata must be an array or null'); + } + + if (count($value) > self::METADATA_MAX_KEYS) { + throw new InvalidArgumentException( + 'Metadata exceeds maximum of ' . self::METADATA_MAX_KEYS . ' keys' + ); + } + + $json = json_encode($value); + if (strlen($json) > self::METADATA_MAX_SIZE) { + throw new InvalidArgumentException( + 'Metadata exceeds maximum size of ' . self::METADATA_MAX_SIZE . ' bytes' + ); + } + + $this->attributes['metadata'] = $json; + } + /** * Check if a specific metadata key exists. */ @@ -139,9 +189,17 @@ class Service extends Model /** * Set a metadata value. + * + * Keys must be non-empty and contain only alphanumeric characters, underscores, and hyphens. */ public function setMeta(string $key, mixed $value): void { + if (empty($key) || ! preg_match('/^[a-zA-Z0-9_-]+$/', $key)) { + throw new InvalidArgumentException( + 'Metadata key must be non-empty and contain only alphanumeric characters, underscores, and hyphens' + ); + } + $metadata = $this->metadata ?? []; $metadata[$key] = $value; $this->metadata = $metadata; diff --git a/tests/Feature/Models/MetadataValidationTest.php b/tests/Feature/Models/MetadataValidationTest.php new file mode 100644 index 0000000..7939efa --- /dev/null +++ b/tests/Feature/Models/MetadataValidationTest.php @@ -0,0 +1,299 @@ +id(); + $table->string('code')->unique(); + $table->string('module')->nullable(); + $table->string('name'); + $table->string('tagline')->nullable(); + $table->text('description')->nullable(); + $table->string('icon')->nullable(); + $table->string('color')->nullable(); + $table->string('marketing_domain')->nullable(); + $table->string('website_class')->nullable(); + $table->string('marketing_url')->nullable(); + $table->string('docs_url')->nullable(); + $table->boolean('is_enabled')->default(true); + $table->boolean('is_public')->default(true); + $table->boolean('is_featured')->default(false); + $table->string('entitlement_code')->nullable(); + $table->integer('sort_order')->default(50); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + if (! \Illuminate\Support\Facades\Schema::hasTable('honeypot_hits')) { + \Illuminate\Support\Facades\Schema::create('honeypot_hits', function ($table) { + $table->id(); + $table->string('ip_address', 45); + $table->string('user_agent', 1000)->nullable(); + $table->string('referer', 2000)->nullable(); + $table->string('path', 255); + $table->string('method', 10); + $table->json('headers')->nullable(); + $table->string('country', 2)->nullable(); + $table->string('city', 100)->nullable(); + $table->boolean('is_bot')->default(false); + $table->string('bot_name', 100)->nullable(); + $table->string('severity', 20)->default('warning'); + $table->timestamps(); + + $table->index('ip_address'); + $table->index('created_at'); + $table->index('is_bot'); + }); + } +}); + +afterEach(function () { + Service::query()->delete(); + HoneypotHit::query()->delete(); +}); + +// ============================================================================= +// Service Metadata Validation +// ============================================================================= + +describe('Service metadata validation', function () { + describe('setMetadataAttribute mutator', function () { + it('accepts valid metadata arrays', function () { + $service = new Service(); + $service->metadata = ['key' => 'value', 'count' => 42]; + + expect($service->getAttributes()['metadata'])->toBe('{"key":"value","count":42}'); + }); + + it('accepts null metadata', function () { + $service = new Service(); + $service->metadata = null; + + expect($service->getAttributes()['metadata'])->toBeNull(); + }); + + it('accepts valid JSON strings', function () { + $service = new Service(); + $service->metadata = '{"key":"value"}'; + + expect($service->getAttributes()['metadata'])->toBe('{"key":"value"}'); + }); + + it('rejects invalid JSON strings', function () { + $service = new Service(); + + expect(fn () => $service->metadata = '{invalid json}') + ->toThrow(InvalidArgumentException::class, 'Metadata must be valid JSON'); + }); + + it('rejects non-array non-string values', function () { + $service = new Service(); + + expect(fn () => $service->metadata = 12345) + ->toThrow(InvalidArgumentException::class, 'Metadata must be an array or null'); + }); + + it('rejects metadata exceeding maximum key count', function () { + $service = new Service(); + $data = []; + for ($i = 0; $i <= Service::METADATA_MAX_KEYS; $i++) { + $data["key_{$i}"] = 'value'; + } + + expect(fn () => $service->metadata = $data) + ->toThrow(InvalidArgumentException::class, 'Metadata exceeds maximum of'); + }); + + it('accepts metadata at the maximum key count', function () { + $service = new Service(); + $data = []; + for ($i = 0; $i < Service::METADATA_MAX_KEYS; $i++) { + $data["key_{$i}"] = 'v'; + } + + $service->metadata = $data; + + expect(json_decode($service->getAttributes()['metadata'], true)) + ->toHaveCount(Service::METADATA_MAX_KEYS); + }); + + it('rejects metadata exceeding maximum size', function () { + $service = new Service(); + // Create a payload that exceeds 64KB + $data = ['large' => str_repeat('x', Service::METADATA_MAX_SIZE)]; + + expect(fn () => $service->metadata = $data) + ->toThrow(InvalidArgumentException::class, 'Metadata exceeds maximum size'); + }); + + it('persists valid metadata to database', function () { + $service = Service::create([ + 'code' => 'test-service', + 'name' => 'Test Service', + 'metadata' => ['version' => '1.0', 'features' => ['a', 'b']], + ]); + + $fresh = Service::find($service->id); + expect($fresh->metadata)->toBe(['version' => '1.0', 'features' => ['a', 'b']]); + }); + }); + + describe('setMeta key validation', function () { + it('accepts valid alphanumeric keys', function () { + $service = new Service(); + $service->metadata = []; + + $service->setMeta('valid_key', 'value'); + expect($service->metadata['valid_key'])->toBe('value'); + }); + + it('accepts keys with hyphens', function () { + $service = new Service(); + $service->metadata = []; + + $service->setMeta('my-key', 'value'); + expect($service->metadata['my-key'])->toBe('value'); + }); + + it('rejects empty keys', function () { + $service = new Service(); + $service->metadata = []; + + expect(fn () => $service->setMeta('', 'value')) + ->toThrow(InvalidArgumentException::class); + }); + + it('rejects keys with special characters', function () { + $service = new Service(); + $service->metadata = []; + + expect(fn () => $service->setMeta('key.with.dots', 'value')) + ->toThrow(InvalidArgumentException::class); + + expect(fn () => $service->setMeta('key with spaces', 'value')) + ->toThrow(InvalidArgumentException::class); + + expect(fn () => $service->setMeta('key/path', 'value')) + ->toThrow(InvalidArgumentException::class); + }); + }); +}); + +// ============================================================================= +// HoneypotHit Headers Validation +// ============================================================================= + +describe('HoneypotHit headers validation', function () { + describe('setHeadersAttribute mutator', function () { + it('accepts valid header arrays', function () { + $hit = new HoneypotHit(); + $hit->headers = ['host' => ['example.com'], 'accept' => ['text/html']]; + + $decoded = json_decode($hit->getAttributes()['headers'], true); + expect($decoded)->toHaveKey('host'); + expect($decoded)->toHaveKey('accept'); + }); + + it('accepts null headers', function () { + $hit = new HoneypotHit(); + $hit->headers = null; + + expect($hit->getAttributes()['headers'])->toBeNull(); + }); + + it('truncates headers exceeding count limit', function () { + $hit = new HoneypotHit(); + $headers = []; + for ($i = 0; $i < HoneypotHit::HEADERS_MAX_COUNT + 20; $i++) { + $headers["x-header-{$i}"] = ["value-{$i}"]; + } + + $hit->headers = $headers; + + $decoded = json_decode($hit->getAttributes()['headers'], true); + expect(count($decoded))->toBeLessThanOrEqual(HoneypotHit::HEADERS_MAX_COUNT); + }); + + it('keeps headers at the exact limit', function () { + $hit = new HoneypotHit(); + $headers = []; + for ($i = 0; $i < HoneypotHit::HEADERS_MAX_COUNT; $i++) { + $headers["h{$i}"] = ['v']; + } + + $hit->headers = $headers; + + $decoded = json_decode($hit->getAttributes()['headers'], true); + expect(count($decoded))->toBe(HoneypotHit::HEADERS_MAX_COUNT); + }); + + it('truncates headers exceeding size limit', function () { + $hit = new HoneypotHit(); + // Create headers with large values that exceed 16KB + $headers = []; + for ($i = 0; $i < 10; $i++) { + $headers["x-large-{$i}"] = [str_repeat('x', 2000)]; + } + + $hit->headers = $headers; + + $json = $hit->getAttributes()['headers']; + expect(strlen($json))->toBeLessThanOrEqual(HoneypotHit::HEADERS_MAX_SIZE); + }); + + it('handles invalid JSON string gracefully', function () { + $hit = new HoneypotHit(); + $hit->headers = '{not valid json}'; + + expect($hit->getAttributes()['headers'])->toBeNull(); + }); + + it('handles non-array non-string values gracefully', function () { + $hit = new HoneypotHit(); + $hit->headers = 12345; + + expect($hit->getAttributes()['headers'])->toBeNull(); + }); + + it('accepts valid JSON strings', function () { + $hit = new HoneypotHit(); + $hit->headers = '{"host":["example.com"]}'; + + $decoded = json_decode($hit->getAttributes()['headers'], true); + expect($decoded)->toHaveKey('host'); + }); + + it('persists valid headers to database', function () { + $hit = HoneypotHit::create([ + 'ip_address' => '192.168.1.1', + 'path' => '/teapot', + 'method' => 'GET', + 'headers' => ['host' => ['example.com'], 'accept' => ['*/*']], + 'severity' => 'warning', + ]); + + $fresh = HoneypotHit::find($hit->id); + expect($fresh->headers)->toBe(['host' => ['example.com'], 'accept' => ['*/*']]); + }); + }); +});