lthn.io/app/Core/Headers/SecurityHeaders.php
Claude 41a90cbff8
feat: lthn.io API serving live chain data
Fixed: basePath self→static binding, namespace detection, event wiring,
SQLite cache, file cache driver. All Mod Boot classes converted to
$listens pattern for lifecycle event discovery.

Working endpoints:
- /v1/explorer/info — live chain height, difficulty, aliases
- /v1/explorer/stats — formatted chain statistics
- /v1/names/directory — alias directory grouped by type
- /v1/names/available/{name} — name availability check
- /v1/names/lookup/{name} — name details

Co-Authored-By: Charon <charon@lethean.io>
2026-04-03 17:17:42 +01:00

459 lines
14 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);
namespace Core\Headers;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
/**
* Add security headers to all responses.
*
* Headers added:
* - Strict-Transport-Security (HSTS) - enforce HTTPS
* - Content-Security-Policy - restrict resource loading
* - X-Content-Type-Options - prevent MIME sniffing
* - X-Frame-Options - prevent clickjacking
* - X-XSS-Protection - enable browser XSS filtering
* - Referrer-Policy - control referrer information
* - Permissions-Policy - control browser features
*
* Supports nonce-based CSP for inline scripts and styles via CspNonceService.
*
* ## Usage
*
* Register in your HTTP kernel or route middleware:
*
* ```php
* // app/Http/Kernel.php
* protected $middleware = [
* // ...
* \Core\Headers\SecurityHeaders::class,
* ];
* ```
*
* ## CSP Directive Resolution
*
* CSP directives are built in this order:
* 1. Base directives from `config('headers.csp.directives')`
* 2. Environment-specific overrides from `config('headers.csp.environment')`
* 3. Nonces added to script-src/style-src (if enabled)
* 4. CDN sources from `config('core.cdn.subdomain')`
* 5. External service sources (jsDelivr, Google Analytics, etc.)
* 6. Development WebSocket sources (localhost:8080)
* 7. Report URI (if configured)
*
* ## Report-Only Mode
*
* Enable `SECURITY_CSP_REPORT_ONLY=true` to log violations without blocking.
* This uses the `Content-Security-Policy-Report-Only` header instead.
*
* ## HSTS Behaviour
*
* HSTS is only added in production environments to avoid issues with
* local development over HTTP. Configure via:
*
* - `SECURITY_HSTS_ENABLED` - Enable/disable HSTS
* - `SECURITY_HSTS_MAX_AGE` - Max age in seconds (default: 1 year)
* - `SECURITY_HSTS_INCLUDE_SUBDOMAINS` - Include subdomains
* - `SECURITY_HSTS_PRELOAD` - Enable preload flag for browser preload lists
*
* @see CspNonceService For nonce generation
* @see Boot For configuration documentation
*/
class SecurityHeaders
{
/**
* The CSP nonce service.
*/
protected ?CspNonceService $nonceService = null;
public function __construct(?CspNonceService $nonceService = null)
{
$this->nonceService = $nonceService ?? App::make(CspNonceService::class);
}
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! config('headers.enabled', true)) {
return $response;
}
$this->addHstsHeader($response);
$this->addCspHeader($response);
$this->addPermissionsPolicyHeader($response);
$this->addStandardSecurityHeaders($response);
return $response;
}
/**
* Get the CSP nonce service.
*/
public function getNonceService(): CspNonceService
{
return $this->nonceService;
}
/**
* Add Strict-Transport-Security header.
*/
protected function addHstsHeader(Response $response): void
{
$config = config('headers.hsts', []);
if (! ($config['enabled'] ?? true)) {
return;
}
// Only add HSTS in production to avoid issues with local development
if (! app()->environment('production')) {
return;
}
$maxAge = $config['max_age'] ?? 31536000;
$value = "max-age={$maxAge}";
if ($config['include_subdomains'] ?? true) {
$value .= '; includeSubDomains';
}
if ($config['preload'] ?? true) {
$value .= '; preload';
}
$response->headers->set('Strict-Transport-Security', $value);
}
/**
* Add Content-Security-Policy header.
*/
protected function addCspHeader(Response $response): void
{
$config = config('headers.csp', []);
if (! ($config['enabled'] ?? true)) {
return;
}
$directives = $this->buildCspDirectives($config);
$cspValue = $this->formatCspDirectives($directives);
$headerName = ($config['report_only'] ?? false)
? 'Content-Security-Policy-Report-Only'
: 'Content-Security-Policy';
$response->headers->set($headerName, $cspValue);
}
/**
* Build CSP directives from configuration.
*
* @return array<string, array<string>>
*/
protected function buildCspDirectives(array $config): array
{
$directives = $config['directives'] ?? $this->getDefaultCspDirectives();
// Apply environment-specific overrides
$directives = $this->applyEnvironmentOverrides($directives, $config);
// Add nonces for script-src and style-src if enabled
$directives = $this->addNonceDirectives($directives, $config);
// Add CDN subdomain sources
$directives = $this->addCdnSources($directives, $config);
// Add external service sources
$directives = $this->addExternalSources($directives, $config);
// Add WebSocket sources for development
$directives = $this->addDevelopmentWebsocketSources($directives);
// Add report-uri if configured
if (! empty($config['report_uri'])) {
$directives['report-uri'] = [$config['report_uri']];
}
return $directives;
}
/**
* Add nonce directives for script-src and style-src.
*
* When nonce-based CSP is enabled, nonces are added to script-src and
* style-src directives, allowing inline scripts/styles that include
* the matching nonce attribute.
*
* @return array<string, array<string>>
*/
protected function addNonceDirectives(array $directives, array $config): array
{
$nonceEnabled = $config['nonce_enabled'] ?? true;
// Skip if nonces are disabled
if (! $nonceEnabled || ! $this->nonceService?->isEnabled()) {
return $directives;
}
// Don't add nonces in local/development environments with unsafe-inline
// as it would be redundant and could cause issues
$environment = app()->environment();
$skipNonceEnvs = $config['nonce_skip_environments'] ?? ['local', 'development'];
if (in_array($environment, $skipNonceEnvs, true)) {
return $directives;
}
$nonce = $this->nonceService->getCspNonceDirective();
$nonceDirectives = $config['nonce_directives'] ?? ['script-src', 'style-src'];
foreach ($nonceDirectives as $directive) {
if (isset($directives[$directive])) {
// Remove unsafe-inline if present and add nonce
// Nonces are more secure than unsafe-inline
$directives[$directive] = array_filter(
$directives[$directive],
fn ($value) => $value !== "'unsafe-inline'"
);
$directives[$directive][] = $nonce;
}
}
return $directives;
}
/**
* Get default CSP directives.
*
* @return array<string, array<string>>
*/
protected function getDefaultCspDirectives(): array
{
return [
'default-src' => ["'self'"],
'script-src' => ["'self'"],
'style-src' => ["'self'", 'https://fonts.bunny.net', 'https://fonts.googleapis.com'],
'img-src' => ["'self'", 'data:', 'https:', 'blob:'],
'font-src' => ["'self'", 'https://fonts.bunny.net', 'https://fonts.gstatic.com', 'data:'],
'connect-src' => ["'self'"],
'frame-src' => ["'self'", 'https://www.youtube.com', 'https://player.vimeo.com'],
'frame-ancestors' => ["'self'"],
'base-uri' => ["'self'"],
'form-action' => ["'self'"],
'object-src' => ["'none'"],
];
}
/**
* Apply environment-specific CSP overrides.
*
* @return array<string, array<string>>
*/
protected function applyEnvironmentOverrides(array $directives, array $config): array
{
$environment = app()->environment();
$envOverrides = $config['environment'][$environment] ?? [];
foreach ($envOverrides as $directive => $sources) {
if (isset($directives[$directive])) {
$directives[$directive] = array_unique(array_merge($directives[$directive], $sources));
} else {
$directives[$directive] = $sources;
}
}
return $directives;
}
/**
* Add CDN subdomain to relevant directives.
*
* @return array<string, array<string>>
*/
protected function addCdnSources(array $directives, array $config): array
{
$baseDomain = config('core.domain.base', 'core.test');
$cdnSubdomain = config('core.cdn.subdomain', 'cdn');
$cdnUrl = "https://{$cdnSubdomain}.{$baseDomain}";
$cdnConfig = $config['external']['cdn'] ?? [];
foreach ($cdnConfig as $directive => $enabled) {
if ($enabled && isset($directives[$directive])) {
$directives[$directive][] = $cdnUrl;
}
}
// Add base domain for connect-src (WebSocket, API calls)
if (isset($directives['connect-src'])) {
$directives['connect-src'][] = "https://*.{$baseDomain}";
$directives['connect-src'][] = "wss://*.{$baseDomain}";
$directives['connect-src'][] = "wss://{$baseDomain}:8080";
}
return $directives;
}
/**
* Add external service sources based on configuration.
*
* @return array<string, array<string>>
*/
protected function addExternalSources(array $directives, array $config): array
{
$external = $config['external'] ?? [];
foreach ($external as $service => $serviceConfig) {
// Skip CDN (handled separately) and disabled services
if ($service === 'cdn') {
continue;
}
if (! ($serviceConfig['enabled'] ?? false)) {
continue;
}
foreach ($serviceConfig as $directive => $sources) {
if ($directive === 'enabled' || ! is_array($sources)) {
continue;
}
if (isset($directives[$directive])) {
$directives[$directive] = array_merge($directives[$directive], $sources);
}
}
}
return $directives;
}
/**
* Add WebSocket sources for development environments.
*
* @return array<string, array<string>>
*/
protected function addDevelopmentWebsocketSources(array $directives): array
{
if (app()->environment('production')) {
return $directives;
}
if (isset($directives['connect-src'])) {
$directives['connect-src'] = array_merge($directives['connect-src'], [
'wss://localhost:8080',
'ws://localhost:8080',
'wss://127.0.0.1:8080',
'ws://127.0.0.1:8080',
]);
}
return $directives;
}
/**
* Format CSP directives into header value.
*/
protected function formatCspDirectives(array $directives): string
{
$parts = [];
foreach ($directives as $directive => $sources) {
$uniqueSources = array_unique($sources);
$parts[] = $directive.' '.implode(' ', $uniqueSources);
}
return implode('; ', $parts);
}
/**
* Add Permissions-Policy header.
*/
protected function addPermissionsPolicyHeader(Response $response): void
{
$config = config('headers.permissions', []);
if (! ($config['enabled'] ?? true)) {
return;
}
$features = $config['features'] ?? $this->getDefaultPermissionsPolicy();
$parts = [];
foreach ($features as $feature => $allowList) {
if (empty($allowList)) {
$parts[] = "{$feature}=()";
} else {
$formatted = array_map(fn ($origin) => $origin === 'self' ? 'self' : "\"{$origin}\"", $allowList);
$parts[] = "{$feature}=(".implode(' ', $formatted).')';
}
}
$response->headers->set('Permissions-Policy', implode(', ', $parts));
}
/**
* Get default Permissions-Policy features.
*
* @return array<string, array<string>>
*/
protected function getDefaultPermissionsPolicy(): array
{
return [
'accelerometer' => [],
'autoplay' => ['self'],
'camera' => [],
'encrypted-media' => ['self'],
'fullscreen' => ['self'],
'geolocation' => [],
'gyroscope' => [],
'magnetometer' => [],
'microphone' => [],
'payment' => [],
'picture-in-picture' => ['self'],
'sync-xhr' => ['self'],
'usb' => [],
];
}
/**
* Add standard security headers.
*/
protected function addStandardSecurityHeaders(Response $response): void
{
$response->headers->set(
'X-Content-Type-Options',
config('headers.x_content_type_options', 'nosniff')
);
$response->headers->set(
'X-Frame-Options',
config('headers.x_frame_options', 'SAMEORIGIN')
);
$response->headers->set(
'X-XSS-Protection',
config('headers.x_xss_protection', '1; mode=block')
);
$response->headers->set(
'Referrer-Policy',
config('headers.referrer_policy', 'strict-origin-when-cross-origin')
);
}
}