php-admin/tests/Feature/Models/MetadataValidationTest.php
Clotho 23b3339b0b
Some checks failed
CI / PHP 8.2 (pull_request) Failing after 1s
CI / PHP 8.3 (pull_request) Failing after 1s
CI / PHP 8.4 (pull_request) Failing after 1s
CI / Assets (pull_request) Failing after 1s
security: validate JSON metadata fields to prevent mass assignment
Add mutators to Service and HoneypotHit models that enforce size and
structure limits on JSON fields (metadata, headers). Service.setMeta()
now validates key format. TeapotController pre-filters header count
before passing to the model.

Fixes #14

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:06:18 +00:00

299 lines
11 KiB
PHP

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
use Core\Mod\Hub\Models\HoneypotHit;
use Core\Mod\Hub\Models\Service;
/**
* Tests for JSON metadata field validation on Service and HoneypotHit models.
*
* Ensures size limits, key count limits, and key format validation
* are enforced to prevent mass assignment of arbitrary data.
*/
beforeEach(function () {
if (! \Illuminate\Support\Facades\Schema::hasTable('platform_services')) {
\Illuminate\Support\Facades\Schema::create('platform_services', function ($table) {
$table->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' => ['*/*']]);
});
});
});