Merge pull request 'security: validate JSON metadata fields to prevent mass assignment' (#22) from security/validate-json-metadata into main
Some checks failed
CI / PHP 8.3 (push) Failing after 1s
CI / Assets (push) Failing after 1s
CI / PHP 8.2 (push) Failing after 1s
CI / PHP 8.4 (push) Failing after 1s

This commit is contained in:
Charon 2026-02-21 01:26:03 +00:00
commit 63abc5f99a
4 changed files with 421 additions and 2 deletions

View file

@ -91,7 +91,7 @@ class TeapotController
}
/**
* Whitelist headers useful for bot detection before storing.
* Remove sensitive headers and enforce size limits before storing.
*/
protected function sanitizeHeaders(array $headers): array
{
@ -109,7 +109,14 @@ class TeapotController
'x-client-ip',
];
return array_intersect_key($headers, array_flip($allowed));
$headers = array_intersect_key($headers, array_flip($allowed));
// 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;
}
/**

View file

@ -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.
*

View file

@ -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;

View 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' => ['*/*']]);
});
});
});