Compare commits
5 commits
security/r
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 63abc5f99a | |||
| 8922683bcf | |||
| ee383bbe3f | |||
| 498bceab88 | |||
| 163d34aacf |
5 changed files with 614 additions and 11 deletions
|
|
@ -44,7 +44,7 @@ class TeapotController
|
||||||
HoneypotHit::create([
|
HoneypotHit::create([
|
||||||
'ip_address' => $ip,
|
'ip_address' => $ip,
|
||||||
'user_agent' => substr($userAgent ?? '', 0, 1000),
|
'user_agent' => substr($userAgent ?? '', 0, 1000),
|
||||||
'referer' => substr($request->header('Referer', ''), 0, 2000),
|
'referer' => $this->sanitizeReferer($request->header('Referer', '')),
|
||||||
'path' => $path,
|
'path' => $path,
|
||||||
'method' => $request->method(),
|
'method' => $request->method(),
|
||||||
'headers' => $this->sanitizeHeaders($request->headers->all()),
|
'headers' => $this->sanitizeHeaders($request->headers->all()),
|
||||||
|
|
@ -59,7 +59,7 @@ class TeapotController
|
||||||
// Auto-block critical hits (active probing) if enabled in config.
|
// Auto-block critical hits (active probing) if enabled in config.
|
||||||
// Skip localhost in dev to avoid blocking yourself.
|
// Skip localhost in dev to avoid blocking yourself.
|
||||||
$autoBlockEnabled = config('core.bouncer.honeypot.auto_block_critical', true);
|
$autoBlockEnabled = config('core.bouncer.honeypot.auto_block_critical', true);
|
||||||
$isLocalhost = in_array($ip, ['127.0.0.1', '::1'], true);
|
$isLocalhost = $this->isPrivateIp($ip);
|
||||||
$isCritical = $severity === HoneypotHit::getSeverityCritical();
|
$isCritical = $severity === HoneypotHit::getSeverityCritical();
|
||||||
|
|
||||||
if ($autoBlockEnabled && $isCritical && ! $isLocalhost) {
|
if ($autoBlockEnabled && $isCritical && ! $isLocalhost) {
|
||||||
|
|
@ -75,19 +75,65 @@ class TeapotController
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove sensitive headers before storing.
|
* Validate and truncate the referer header.
|
||||||
|
*/
|
||||||
|
protected function sanitizeReferer(string $referer): string
|
||||||
|
{
|
||||||
|
if ($referer === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter_var($referer, FILTER_VALIDATE_URL) === false) {
|
||||||
|
return 'invalid-url';
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($referer, 0, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove sensitive headers and enforce size limits before storing.
|
||||||
*/
|
*/
|
||||||
protected function sanitizeHeaders(array $headers): array
|
protected function sanitizeHeaders(array $headers): array
|
||||||
{
|
{
|
||||||
$sensitive = ['cookie', 'authorization', 'x-csrf-token', 'x-xsrf-token'];
|
$allowed = [
|
||||||
|
'user-agent',
|
||||||
|
'accept',
|
||||||
|
'accept-language',
|
||||||
|
'accept-encoding',
|
||||||
|
'referer',
|
||||||
|
'origin',
|
||||||
|
'x-requested-with',
|
||||||
|
'x-forwarded-for',
|
||||||
|
'x-real-ip',
|
||||||
|
'cf-connecting-ip',
|
||||||
|
'x-client-ip',
|
||||||
|
];
|
||||||
|
|
||||||
foreach ($sensitive as $key) {
|
$headers = array_intersect_key($headers, array_flip($allowed));
|
||||||
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;
|
return $headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an IP address is private or reserved.
|
||||||
|
*/
|
||||||
|
protected function isPrivateIp(string $ip): bool
|
||||||
|
{
|
||||||
|
// Normalise IPv4-mapped IPv6 addresses
|
||||||
|
$ip = preg_replace('/^::ffff:/i', '', $ip);
|
||||||
|
|
||||||
|
return filter_var(
|
||||||
|
$ip,
|
||||||
|
FILTER_VALIDATE_IP,
|
||||||
|
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||||
|
) === false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The teapot response body.
|
* The teapot response body.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,61 @@ class HoneypotHit extends Model
|
||||||
'is_bot' => 'boolean',
|
'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.
|
* Severity levels for honeypot hits.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ namespace Core\Mod\Hub\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
class Service extends Model
|
class Service extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -121,6 +122,55 @@ class Service extends Model
|
||||||
return null;
|
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.
|
* Check if a specific metadata key exists.
|
||||||
*/
|
*/
|
||||||
|
|
@ -139,9 +189,17 @@ class Service extends Model
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a metadata value.
|
* 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
|
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 = $this->metadata ?? [];
|
||||||
$metadata[$key] = $value;
|
$metadata[$key] = $value;
|
||||||
$this->metadata = $metadata;
|
$this->metadata = $metadata;
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ beforeEach(function () {
|
||||||
|
|
||||||
// Clear rate limiter between tests
|
// Clear rate limiter between tests
|
||||||
RateLimiter::clear('honeypot:log:192.168.1.100');
|
RateLimiter::clear('honeypot:log:192.168.1.100');
|
||||||
|
RateLimiter::clear('honeypot:log:203.0.113.50');
|
||||||
|
RateLimiter::clear('honeypot:log:127.0.0.1');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(function () {
|
afterEach(function () {
|
||||||
|
|
@ -472,13 +474,14 @@ describe('TeapotController', function () {
|
||||||
it('records critical severity for admin paths', function () {
|
it('records critical severity for admin paths', function () {
|
||||||
$controller = new TeapotController();
|
$controller = new TeapotController();
|
||||||
$request = Request::create('/admin', 'GET');
|
$request = Request::create('/admin', 'GET');
|
||||||
|
$request->server->set('REMOTE_ADDR', '203.0.113.50');
|
||||||
|
|
||||||
$mockGeoIp = Mockery::mock(DetectLocation::class);
|
$mockGeoIp = Mockery::mock(DetectLocation::class);
|
||||||
$mockGeoIp->shouldReceive('getCountryCode')->andReturn(null);
|
$mockGeoIp->shouldReceive('getCountryCode')->andReturn(null);
|
||||||
$mockGeoIp->shouldReceive('getCity')->andReturn(null);
|
$mockGeoIp->shouldReceive('getCity')->andReturn(null);
|
||||||
app()->instance(DetectLocation::class, $mockGeoIp);
|
app()->instance(DetectLocation::class, $mockGeoIp);
|
||||||
|
|
||||||
// Critical path should trigger auto-block for non-localhost
|
// Critical path should trigger auto-block for non-private IPs
|
||||||
$mockBlocklist = Mockery::mock(BlocklistService::class);
|
$mockBlocklist = Mockery::mock(BlocklistService::class);
|
||||||
$mockBlocklist->shouldReceive('block')->once();
|
$mockBlocklist->shouldReceive('block')->once();
|
||||||
app()->instance(BlocklistService::class, $mockBlocklist);
|
app()->instance(BlocklistService::class, $mockBlocklist);
|
||||||
|
|
@ -490,14 +493,16 @@ describe('TeapotController', function () {
|
||||||
expect($response->headers->get('X-Severity'))->toBe('critical');
|
expect($response->headers->get('X-Severity'))->toBe('critical');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sanitizes sensitive headers before storing', function () {
|
it('uses whitelist to only store allowed headers', function () {
|
||||||
$controller = new TeapotController();
|
$controller = new TeapotController();
|
||||||
$request = Request::create('/teapot', 'GET');
|
$request = Request::create('/teapot', 'GET');
|
||||||
$request->headers->set('User-Agent', 'TestBot/1.0');
|
$request->headers->set('User-Agent', 'TestBot/1.0');
|
||||||
|
$request->headers->set('Accept-Language', 'en-GB');
|
||||||
$request->headers->set('Cookie', 'session=secret123');
|
$request->headers->set('Cookie', 'session=secret123');
|
||||||
$request->headers->set('Authorization', 'Bearer token123');
|
$request->headers->set('Authorization', 'Bearer token123');
|
||||||
$request->headers->set('X-CSRF-Token', 'csrf123');
|
$request->headers->set('X-CSRF-Token', 'csrf123');
|
||||||
$request->headers->set('X-Custom-Header', 'safe-value');
|
$request->headers->set('X-Custom-Header', 'safe-value');
|
||||||
|
$request->headers->set('X-Forwarded-For', '203.0.113.50');
|
||||||
|
|
||||||
$mockGeoIp = Mockery::mock(DetectLocation::class);
|
$mockGeoIp = Mockery::mock(DetectLocation::class);
|
||||||
$mockGeoIp->shouldReceive('getCountryCode')->andReturn(null);
|
$mockGeoIp->shouldReceive('getCountryCode')->andReturn(null);
|
||||||
|
|
@ -513,13 +518,18 @@ describe('TeapotController', function () {
|
||||||
$hit = HoneypotHit::first();
|
$hit = HoneypotHit::first();
|
||||||
$headers = $hit->headers;
|
$headers = $hit->headers;
|
||||||
|
|
||||||
// Sensitive headers should be removed
|
// Sensitive headers must not be stored
|
||||||
expect($headers)->not->toHaveKey('cookie');
|
expect($headers)->not->toHaveKey('cookie');
|
||||||
expect($headers)->not->toHaveKey('authorization');
|
expect($headers)->not->toHaveKey('authorization');
|
||||||
expect($headers)->not->toHaveKey('x-csrf-token');
|
expect($headers)->not->toHaveKey('x-csrf-token');
|
||||||
|
|
||||||
// Safe headers should be preserved
|
// Non-whitelisted headers must not be stored
|
||||||
expect($headers)->toHaveKey('x-custom-header');
|
expect($headers)->not->toHaveKey('x-custom-header');
|
||||||
|
|
||||||
|
// Whitelisted headers should be preserved
|
||||||
|
expect($headers)->toHaveKey('user-agent');
|
||||||
|
expect($headers)->toHaveKey('accept-language');
|
||||||
|
expect($headers)->toHaveKey('x-forwarded-for');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('truncates long user agent strings', function () {
|
it('truncates long user agent strings', function () {
|
||||||
|
|
@ -573,6 +583,140 @@ describe('TeapotController', function () {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Private IP Detection Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('Private IP detection', function () {
|
||||||
|
it('detects IPv4 loopback as private', function () {
|
||||||
|
$controller = new TeapotController();
|
||||||
|
$reflection = new ReflectionMethod($controller, 'isPrivateIp');
|
||||||
|
$reflection->setAccessible(true);
|
||||||
|
|
||||||
|
expect($reflection->invoke($controller, '127.0.0.1'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects IPv6 loopback as private', function () {
|
||||||
|
$controller = new TeapotController();
|
||||||
|
$reflection = new ReflectionMethod($controller, 'isPrivateIp');
|
||||||
|
$reflection->setAccessible(true);
|
||||||
|
|
||||||
|
expect($reflection->invoke($controller, '::1'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects IPv4-mapped IPv6 loopback as private', function () {
|
||||||
|
$controller = new TeapotController();
|
||||||
|
$reflection = new ReflectionMethod($controller, 'isPrivateIp');
|
||||||
|
$reflection->setAccessible(true);
|
||||||
|
|
||||||
|
expect($reflection->invoke($controller, '::ffff:127.0.0.1'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects private network ranges as private', function () {
|
||||||
|
$controller = new TeapotController();
|
||||||
|
$reflection = new ReflectionMethod($controller, 'isPrivateIp');
|
||||||
|
$reflection->setAccessible(true);
|
||||||
|
|
||||||
|
expect($reflection->invoke($controller, '10.0.0.1'))->toBeTrue();
|
||||||
|
expect($reflection->invoke($controller, '172.17.0.1'))->toBeTrue();
|
||||||
|
expect($reflection->invoke($controller, '192.168.1.1'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows public IP addresses', function () {
|
||||||
|
$controller = new TeapotController();
|
||||||
|
$reflection = new ReflectionMethod($controller, 'isPrivateIp');
|
||||||
|
$reflection->setAccessible(true);
|
||||||
|
|
||||||
|
expect($reflection->invoke($controller, '8.8.8.8'))->toBeFalse();
|
||||||
|
expect($reflection->invoke($controller, '203.0.113.50'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips auto-block for private IPs on critical paths', function () {
|
||||||
|
$controller = new TeapotController();
|
||||||
|
$request = Request::create('/admin', 'GET');
|
||||||
|
$request->server->set('REMOTE_ADDR', '192.168.1.100');
|
||||||
|
|
||||||
|
$mockGeoIp = Mockery::mock(DetectLocation::class);
|
||||||
|
$mockGeoIp->shouldReceive('getCountryCode')->andReturn(null);
|
||||||
|
$mockGeoIp->shouldReceive('getCity')->andReturn(null);
|
||||||
|
app()->instance(DetectLocation::class, $mockGeoIp);
|
||||||
|
|
||||||
|
$mockBlocklist = Mockery::mock(BlocklistService::class);
|
||||||
|
$mockBlocklist->shouldNotReceive('block');
|
||||||
|
app()->instance(BlocklistService::class, $mockBlocklist);
|
||||||
|
|
||||||
|
$response = $controller($request);
|
||||||
|
|
||||||
|
expect($response->getStatusCode())->toBe(418);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Referer Validation Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('Referer validation', function () {
|
||||||
|
it('stores valid URL referers', function () {
|
||||||
|
$controller = new TeapotController();
|
||||||
|
$request = Request::create('/teapot', 'GET');
|
||||||
|
$request->headers->set('Referer', 'https://example.com/page');
|
||||||
|
|
||||||
|
$mockGeoIp = Mockery::mock(DetectLocation::class);
|
||||||
|
$mockGeoIp->shouldReceive('getCountryCode')->andReturn(null);
|
||||||
|
$mockGeoIp->shouldReceive('getCity')->andReturn(null);
|
||||||
|
app()->instance(DetectLocation::class, $mockGeoIp);
|
||||||
|
|
||||||
|
$mockBlocklist = Mockery::mock(BlocklistService::class);
|
||||||
|
$mockBlocklist->shouldReceive('block')->andReturn(null);
|
||||||
|
app()->instance(BlocklistService::class, $mockBlocklist);
|
||||||
|
|
||||||
|
$controller($request);
|
||||||
|
|
||||||
|
$hit = HoneypotHit::first();
|
||||||
|
expect($hit->referer)->toBe('https://example.com/page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces invalid referers with marker', function () {
|
||||||
|
$controller = new TeapotController();
|
||||||
|
$request = Request::create('/teapot', 'GET');
|
||||||
|
$request->headers->set('Referer', '<script>alert("xss")</script>');
|
||||||
|
|
||||||
|
$mockGeoIp = Mockery::mock(DetectLocation::class);
|
||||||
|
$mockGeoIp->shouldReceive('getCountryCode')->andReturn(null);
|
||||||
|
$mockGeoIp->shouldReceive('getCity')->andReturn(null);
|
||||||
|
app()->instance(DetectLocation::class, $mockGeoIp);
|
||||||
|
|
||||||
|
$mockBlocklist = Mockery::mock(BlocklistService::class);
|
||||||
|
$mockBlocklist->shouldReceive('block')->andReturn(null);
|
||||||
|
app()->instance(BlocklistService::class, $mockBlocklist);
|
||||||
|
|
||||||
|
$controller($request);
|
||||||
|
|
||||||
|
$hit = HoneypotHit::first();
|
||||||
|
expect($hit->referer)->toBe('invalid-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores empty string for missing referer', function () {
|
||||||
|
$controller = new TeapotController();
|
||||||
|
$request = Request::create('/teapot', 'GET');
|
||||||
|
// No Referer header set
|
||||||
|
|
||||||
|
$mockGeoIp = Mockery::mock(DetectLocation::class);
|
||||||
|
$mockGeoIp->shouldReceive('getCountryCode')->andReturn(null);
|
||||||
|
$mockGeoIp->shouldReceive('getCity')->andReturn(null);
|
||||||
|
app()->instance(DetectLocation::class, $mockGeoIp);
|
||||||
|
|
||||||
|
$mockBlocklist = Mockery::mock(BlocklistService::class);
|
||||||
|
$mockBlocklist->shouldReceive('block')->andReturn(null);
|
||||||
|
app()->instance(BlocklistService::class, $mockBlocklist);
|
||||||
|
|
||||||
|
$controller($request);
|
||||||
|
|
||||||
|
$hit = HoneypotHit::first();
|
||||||
|
expect($hit->referer)->toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Integration Tests
|
// Integration Tests
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -581,6 +725,7 @@ describe('Honeypot integration', function () {
|
||||||
it('creates hit record with all fields populated', function () {
|
it('creates hit record with all fields populated', function () {
|
||||||
$controller = new TeapotController();
|
$controller = new TeapotController();
|
||||||
$request = Request::create('/wp-admin/admin.php', 'POST');
|
$request = Request::create('/wp-admin/admin.php', 'POST');
|
||||||
|
$request->server->set('REMOTE_ADDR', '203.0.113.50');
|
||||||
$request->headers->set('User-Agent', 'python-requests/2.28.1');
|
$request->headers->set('User-Agent', 'python-requests/2.28.1');
|
||||||
$request->headers->set('Referer', 'https://malicious-site.com/scanner');
|
$request->headers->set('Referer', 'https://malicious-site.com/scanner');
|
||||||
$request->headers->set('Accept-Language', 'en-US,en;q=0.9');
|
$request->headers->set('Accept-Language', 'en-US,en;q=0.9');
|
||||||
|
|
|
||||||
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