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>
This commit is contained in:
parent
6bdb6c0242
commit
23b3339b0b
4 changed files with 418 additions and 1 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
299
tests/Feature/Models/MetadataValidationTest.php
Normal file
299
tests/Feature/Models/MetadataValidationTest.php
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
<?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' => ['*/*']]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue