Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception\Authentication;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Ausnahme für gesperrte Benutzerkonten
|
||||
*
|
||||
* Verwendet OWASP-konforme Nachrichten für Account-Sperrungen
|
||||
*/
|
||||
final class AccountLockedException extends FrameworkException
|
||||
{
|
||||
/**
|
||||
* @param string $identifier Benutzer-Identifikator
|
||||
* @param int $failedAttempts Anzahl der Fehlversuche die zur Sperrung führten
|
||||
* @param int $lockDurationMinutes Dauer der Sperrung in Minuten
|
||||
* @param \Throwable|null $previous Vorherige Ausnahme
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $identifier,
|
||||
public readonly int $failedAttempts,
|
||||
public readonly int $lockDurationMinutes = 15,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
// OWASP-konforme Nachricht mit Platzhaltern
|
||||
$message = "User {$this->identifier} account locked after {$this->failedAttempts} failed attempts";
|
||||
|
||||
$unlockTime = new \DateTimeImmutable("+{$this->lockDurationMinutes} minutes");
|
||||
|
||||
$context = ExceptionContext::forOperation('authentication.account_lock', 'Auth')
|
||||
->withData([
|
||||
'user_identifier' => $this->identifier,
|
||||
'failed_attempts' => $this->failedAttempts,
|
||||
'lock_duration_minutes' => $this->lockDurationMinutes,
|
||||
'unlock_time' => $unlockTime->format('Y-m-d H:i:s'),
|
||||
'event_identifier' => "authn_account_locked:{$this->identifier},{$this->failedAttempts}",
|
||||
'category' => 'authentication',
|
||||
'requires_alert' => true, // Account-Sperrungen sind immer alert-würdig
|
||||
])
|
||||
->withMetadata([
|
||||
'security_event' => true,
|
||||
'owasp_compliant' => true,
|
||||
'log_level' => 'WARN',
|
||||
'critical_security_event' => true,
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: 423, // Locked
|
||||
previous: $previous,
|
||||
errorCode: ErrorCode::AUTH_ACCOUNT_LOCKED,
|
||||
retryAfter: $this->lockDurationMinutes * 60 // Retry nach Sperrzeit in Sekunden
|
||||
);
|
||||
}
|
||||
|
||||
// === Factory Methods für verschiedene Sperr-Szenarien ===
|
||||
|
||||
public static function tooManyFailedAttempts(string $identifier, int $attempts = 5): self
|
||||
{
|
||||
return new self($identifier, $attempts, 15); // 15 Minuten Standard-Sperrzeit
|
||||
}
|
||||
|
||||
public static function suspiciousActivity(string $identifier, int $lockDurationMinutes = 60): self
|
||||
{
|
||||
return new self($identifier, 0, $lockDurationMinutes);
|
||||
}
|
||||
|
||||
public static function administrativeLock(string $identifier): self
|
||||
{
|
||||
return new self($identifier, 0, 0); // 0 = permanente Sperrung
|
||||
}
|
||||
|
||||
public static function bruteForceDetected(string $identifier, int $attempts): self
|
||||
{
|
||||
return new self($identifier, $attempts, 120); // 2 Stunden bei Brute Force
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt OWASP-konforme Event-Daten zurück
|
||||
*/
|
||||
public function getSecurityEventData(): array
|
||||
{
|
||||
return [
|
||||
'event_identifier' => "authn_account_locked:{$this->identifier},{$this->failedAttempts}",
|
||||
'description' => "User {$this->identifier} account locked after {$this->failedAttempts} failed attempts",
|
||||
'category' => 'authentication',
|
||||
'log_level' => 'WARN',
|
||||
'requires_alert' => true,
|
||||
'user_identifier' => $this->identifier,
|
||||
'failed_attempts' => $this->failedAttempts,
|
||||
'lock_duration_minutes' => $this->lockDurationMinutes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob die Sperrung permanent ist
|
||||
*/
|
||||
public function isPermanentLock(): bool
|
||||
{
|
||||
return $this->lockDurationMinutes === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Entsperrzeit
|
||||
*/
|
||||
public function getUnlockTime(): ?\DateTimeImmutable
|
||||
{
|
||||
if ($this->isPermanentLock()) {
|
||||
return null; // Permanente Sperrung
|
||||
}
|
||||
|
||||
return new \DateTimeImmutable("+{$this->lockDurationMinutes} minutes");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt verbleibende Sperrzeit in Sekunden zurück
|
||||
*/
|
||||
public function getRemainingLockTimeSeconds(): ?int
|
||||
{
|
||||
$unlockTime = $this->getUnlockTime();
|
||||
if ($unlockTime === null) {
|
||||
return null; // Permanente Sperrung
|
||||
}
|
||||
|
||||
$now = new \DateTimeImmutable();
|
||||
$diff = $unlockTime->getTimestamp() - $now->getTimestamp();
|
||||
|
||||
return max(0, $diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Account-Sperrungen erfordern immer Alerts
|
||||
*/
|
||||
public function requiresAlert(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception\Authentication;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Ausnahme für unzureichende Berechtigungen
|
||||
*
|
||||
* Verwendet OWASP-konforme Nachrichten für Authorization-Fehler
|
||||
*/
|
||||
final class InsufficientPrivilegesException extends FrameworkException
|
||||
{
|
||||
/**
|
||||
* @param string $userId Benutzer-Identifikator
|
||||
* @param string $resource Geschützte Ressource
|
||||
* @param string $action Versuchte Aktion
|
||||
* @param array $requiredRoles Erforderliche Rollen/Berechtigungen
|
||||
* @param array $userRoles Aktuelle Benutzer-Rollen
|
||||
* @param \Throwable|null $previous Vorherige Ausnahme
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $userId,
|
||||
public readonly string $resource,
|
||||
public readonly string $action,
|
||||
public readonly array $requiredRoles = [],
|
||||
public readonly array $userRoles = [],
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
// OWASP-konforme Nachricht mit Platzhaltern
|
||||
$message = "User {$this->userId} access denied to {$this->resource}";
|
||||
|
||||
$context = ExceptionContext::forOperation('authorization.access_check', 'Auth')
|
||||
->withData([
|
||||
'user_identifier' => $this->userId,
|
||||
'resource' => $this->resource,
|
||||
'action' => $this->action,
|
||||
'required_roles' => $this->requiredRoles,
|
||||
'user_roles' => $this->userRoles,
|
||||
'missing_roles' => $this->getMissingRoles(),
|
||||
'event_identifier' => "authz_access_denied:{$this->userId},{$this->resource}",
|
||||
'category' => 'authorization',
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
])
|
||||
->withMetadata([
|
||||
'security_event' => true,
|
||||
'owasp_compliant' => true,
|
||||
'log_level' => 'WARN',
|
||||
'authorization_failure' => true,
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: 403, // Forbidden
|
||||
previous: $previous,
|
||||
errorCode: ErrorCode::AUTH_INSUFFICIENT_PRIVILEGES
|
||||
);
|
||||
}
|
||||
|
||||
// === Factory Methods für verschiedene Authorization-Szenarien ===
|
||||
|
||||
public static function resourceAccess(string $userId, string $resource, array $requiredRoles = []): self
|
||||
{
|
||||
return new self($userId, $resource, 'access', $requiredRoles);
|
||||
}
|
||||
|
||||
public static function adminAction(string $userId, string $action): self
|
||||
{
|
||||
return new self($userId, 'admin_panel', $action, ['admin', 'super_admin']);
|
||||
}
|
||||
|
||||
public static function apiEndpoint(string $userId, string $endpoint, array $requiredScopes = []): self
|
||||
{
|
||||
return new self($userId, $endpoint, 'api_call', $requiredScopes);
|
||||
}
|
||||
|
||||
public static function fileAccess(string $userId, string $filePath, string $action = 'read'): self
|
||||
{
|
||||
return new self($userId, $filePath, $action, ['file_access']);
|
||||
}
|
||||
|
||||
public static function dataAccess(string $userId, string $dataType, string $action = 'read'): self
|
||||
{
|
||||
return new self($userId, $dataType, $action, ['data_access']);
|
||||
}
|
||||
|
||||
public static function privilegeEscalation(string $userId, string $targetRole): self
|
||||
{
|
||||
return new self($userId, $targetRole, 'privilege_escalation', ['admin']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt OWASP-konforme Event-Daten zurück
|
||||
*/
|
||||
public function getSecurityEventData(): array
|
||||
{
|
||||
return [
|
||||
'event_identifier' => "authz_access_denied:{$this->userId},{$this->resource}",
|
||||
'description' => "User {$this->userId} access denied to {$this->resource}",
|
||||
'category' => 'authorization',
|
||||
'log_level' => 'WARN',
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
'user_identifier' => $this->userId,
|
||||
'resource' => $this->resource,
|
||||
'action' => $this->action,
|
||||
'required_roles' => $this->requiredRoles,
|
||||
'user_roles' => $this->userRoles,
|
||||
'missing_roles' => $this->getMissingRoles(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet fehlende Rollen/Berechtigungen
|
||||
*/
|
||||
public function getMissingRoles(): array
|
||||
{
|
||||
return array_diff($this->requiredRoles, $this->userRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Benutzer alle erforderlichen Rollen hat
|
||||
*/
|
||||
public function hasRequiredRoles(): bool
|
||||
{
|
||||
return empty($this->getMissingRoles());
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob der Access-Denial verdächtig ist und ein Alert erfordert
|
||||
*/
|
||||
public function requiresAlert(): bool
|
||||
{
|
||||
// Alert bei Admin-Aktionen oder Privilege-Escalation-Versuchen
|
||||
if ($this->action === 'privilege_escalation') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (str_contains($this->resource, 'admin') || str_contains($this->action, 'admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Alert bei sensiblen Ressourcen
|
||||
$sensitiveResources = ['user_data', 'financial_data', 'system_config', 'security_settings'];
|
||||
foreach ($sensitiveResources as $sensitive) {
|
||||
if (str_contains($this->resource, $sensitive)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt benutzerfreundliche Fehlermeldung zurück
|
||||
*/
|
||||
public function getUserMessage(): string
|
||||
{
|
||||
if (empty($this->requiredRoles)) {
|
||||
return "You don't have permission to access this resource.";
|
||||
}
|
||||
|
||||
$missingRoles = $this->getMissingRoles();
|
||||
if (count($missingRoles) === 1) {
|
||||
return "You need the '{$missingRoles[0]}' role to access this resource.";
|
||||
} elseif (count($missingRoles) > 1) {
|
||||
$roleList = implode(', ', $missingRoles);
|
||||
|
||||
return "You need one of these roles to access this resource: {$roleList}";
|
||||
}
|
||||
|
||||
return "Access denied. Please contact your administrator for the required permissions.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Empfehlung für die nächsten Schritte zurück
|
||||
*/
|
||||
public function getRecommendation(): string
|
||||
{
|
||||
if ($this->requiresAlert()) {
|
||||
return "Contact your system administrator for access to this resource.";
|
||||
}
|
||||
|
||||
return "Please request the necessary permissions from your team lead or administrator.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es ein potentieller Privilege-Escalation-Versuch ist
|
||||
*/
|
||||
public function isPrivilegeEscalationAttempt(): bool
|
||||
{
|
||||
return $this->action === 'privilege_escalation' ||
|
||||
str_contains($this->action, 'escalat') ||
|
||||
(in_array('admin', $this->requiredRoles) && ! in_array('admin', $this->userRoles));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception\Authentication;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Ausnahme für ungültige Anmeldedaten
|
||||
*
|
||||
* Verwendet OWASP-konforme Nachrichten für Authentifizierungsfehler
|
||||
*/
|
||||
final class InvalidCredentialsException extends FrameworkException
|
||||
{
|
||||
/**
|
||||
* @param string $identifier Benutzer-Identifikator (E-Mail, Username, etc.)
|
||||
* @param string $reason Grund für den Fehler (invalid_credentials, password_expired, etc.)
|
||||
* @param int $attemptCount Anzahl der Fehlversuche
|
||||
* @param \Throwable|null $previous Vorherige Ausnahme
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $identifier,
|
||||
public readonly string $reason = 'invalid_credentials',
|
||||
public readonly int $attemptCount = 1,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
// OWASP-konforme Nachricht mit Platzhaltern
|
||||
$message = "User {$this->identifier} authentication failure";
|
||||
|
||||
$context = ExceptionContext::forOperation('authentication.login', 'Auth')
|
||||
->withData([
|
||||
'user_identifier' => $this->identifier,
|
||||
'failure_reason' => $this->reason,
|
||||
'attempt_count' => $this->attemptCount,
|
||||
'event_identifier' => "authn_login_fail:{$this->identifier}",
|
||||
'category' => 'authentication',
|
||||
'requires_alert' => $this->attemptCount >= 3, // Alert ab 3 Versuchen
|
||||
])
|
||||
->withMetadata([
|
||||
'security_event' => true,
|
||||
'owasp_compliant' => true,
|
||||
'log_level' => 'WARN',
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: 401, // Unauthorized
|
||||
previous: $previous,
|
||||
errorCode: ErrorCode::AUTH_CREDENTIALS_INVALID
|
||||
);
|
||||
}
|
||||
|
||||
// === Factory Methods für verschiedene Authentifizierungsfehler ===
|
||||
|
||||
public static function invalidPassword(string $identifier, int $attemptCount = 1): self
|
||||
{
|
||||
return new self($identifier, 'invalid_password', $attemptCount);
|
||||
}
|
||||
|
||||
public static function invalidUsername(string $identifier, int $attemptCount = 1): self
|
||||
{
|
||||
return new self($identifier, 'invalid_username', $attemptCount);
|
||||
}
|
||||
|
||||
public static function passwordExpired(string $identifier): self
|
||||
{
|
||||
return new self($identifier, 'password_expired', 1);
|
||||
}
|
||||
|
||||
public static function twoFactorRequired(string $identifier): self
|
||||
{
|
||||
return new self($identifier, 'two_factor_required', 1);
|
||||
}
|
||||
|
||||
public static function accountNotVerified(string $identifier): self
|
||||
{
|
||||
return new self($identifier, 'account_not_verified', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt OWASP-konforme Event-Daten zurück
|
||||
*/
|
||||
public function getSecurityEventData(): array
|
||||
{
|
||||
return [
|
||||
'event_identifier' => "authn_login_fail:{$this->identifier}",
|
||||
'description' => "User {$this->identifier} login failure",
|
||||
'category' => 'authentication',
|
||||
'log_level' => 'WARN',
|
||||
'requires_alert' => $this->attemptCount >= 3,
|
||||
'user_identifier' => $this->identifier,
|
||||
'failure_reason' => $this->reason,
|
||||
'attempt_count' => $this->attemptCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Alert erforderlich ist (basierend auf Anzahl Versuche)
|
||||
*/
|
||||
public function requiresAlert(): bool
|
||||
{
|
||||
return $this->attemptCount >= 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt sicheren Identifier für Logs zurück (ohne sensible Daten)
|
||||
*/
|
||||
public function getSafeIdentifier(): string
|
||||
{
|
||||
// Wenn E-Mail, zeige nur ersten Teil
|
||||
if (str_contains($this->identifier, '@')) {
|
||||
$parts = explode('@', $this->identifier);
|
||||
|
||||
return substr($parts[0], 0, 2) . '***@' . $parts[1];
|
||||
}
|
||||
|
||||
// Bei anderen Identifiern zeige nur ersten Teil
|
||||
return substr($this->identifier, 0, 3) . '***';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception\Authentication;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Ausnahme für Session-Timeouts
|
||||
*
|
||||
* Verwendet OWASP-konforme Nachrichten für Session-Management
|
||||
*/
|
||||
final class SessionTimeoutException extends FrameworkException
|
||||
{
|
||||
/**
|
||||
* @param string $sessionId Session-Identifikator
|
||||
* @param string $userId Benutzer-Identifikator
|
||||
* @param int $timeoutMinutes Timeout-Dauer in Minuten
|
||||
* @param string $reason Grund für Timeout (inactivity, absolute, etc.)
|
||||
* @param \Throwable|null $previous Vorherige Ausnahme
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $sessionId,
|
||||
public readonly string $userId,
|
||||
public readonly int $timeoutMinutes,
|
||||
public readonly string $reason = 'inactivity',
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
// OWASP-konforme Nachricht mit Platzhaltern
|
||||
$message = "User {$this->userId} session timeout after {$this->timeoutMinutes} minutes of {$this->reason}";
|
||||
|
||||
$context = ExceptionContext::forOperation('authentication.session_timeout', 'Auth')
|
||||
->withData([
|
||||
'session_id' => $this->sessionId,
|
||||
'user_identifier' => $this->userId,
|
||||
'timeout_minutes' => $this->timeoutMinutes,
|
||||
'timeout_reason' => $this->reason,
|
||||
'event_identifier' => "session_timeout:{$this->userId},{$this->reason}",
|
||||
'category' => 'session_management',
|
||||
'requires_alert' => false, // Session-Timeouts sind normal
|
||||
])
|
||||
->withMetadata([
|
||||
'security_event' => true,
|
||||
'owasp_compliant' => true,
|
||||
'log_level' => 'INFO',
|
||||
'session_management' => true,
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: 401, // Unauthorized
|
||||
previous: $previous,
|
||||
errorCode: ErrorCode::AUTH_SESSION_EXPIRED
|
||||
);
|
||||
}
|
||||
|
||||
// === Factory Methods für verschiedene Timeout-Szenarien ===
|
||||
|
||||
public static function inactivityTimeout(string $sessionId, string $userId, int $timeoutMinutes = 30): self
|
||||
{
|
||||
return new self($sessionId, $userId, $timeoutMinutes, 'inactivity');
|
||||
}
|
||||
|
||||
public static function absoluteTimeout(string $sessionId, string $userId, int $timeoutMinutes = 480): self
|
||||
{
|
||||
return new self($sessionId, $userId, $timeoutMinutes, 'absolute_timeout');
|
||||
}
|
||||
|
||||
public static function concurrentSessionLimit(string $sessionId, string $userId): self
|
||||
{
|
||||
return new self($sessionId, $userId, 0, 'concurrent_session_limit');
|
||||
}
|
||||
|
||||
public static function securityPolicyTimeout(string $sessionId, string $userId, int $timeoutMinutes): self
|
||||
{
|
||||
return new self($sessionId, $userId, $timeoutMinutes, 'security_policy');
|
||||
}
|
||||
|
||||
public static function administrativeTermination(string $sessionId, string $userId): self
|
||||
{
|
||||
return new self($sessionId, $userId, 0, 'administrative_termination');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt OWASP-konforme Event-Daten zurück
|
||||
*/
|
||||
public function getSecurityEventData(): array
|
||||
{
|
||||
return [
|
||||
'event_identifier' => "session_timeout:{$this->userId},{$this->reason}",
|
||||
'description' => "User {$this->userId} session timeout after {$this->timeoutMinutes} minutes of {$this->reason}",
|
||||
'category' => 'session_management',
|
||||
'log_level' => 'INFO',
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
'session_id' => $this->getSessionIdHash(), // Gehashte Session-ID für Security
|
||||
'user_identifier' => $this->userId,
|
||||
'timeout_minutes' => $this->timeoutMinutes,
|
||||
'timeout_reason' => $this->reason,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt gehashte Session-ID für sicheres Logging zurück
|
||||
*/
|
||||
public function getSessionIdHash(): string
|
||||
{
|
||||
return substr(hash('sha256', $this->sessionId), 0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob der Timeout verdächtig ist und ein Alert erfordert
|
||||
*/
|
||||
public function requiresAlert(): bool
|
||||
{
|
||||
return match ($this->reason) {
|
||||
'concurrent_session_limit' => true,
|
||||
'security_policy' => true,
|
||||
'administrative_termination' => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob die Session automatisch verlängert werden kann
|
||||
*/
|
||||
public function isRenewable(): bool
|
||||
{
|
||||
return match ($this->reason) {
|
||||
'inactivity' => true,
|
||||
'absolute_timeout' => false,
|
||||
'concurrent_session_limit' => false,
|
||||
'security_policy' => false,
|
||||
'administrative_termination' => false,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt empfohlene Aktion für den Benutzer zurück
|
||||
*/
|
||||
public function getRecommendedAction(): string
|
||||
{
|
||||
return match ($this->reason) {
|
||||
'inactivity' => 'Please log in again to continue',
|
||||
'absolute_timeout' => 'Session has reached maximum duration. Please log in again',
|
||||
'concurrent_session_limit' => 'Maximum concurrent sessions reached. Please close other sessions or log in again',
|
||||
'security_policy' => 'Session terminated due to security policy. Please log in again',
|
||||
'administrative_termination' => 'Session was terminated by administrator. Please contact support if needed',
|
||||
default => 'Please log in again to continue',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt benutzerfreundliche Timeout-Nachricht zurück
|
||||
*/
|
||||
public function getUserMessage(): string
|
||||
{
|
||||
if ($this->timeoutMinutes === 0) {
|
||||
return $this->getRecommendedAction();
|
||||
}
|
||||
|
||||
if ($this->timeoutMinutes < 60) {
|
||||
return "Your session timed out after {$this->timeoutMinutes} minutes of inactivity. " . $this->getRecommendedAction();
|
||||
} else {
|
||||
$hours = floor($this->timeoutMinutes / 60);
|
||||
$minutes = $this->timeoutMinutes % 60;
|
||||
$timeString = $hours . 'h' . ($minutes > 0 ? " {$minutes}m" : '');
|
||||
|
||||
return "Your session timed out after {$timeString}. " . $this->getRecommendedAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
162
src/Framework/Exception/Authentication/TokenExpiredException.php
Normal file
162
src/Framework/Exception/Authentication/TokenExpiredException.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception\Authentication;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Ausnahme für abgelaufene Authentication-Token
|
||||
*
|
||||
* Verwendet OWASP-konforme Nachrichten für Token-Expiration
|
||||
*/
|
||||
final class TokenExpiredException extends FrameworkException
|
||||
{
|
||||
/**
|
||||
* @param string $tokenType Art des Tokens (session, jwt, api_key, etc.)
|
||||
* @param string $identifier Token-Identifikator oder Benutzer-ID
|
||||
* @param \DateTimeImmutable $expiredAt Zeitpunkt der Ablaufzeit
|
||||
* @param \Throwable|null $previous Vorherige Ausnahme
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $tokenType,
|
||||
public readonly string $identifier,
|
||||
public readonly \DateTimeImmutable $expiredAt,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
// OWASP-konforme Nachricht mit Platzhaltern
|
||||
$message = "User {$this->identifier} {$this->tokenType} token expired";
|
||||
|
||||
$context = ExceptionContext::forOperation('authentication.token_validation', 'Auth')
|
||||
->withData([
|
||||
'token_type' => $this->tokenType,
|
||||
'user_identifier' => $this->identifier,
|
||||
'expired_at' => $this->expiredAt->format('Y-m-d H:i:s'),
|
||||
'expired_since_seconds' => $this->getExpiredSinceSeconds(),
|
||||
'event_identifier' => "authn_token_expired:{$this->identifier},{$this->tokenType}",
|
||||
'category' => 'authentication',
|
||||
'requires_alert' => false, // Token-Ablauf ist normal
|
||||
])
|
||||
->withMetadata([
|
||||
'security_event' => true,
|
||||
'owasp_compliant' => true,
|
||||
'log_level' => 'INFO',
|
||||
'token_expired' => true,
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: 401, // Unauthorized
|
||||
previous: $previous,
|
||||
errorCode: ErrorCode::AUTH_TOKEN_EXPIRED
|
||||
);
|
||||
}
|
||||
|
||||
// === Factory Methods für verschiedene Token-Typen ===
|
||||
|
||||
public static function sessionToken(string $sessionId, \DateTimeImmutable $expiredAt): self
|
||||
{
|
||||
return new self('session', $sessionId, $expiredAt);
|
||||
}
|
||||
|
||||
public static function jwtToken(string $userId, \DateTimeImmutable $expiredAt): self
|
||||
{
|
||||
return new self('jwt', $userId, $expiredAt);
|
||||
}
|
||||
|
||||
public static function apiKey(string $keyId, \DateTimeImmutable $expiredAt): self
|
||||
{
|
||||
return new self('api_key', $keyId, $expiredAt);
|
||||
}
|
||||
|
||||
public static function refreshToken(string $userId, \DateTimeImmutable $expiredAt): self
|
||||
{
|
||||
return new self('refresh_token', $userId, $expiredAt);
|
||||
}
|
||||
|
||||
public static function passwordResetToken(string $userId, \DateTimeImmutable $expiredAt): self
|
||||
{
|
||||
return new self('password_reset', $userId, $expiredAt);
|
||||
}
|
||||
|
||||
public static function twoFactorToken(string $userId, \DateTimeImmutable $expiredAt): self
|
||||
{
|
||||
return new self('two_factor', $userId, $expiredAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt OWASP-konforme Event-Daten zurück
|
||||
*/
|
||||
public function getSecurityEventData(): array
|
||||
{
|
||||
return [
|
||||
'event_identifier' => "authn_token_expired:{$this->identifier},{$this->tokenType}",
|
||||
'description' => "User {$this->identifier} {$this->tokenType} token expired",
|
||||
'category' => 'authentication',
|
||||
'log_level' => 'INFO',
|
||||
'requires_alert' => false,
|
||||
'token_type' => $this->tokenType,
|
||||
'user_identifier' => $this->identifier,
|
||||
'expired_at' => $this->expiredAt->format('Y-m-d H:i:s'),
|
||||
'expired_since_seconds' => $this->getExpiredSinceSeconds(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet wie lange der Token schon abgelaufen ist (in Sekunden)
|
||||
*/
|
||||
public function getExpiredSinceSeconds(): int
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
|
||||
return max(0, $now->getTimestamp() - $this->expiredAt->getTimestamp());
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob der Token kürzlich abgelaufen ist (< 5 Minuten)
|
||||
*/
|
||||
public function isRecentlyExpired(): bool
|
||||
{
|
||||
return $this->getExpiredSinceSeconds() < 300; // 5 Minuten
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob der Token schon länger abgelaufen ist (> 1 Stunde)
|
||||
*/
|
||||
public function isLongExpired(): bool
|
||||
{
|
||||
return $this->getExpiredSinceSeconds() > 3600; // 1 Stunde
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt eine benutzerfreundliche Nachricht über die Ablaufzeit zurück
|
||||
*/
|
||||
public function getExpirationMessage(): string
|
||||
{
|
||||
$expiredSince = $this->getExpiredSinceSeconds();
|
||||
|
||||
if ($expiredSince < 60) {
|
||||
return "Token expired {$expiredSince} seconds ago";
|
||||
} elseif ($expiredSince < 3600) {
|
||||
$minutes = floor($expiredSince / 60);
|
||||
|
||||
return "Token expired {$minutes} minutes ago";
|
||||
} else {
|
||||
$hours = floor($expiredSince / 3600);
|
||||
|
||||
return "Token expired {$hours} hours ago";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-Ablauf erfordert normalerweise keine Alerts
|
||||
*/
|
||||
public function requiresAlert(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ final class ConsoleException extends FrameworkException
|
||||
'command' => $command,
|
||||
'exit_code' => $exitCode,
|
||||
'output' => $output,
|
||||
'working_directory' => getcwd()
|
||||
'working_directory' => getcwd(),
|
||||
]);
|
||||
|
||||
return new self("Console command failed: {$command}", $exitCode, null, $context);
|
||||
@@ -23,7 +23,7 @@ final class ConsoleException extends FrameworkException
|
||||
{
|
||||
$context = ExceptionContext::forOperation('console.command_lookup', 'ConsoleRunner')
|
||||
->withData([
|
||||
'command' => $command
|
||||
'command' => $command,
|
||||
]);
|
||||
|
||||
return new self("Console command not found: {$command}", 404, null, $context);
|
||||
@@ -35,7 +35,7 @@ final class ConsoleException extends FrameworkException
|
||||
->withData([
|
||||
'command' => $command,
|
||||
'arguments' => $arguments,
|
||||
'validation_errors' => $errors
|
||||
'validation_errors' => $errors,
|
||||
]);
|
||||
|
||||
return new self("Invalid arguments for command: {$command}", 400, null, $context);
|
||||
|
||||
@@ -4,58 +4,143 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception;
|
||||
|
||||
final class DatabaseException extends FrameworkException
|
||||
/**
|
||||
* Database-spezifische Exception mit vorgefertigten Error Codes
|
||||
*/
|
||||
class DatabaseException extends FrameworkException
|
||||
{
|
||||
public static function connectionFailed(string $dsn, \Throwable $previous): self
|
||||
{
|
||||
$context = ExceptionContext::forOperation('database.connect', 'PDO')
|
||||
// === Factory Methods für häufige Database Errors ===
|
||||
|
||||
public static function connectionFailed(
|
||||
?string $details = null,
|
||||
?\Throwable $previous = null
|
||||
): static {
|
||||
$context = ExceptionContext::forOperation('database.connect', 'Database')
|
||||
->withData(['details' => $details]);
|
||||
|
||||
$message = $details ? "Database connection failed: $details" : null;
|
||||
|
||||
return static::create(
|
||||
ErrorCode::DB_CONNECTION_FAILED,
|
||||
$message,
|
||||
$context,
|
||||
$previous
|
||||
);
|
||||
}
|
||||
|
||||
public static function queryFailed(
|
||||
string $sql,
|
||||
?string $error = null,
|
||||
?\Throwable $previous = null
|
||||
): static {
|
||||
$context = ExceptionContext::forOperation('database.query', 'Database')
|
||||
->withData(['sql' => $sql, 'error' => $error])
|
||||
->withDebug(['query_length' => strlen($sql)]);
|
||||
|
||||
return static::create(
|
||||
ErrorCode::DB_QUERY_FAILED,
|
||||
"Query execution failed: $error",
|
||||
$context,
|
||||
$previous
|
||||
);
|
||||
}
|
||||
|
||||
public static function constraintViolation(
|
||||
string $constraint,
|
||||
?string $table = null,
|
||||
?array $data = null,
|
||||
?\Throwable $previous = null
|
||||
): static {
|
||||
$context = ExceptionContext::forOperation('database.constraint', 'Database')
|
||||
->withData([
|
||||
'dsn' => self::sanitizeDsn($dsn),
|
||||
'driver' => explode(':', $dsn)[0] ?? 'unknown'
|
||||
'constraint' => $constraint,
|
||||
'table' => $table,
|
||||
'violation_data' => $data,
|
||||
]);
|
||||
|
||||
return new self('Database connection failed', 0, $previous, $context);
|
||||
return static::create(
|
||||
ErrorCode::DB_CONSTRAINT_VIOLATION,
|
||||
"Database constraint violation: $constraint" . ($table ? " on table $table" : ''),
|
||||
$context,
|
||||
$previous
|
||||
);
|
||||
}
|
||||
|
||||
public static function queryFailed(string $query, array $params, \Throwable $previous): self
|
||||
{
|
||||
$context = ExceptionContext::forOperation('database.query', 'PDO')
|
||||
public static function transactionFailed(
|
||||
?string $details = null,
|
||||
?array $operations = null,
|
||||
?\Throwable $previous = null
|
||||
): static {
|
||||
$context = ExceptionContext::forOperation('database.transaction', 'Database')
|
||||
->withData(['details' => $details, 'operations' => $operations]);
|
||||
|
||||
return static::create(
|
||||
ErrorCode::DB_TRANSACTION_FAILED,
|
||||
"Transaction failed: $details",
|
||||
$context,
|
||||
$previous
|
||||
);
|
||||
}
|
||||
|
||||
public static function poolExhausted(
|
||||
int $maxConnections,
|
||||
int $currentConnections,
|
||||
?\Throwable $previous = null
|
||||
): static {
|
||||
$context = ExceptionContext::forOperation('database.pool', 'Database')
|
||||
->withData([
|
||||
'query' => $query,
|
||||
'parameters' => $params,
|
||||
'query_type' => self::detectQueryType($query)
|
||||
'max_connections' => $maxConnections,
|
||||
'current_connections' => $currentConnections,
|
||||
'usage_percentage' => round(($currentConnections / $maxConnections) * 100, 2),
|
||||
]);
|
||||
|
||||
return new self('Database query failed', 0, $previous, $context);
|
||||
return static::create(
|
||||
ErrorCode::DB_POOL_EXHAUSTED,
|
||||
"Connection pool exhausted ($currentConnections/$maxConnections)",
|
||||
$context,
|
||||
$previous
|
||||
);
|
||||
}
|
||||
|
||||
public static function transactionFailed(string $operation, \Throwable $previous): self
|
||||
{
|
||||
$context = ExceptionContext::forOperation('database.transaction', 'PDO')
|
||||
public static function timeout(
|
||||
float $timeoutSeconds,
|
||||
string $operation = 'query',
|
||||
?string $details = null,
|
||||
?\Throwable $previous = null
|
||||
): static {
|
||||
$context = ExceptionContext::forOperation("database.$operation", 'Database')
|
||||
->withData([
|
||||
'transaction_operation' => $operation
|
||||
'timeout_seconds' => $timeoutSeconds,
|
||||
'operation' => $operation,
|
||||
'details' => $details,
|
||||
]);
|
||||
|
||||
return new self("Database transaction failed: {$operation}", 0, $previous, $context);
|
||||
return static::create(
|
||||
ErrorCode::DB_TIMEOUT,
|
||||
"Database operation '$operation' timed out after {$timeoutSeconds}s",
|
||||
$context,
|
||||
$previous
|
||||
);
|
||||
}
|
||||
|
||||
private static function sanitizeDsn(string $dsn): string
|
||||
{
|
||||
return preg_replace('/password=[^;]+/', 'password=[REDACTED]', $dsn);
|
||||
}
|
||||
public static function migrationFailed(
|
||||
string $migrationName,
|
||||
?string $error = null,
|
||||
?string $version = null,
|
||||
?\Throwable $previous = null
|
||||
): static {
|
||||
$context = ExceptionContext::forOperation('database.migration', 'Database')
|
||||
->withData([
|
||||
'migration' => $migrationName,
|
||||
'version' => $version,
|
||||
'error' => $error,
|
||||
]);
|
||||
|
||||
private static function detectQueryType(string $query): string
|
||||
{
|
||||
$query = trim(strtoupper($query));
|
||||
return match (true) {
|
||||
str_starts_with($query, 'SELECT') => 'SELECT',
|
||||
str_starts_with($query, 'INSERT') => 'INSERT',
|
||||
str_starts_with($query, 'UPDATE') => 'UPDATE',
|
||||
str_starts_with($query, 'DELETE') => 'DELETE',
|
||||
str_starts_with($query, 'CREATE') => 'CREATE',
|
||||
str_starts_with($query, 'DROP') => 'DROP',
|
||||
str_starts_with($query, 'ALTER') => 'ALTER',
|
||||
default => 'UNKNOWN'
|
||||
};
|
||||
return static::create(
|
||||
ErrorCode::DB_MIGRATION_FAILED,
|
||||
"Migration '$migrationName' failed: $error",
|
||||
$context,
|
||||
$previous
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ final class DirectoryCreateException extends FrameworkException
|
||||
'permissions' => is_dir(dirname($directory)) ? decoct(fileperms(dirname($directory))) : 'unknown',
|
||||
'parent_exists' => is_dir(dirname($directory)),
|
||||
'parent_writable' => is_writable(dirname($directory)),
|
||||
'disk_free_space' => disk_free_space(dirname($directory))
|
||||
'disk_free_space' => disk_free_space(dirname($directory)),
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
|
||||
@@ -17,7 +17,7 @@ final class DirectoryListException extends FrameworkException
|
||||
'exists' => is_dir($directory),
|
||||
'readable' => is_readable($directory),
|
||||
'permissions' => is_dir($directory) ? decoct(fileperms($directory)) : 'unknown',
|
||||
'file_count' => is_readable($directory) ? count(scandir($directory)) - 2 : 'unknown'
|
||||
'file_count' => is_readable($directory) ? count(scandir($directory)) - 2 : 'unknown',
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
|
||||
290
src/Framework/Exception/ErrorCode.php
Normal file
290
src/Framework/Exception/ErrorCode.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception;
|
||||
|
||||
/**
|
||||
* Systematische Error Codes für bessere Fehlerklassifizierung
|
||||
*/
|
||||
enum ErrorCode: string
|
||||
{
|
||||
// System-Level Errors (SYS)
|
||||
case SYSTEM_CONFIG_MISSING = 'SYS001';
|
||||
case SYSTEM_CONFIG_INVALID = 'SYS002';
|
||||
case SYSTEM_DEPENDENCY_MISSING = 'SYS003';
|
||||
case SYSTEM_RESOURCE_EXHAUSTED = 'SYS004';
|
||||
case SYSTEM_INITIALIZATION_FAILED = 'SYS005';
|
||||
|
||||
// Database Errors (DB)
|
||||
case DB_CONNECTION_FAILED = 'DB001';
|
||||
case DB_QUERY_FAILED = 'DB002';
|
||||
case DB_CONSTRAINT_VIOLATION = 'DB003';
|
||||
case DB_TRANSACTION_FAILED = 'DB004';
|
||||
case DB_MIGRATION_FAILED = 'DB005';
|
||||
case DB_POOL_EXHAUSTED = 'DB006';
|
||||
case DB_TIMEOUT = 'DB007';
|
||||
|
||||
// Authentication Errors (AUTH)
|
||||
case AUTH_CREDENTIALS_INVALID = 'AUTH001';
|
||||
case AUTH_TOKEN_EXPIRED = 'AUTH002';
|
||||
case AUTH_TOKEN_INVALID = 'AUTH003';
|
||||
case AUTH_USER_LOCKED = 'AUTH004';
|
||||
case AUTH_SESSION_EXPIRED = 'AUTH005';
|
||||
case AUTH_INSUFFICIENT_PRIVILEGES = 'AUTH006';
|
||||
|
||||
// Validation Errors (VAL)
|
||||
case VAL_REQUIRED_FIELD_MISSING = 'VAL001';
|
||||
case VAL_INVALID_FORMAT = 'VAL002';
|
||||
case VAL_OUT_OF_RANGE = 'VAL003';
|
||||
case VAL_DUPLICATE_VALUE = 'VAL004';
|
||||
case VAL_BUSINESS_RULE_VIOLATION = 'VAL005';
|
||||
|
||||
// HTTP Errors (HTTP)
|
||||
case HTTP_NOT_FOUND = 'HTTP001';
|
||||
case HTTP_METHOD_NOT_ALLOWED = 'HTTP002';
|
||||
case HTTP_RATE_LIMIT_EXCEEDED = 'HTTP003';
|
||||
case HTTP_PAYLOAD_TOO_LARGE = 'HTTP004';
|
||||
case HTTP_UNSUPPORTED_MEDIA_TYPE = 'HTTP005';
|
||||
|
||||
// Security Errors (SEC)
|
||||
case SEC_XSS_ATTEMPT = 'SEC001';
|
||||
case SEC_SQL_INJECTION_ATTEMPT = 'SEC002';
|
||||
case SEC_PATH_TRAVERSAL_ATTEMPT = 'SEC003';
|
||||
case SEC_CSRF_TOKEN_MISMATCH = 'SEC004';
|
||||
case SEC_UNAUTHORIZED_ACCESS = 'SEC005';
|
||||
case SEC_SUSPICIOUS_ACTIVITY = 'SEC006';
|
||||
|
||||
// Cache Errors (CACHE)
|
||||
case CACHE_CONNECTION_FAILED = 'CACHE001';
|
||||
case CACHE_KEY_NOT_FOUND = 'CACHE002';
|
||||
case CACHE_SERIALIZATION_FAILED = 'CACHE003';
|
||||
case CACHE_EVICTION_FAILED = 'CACHE004';
|
||||
|
||||
// File System Errors (FS)
|
||||
case FS_FILE_NOT_FOUND = 'FS001';
|
||||
case FS_PERMISSION_DENIED = 'FS002';
|
||||
case FS_DISK_FULL = 'FS003';
|
||||
case FS_DIRECTORY_NOT_WRITABLE = 'FS004';
|
||||
case FS_UPLOAD_FAILED = 'FS005';
|
||||
|
||||
// External API Errors (API)
|
||||
case API_SERVICE_UNAVAILABLE = 'API001';
|
||||
case API_RATE_LIMIT_EXCEEDED = 'API002';
|
||||
case API_AUTHENTICATION_FAILED = 'API003';
|
||||
case API_INVALID_RESPONSE = 'API004';
|
||||
case API_TIMEOUT = 'API005';
|
||||
|
||||
// Service Errors (SVC)
|
||||
case SERVICE_CIRCUIT_OPEN = 'SVC001';
|
||||
case SERVICE_CIRCUIT_HALF_OPEN = 'SVC002';
|
||||
case SERVICE_HEALTH_CHECK_FAILED = 'SVC003';
|
||||
case SERVICE_DEGRADED = 'SVC004';
|
||||
|
||||
// Business Logic Errors (BIZ)
|
||||
case BIZ_WORKFLOW_VIOLATION = 'BIZ001';
|
||||
case BIZ_INSUFFICIENT_BALANCE = 'BIZ002';
|
||||
case BIZ_OPERATION_NOT_ALLOWED = 'BIZ003';
|
||||
case BIZ_QUOTA_EXCEEDED = 'BIZ004';
|
||||
|
||||
// Search Errors (SEARCH)
|
||||
case SEARCH_INDEX_FAILED = 'SEARCH001';
|
||||
case SEARCH_UPDATE_FAILED = 'SEARCH002';
|
||||
case SEARCH_DELETE_FAILED = 'SEARCH003';
|
||||
case SEARCH_CONFIG_INVALID = 'SEARCH004';
|
||||
case SEARCH_ENGINE_UNAVAILABLE = 'SEARCH005';
|
||||
|
||||
public function getCategory(): string
|
||||
{
|
||||
return substr($this->value, 0, strpos($this->value, '0') ?: 3);
|
||||
}
|
||||
|
||||
public function getNumericCode(): int
|
||||
{
|
||||
return (int) substr($this->value, -3);
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match($this) {
|
||||
// System Errors
|
||||
self::SYSTEM_CONFIG_MISSING => 'Required configuration is missing',
|
||||
self::SYSTEM_CONFIG_INVALID => 'Configuration contains invalid values',
|
||||
self::SYSTEM_DEPENDENCY_MISSING => 'Required system dependency is not available',
|
||||
self::SYSTEM_RESOURCE_EXHAUSTED => 'System resources are exhausted',
|
||||
self::SYSTEM_INITIALIZATION_FAILED => 'System initialization failed',
|
||||
|
||||
// Database Errors
|
||||
self::DB_CONNECTION_FAILED => 'Database connection could not be established',
|
||||
self::DB_QUERY_FAILED => 'Database query execution failed',
|
||||
self::DB_CONSTRAINT_VIOLATION => 'Database constraint violation occurred',
|
||||
self::DB_TRANSACTION_FAILED => 'Database transaction failed',
|
||||
self::DB_MIGRATION_FAILED => 'Database migration failed',
|
||||
self::DB_POOL_EXHAUSTED => 'Database connection pool exhausted',
|
||||
self::DB_TIMEOUT => 'Database operation timed out',
|
||||
|
||||
// Authentication Errors
|
||||
self::AUTH_CREDENTIALS_INVALID => 'Provided credentials are invalid',
|
||||
self::AUTH_TOKEN_EXPIRED => 'Authentication token has expired',
|
||||
self::AUTH_TOKEN_INVALID => 'Authentication token is invalid',
|
||||
self::AUTH_USER_LOCKED => 'User account is locked',
|
||||
self::AUTH_SESSION_EXPIRED => 'User session has expired',
|
||||
self::AUTH_INSUFFICIENT_PRIVILEGES => 'Insufficient privileges for this operation',
|
||||
|
||||
// Validation Errors
|
||||
self::VAL_REQUIRED_FIELD_MISSING => 'Required field is missing',
|
||||
self::VAL_INVALID_FORMAT => 'Field has invalid format',
|
||||
self::VAL_OUT_OF_RANGE => 'Value is out of allowed range',
|
||||
self::VAL_DUPLICATE_VALUE => 'Value already exists',
|
||||
self::VAL_BUSINESS_RULE_VIOLATION => 'Business rule violation',
|
||||
|
||||
// HTTP Errors
|
||||
self::HTTP_NOT_FOUND => 'Requested resource not found',
|
||||
self::HTTP_METHOD_NOT_ALLOWED => 'HTTP method not allowed',
|
||||
self::HTTP_RATE_LIMIT_EXCEEDED => 'Rate limit exceeded',
|
||||
self::HTTP_PAYLOAD_TOO_LARGE => 'Request payload too large',
|
||||
self::HTTP_UNSUPPORTED_MEDIA_TYPE => 'Unsupported media type',
|
||||
|
||||
// Security Errors
|
||||
self::SEC_XSS_ATTEMPT => 'Cross-site scripting attempt detected',
|
||||
self::SEC_SQL_INJECTION_ATTEMPT => 'SQL injection attempt detected',
|
||||
self::SEC_PATH_TRAVERSAL_ATTEMPT => 'Path traversal attempt detected',
|
||||
self::SEC_CSRF_TOKEN_MISMATCH => 'CSRF token mismatch',
|
||||
self::SEC_UNAUTHORIZED_ACCESS => 'Unauthorized access attempt',
|
||||
self::SEC_SUSPICIOUS_ACTIVITY => 'Suspicious activity detected',
|
||||
|
||||
// Cache Errors
|
||||
self::CACHE_CONNECTION_FAILED => 'Cache connection failed',
|
||||
self::CACHE_KEY_NOT_FOUND => 'Cache key not found',
|
||||
self::CACHE_SERIALIZATION_FAILED => 'Cache serialization failed',
|
||||
self::CACHE_EVICTION_FAILED => 'Cache eviction failed',
|
||||
|
||||
// File System Errors
|
||||
self::FS_FILE_NOT_FOUND => 'File not found',
|
||||
self::FS_PERMISSION_DENIED => 'File permission denied',
|
||||
self::FS_DISK_FULL => 'Disk space exhausted',
|
||||
self::FS_DIRECTORY_NOT_WRITABLE => 'Directory is not writable',
|
||||
self::FS_UPLOAD_FAILED => 'File upload failed',
|
||||
|
||||
// External API Errors
|
||||
self::API_SERVICE_UNAVAILABLE => 'External service unavailable',
|
||||
self::API_RATE_LIMIT_EXCEEDED => 'API rate limit exceeded',
|
||||
self::API_AUTHENTICATION_FAILED => 'API authentication failed',
|
||||
self::API_INVALID_RESPONSE => 'Invalid API response received',
|
||||
self::API_TIMEOUT => 'API request timed out',
|
||||
|
||||
// Business Logic Errors
|
||||
self::BIZ_WORKFLOW_VIOLATION => 'Business workflow violation',
|
||||
self::BIZ_INSUFFICIENT_BALANCE => 'Insufficient balance for operation',
|
||||
self::BIZ_OPERATION_NOT_ALLOWED => 'Operation not allowed in current state',
|
||||
self::BIZ_QUOTA_EXCEEDED => 'Usage quota exceeded',
|
||||
|
||||
// Service Errors
|
||||
self::SERVICE_CIRCUIT_OPEN => 'Service circuit breaker is open',
|
||||
self::SERVICE_CIRCUIT_HALF_OPEN => 'Service circuit breaker is half-open',
|
||||
self::SERVICE_HEALTH_CHECK_FAILED => 'Service health check failed',
|
||||
self::SERVICE_DEGRADED => 'Service is in degraded state',
|
||||
|
||||
// Search Errors
|
||||
self::SEARCH_INDEX_FAILED => 'Failed to index document in search engine',
|
||||
self::SEARCH_UPDATE_FAILED => 'Failed to update document in search engine',
|
||||
self::SEARCH_DELETE_FAILED => 'Failed to delete document from search engine',
|
||||
self::SEARCH_CONFIG_INVALID => 'Invalid search configuration',
|
||||
self::SEARCH_ENGINE_UNAVAILABLE => 'Search engine is unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
public function getRecoveryHint(): string
|
||||
{
|
||||
return match($this) {
|
||||
// System Errors
|
||||
self::SYSTEM_CONFIG_MISSING => 'Check configuration files and environment variables',
|
||||
self::SYSTEM_CONFIG_INVALID => 'Review configuration values and fix invalid entries',
|
||||
self::SYSTEM_DEPENDENCY_MISSING => 'Install missing dependencies or check system requirements',
|
||||
self::SYSTEM_RESOURCE_EXHAUSTED => 'Free up system resources or increase limits',
|
||||
self::SYSTEM_INITIALIZATION_FAILED => 'Check system startup logs and fix initialization issues',
|
||||
|
||||
// Database Errors
|
||||
self::DB_CONNECTION_FAILED => 'Check database server status and connection settings',
|
||||
self::DB_QUERY_FAILED => 'Review query syntax and database schema',
|
||||
self::DB_CONSTRAINT_VIOLATION => 'Check data integrity and constraint definitions',
|
||||
self::DB_TRANSACTION_FAILED => 'Retry transaction or check for deadlocks',
|
||||
self::DB_MIGRATION_FAILED => 'Review migration scripts and database state',
|
||||
self::DB_POOL_EXHAUSTED => 'Increase connection pool size or optimize queries',
|
||||
self::DB_TIMEOUT => 'Optimize query performance or increase timeout limits',
|
||||
|
||||
// Authentication Errors
|
||||
self::AUTH_CREDENTIALS_INVALID => 'Verify username and password',
|
||||
self::AUTH_TOKEN_EXPIRED => 'Refresh authentication token',
|
||||
self::AUTH_TOKEN_INVALID => 'Obtain new authentication token',
|
||||
self::AUTH_USER_LOCKED => 'Contact administrator to unlock account',
|
||||
self::AUTH_SESSION_EXPIRED => 'Log in again to create new session',
|
||||
self::AUTH_INSUFFICIENT_PRIVILEGES => 'Request appropriate permissions from administrator',
|
||||
|
||||
// Validation Errors
|
||||
self::VAL_REQUIRED_FIELD_MISSING => 'Provide value for required field',
|
||||
self::VAL_INVALID_FORMAT => 'Correct field format according to requirements',
|
||||
self::VAL_OUT_OF_RANGE => 'Provide value within allowed range',
|
||||
self::VAL_DUPLICATE_VALUE => 'Use unique value that does not already exist',
|
||||
self::VAL_BUSINESS_RULE_VIOLATION => 'Follow business rules and constraints',
|
||||
|
||||
// HTTP Errors
|
||||
self::HTTP_NOT_FOUND => 'Check URL and ensure resource exists',
|
||||
self::HTTP_METHOD_NOT_ALLOWED => 'Use correct HTTP method for this endpoint',
|
||||
self::HTTP_RATE_LIMIT_EXCEEDED => 'Reduce request frequency or wait before retrying',
|
||||
self::HTTP_PAYLOAD_TOO_LARGE => 'Reduce request payload size',
|
||||
self::HTTP_UNSUPPORTED_MEDIA_TYPE => 'Use supported content type',
|
||||
|
||||
// Security Errors
|
||||
self::SEC_XSS_ATTEMPT => 'Review input validation and output encoding',
|
||||
self::SEC_SQL_INJECTION_ATTEMPT => 'Use parameterized queries and input validation',
|
||||
self::SEC_PATH_TRAVERSAL_ATTEMPT => 'Validate and sanitize file paths',
|
||||
self::SEC_CSRF_TOKEN_MISMATCH => 'Include valid CSRF token in request',
|
||||
self::SEC_UNAUTHORIZED_ACCESS => 'Authenticate and ensure proper authorization',
|
||||
self::SEC_SUSPICIOUS_ACTIVITY => 'Review security logs and investigate activity',
|
||||
|
||||
// Search Errors
|
||||
self::SEARCH_INDEX_FAILED => 'Check search engine connectivity and document format',
|
||||
self::SEARCH_UPDATE_FAILED => 'Verify document exists and search engine is operational',
|
||||
self::SEARCH_DELETE_FAILED => 'Ensure document exists in search index',
|
||||
self::SEARCH_CONFIG_INVALID => 'Review search configuration and field mappings',
|
||||
self::SEARCH_ENGINE_UNAVAILABLE => 'Wait for search engine to become available or use fallback',
|
||||
|
||||
// Other errors have generic recovery hint
|
||||
default => 'Check logs for more details and contact support if needed',
|
||||
};
|
||||
}
|
||||
|
||||
public function isRecoverable(): bool
|
||||
{
|
||||
return match($this) {
|
||||
// Non-recoverable errors
|
||||
self::SYSTEM_CONFIG_MISSING,
|
||||
self::SYSTEM_CONFIG_INVALID,
|
||||
self::SYSTEM_DEPENDENCY_MISSING,
|
||||
self::FS_PERMISSION_DENIED,
|
||||
self::AUTH_USER_LOCKED,
|
||||
self::BIZ_WORKFLOW_VIOLATION => false,
|
||||
|
||||
// Recoverable errors (can be retried or handled gracefully)
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
public function getRetryAfterSeconds(): ?int
|
||||
{
|
||||
return match($this) {
|
||||
self::DB_CONNECTION_FAILED => 30,
|
||||
self::DB_TIMEOUT => 60,
|
||||
self::API_SERVICE_UNAVAILABLE => 300,
|
||||
self::API_RATE_LIMIT_EXCEEDED => 60,
|
||||
self::API_TIMEOUT => 30,
|
||||
self::CACHE_CONNECTION_FAILED => 10,
|
||||
self::HTTP_RATE_LIMIT_EXCEEDED => 60,
|
||||
self::SEARCH_ENGINE_UNAVAILABLE => 60,
|
||||
self::SEARCH_INDEX_FAILED => 5,
|
||||
self::SEARCH_UPDATE_FAILED => 5,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,18 +16,20 @@ final readonly class ErrorHandlerContext
|
||||
public RequestContext $request,
|
||||
public SystemContext $system,
|
||||
public array $metadata = []
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public static function create(
|
||||
ExceptionContext $exceptionContext,
|
||||
?RequestContext $requestContext = null,
|
||||
?SystemContext $systemContext = null,
|
||||
array $metadata = []
|
||||
array $metadata = [],
|
||||
?\App\Framework\Performance\MemoryMonitor $memoryMonitor = null
|
||||
): self {
|
||||
return new self(
|
||||
exception: $exceptionContext,
|
||||
request: $requestContext ?? RequestContext::fromGlobals(),
|
||||
system: $systemContext ?? SystemContext::current(),
|
||||
system: $systemContext ?? SystemContext::current($memoryMonitor),
|
||||
metadata: $metadata
|
||||
);
|
||||
}
|
||||
@@ -79,7 +81,7 @@ final readonly class ErrorHandlerContext
|
||||
'exception' => $this->exception->toArray(),
|
||||
'request' => $this->request->toArray(),
|
||||
'system' => $this->system->toArray(),
|
||||
'metadata' => $this->metadata
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -91,12 +93,12 @@ final readonly class ErrorHandlerContext
|
||||
$contexts = [
|
||||
'exception' => $this->exception->toArray(),
|
||||
'request' => $this->request->toArray(),
|
||||
'system' => $this->system->toArray()
|
||||
'system' => $this->system->toArray(),
|
||||
];
|
||||
|
||||
$flattened = [];
|
||||
foreach ($contexts as $prefix => $data) {
|
||||
array_walk($data, function($value, $key) use (&$flattened, $prefix) {
|
||||
array_walk($data, function ($value, $key) use (&$flattened, $prefix) {
|
||||
$flattened[$prefix . '_' . $key] = $value;
|
||||
});
|
||||
}
|
||||
@@ -118,7 +120,7 @@ final readonly class ErrorHandlerContext
|
||||
'memory_usage' => $this->system->memoryUsage,
|
||||
'execution_time' => $this->system->executionTime,
|
||||
'data' => $this->exception->data,
|
||||
'metadata' => $this->metadata
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -133,8 +135,8 @@ final readonly class ErrorHandlerContext
|
||||
$securityLog = [
|
||||
'datetime' => date('c'),
|
||||
'appid' => $appId,
|
||||
'level' => 'INFO',
|
||||
'description' => 'No description',
|
||||
#'level' => 'INFO',
|
||||
#'description' => 'No description',
|
||||
'useragent' => $this->request->userAgent,
|
||||
'source_ip' => $this->request->clientIp,
|
||||
'host_ip' => $this->request->hostIp,
|
||||
@@ -144,7 +146,7 @@ final readonly class ErrorHandlerContext
|
||||
'request_uri' => $this->request->requestUri,
|
||||
'request_method' => $this->request->requestMethod,
|
||||
'region' => $_ENV['AWS_REGION'] ?? 'unknown',
|
||||
'geo' => $_ENV['GEO_LOCATION'] ?? 'unknown'
|
||||
'geo' => $_ENV['GEO_LOCATION'] ?? 'unknown',
|
||||
];
|
||||
|
||||
// Security-Event-spezifische Daten falls verfügbar
|
||||
|
||||
@@ -15,7 +15,8 @@ final readonly class ExceptionContext
|
||||
public array $data = [],
|
||||
public array $debug = [],
|
||||
public array $metadata = []
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public static function empty(): self
|
||||
{
|
||||
@@ -78,7 +79,7 @@ final readonly class ExceptionContext
|
||||
'component' => $this->component,
|
||||
'data' => $this->sanitizeData($this->data),
|
||||
'debug' => $this->debug,
|
||||
'metadata' => $this->metadata
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,22 @@ class FrameworkException extends \RuntimeException
|
||||
{
|
||||
protected ExceptionContext $context;
|
||||
|
||||
protected ?ErrorCode $errorCode;
|
||||
|
||||
protected ?int $retryAfter;
|
||||
|
||||
public function __construct(
|
||||
string $message,
|
||||
ExceptionContext $context,
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null,
|
||||
?ExceptionContext $context = null
|
||||
?ErrorCode $errorCode = null,
|
||||
?int $retryAfter = null
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
$this->context = $context ?? ExceptionContext::empty();
|
||||
$this->context = $context;
|
||||
$this->errorCode = $errorCode;
|
||||
$this->retryAfter = $retryAfter ?? $errorCode?->getRetryAfterSeconds();
|
||||
}
|
||||
|
||||
public function getContext(): ExceptionContext
|
||||
@@ -27,6 +35,7 @@ class FrameworkException extends \RuntimeException
|
||||
{
|
||||
$new = clone $this;
|
||||
$new->context = $context;
|
||||
|
||||
return $new;
|
||||
}
|
||||
|
||||
@@ -60,7 +69,7 @@ class FrameworkException extends \RuntimeException
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
$array = [
|
||||
'class' => static::class,
|
||||
'message' => $this->getMessage(),
|
||||
'code' => $this->getCode(),
|
||||
@@ -69,5 +78,179 @@ class FrameworkException extends \RuntimeException
|
||||
'context' => $this->context->toArray(),
|
||||
'trace' => $this->getTraceAsString(),
|
||||
];
|
||||
|
||||
// ErrorCode-spezifische Daten hinzufügen wenn vorhanden
|
||||
if ($this->errorCode) {
|
||||
$array['error_code'] = $this->errorCode->value;
|
||||
$array['error_category'] = $this->errorCode->getCategory();
|
||||
$array['description'] = $this->errorCode->getDescription();
|
||||
$array['recovery_hint'] = $this->errorCode->getRecoveryHint();
|
||||
$array['is_recoverable'] = $this->errorCode->isRecoverable();
|
||||
$array['retry_after'] = $this->retryAfter;
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
// === Factory Methods ===
|
||||
|
||||
/**
|
||||
* Einfache Exception ohne ErrorCode - für schnelle Verwendung
|
||||
*/
|
||||
public static function simple(
|
||||
string $message,
|
||||
?\Throwable $previous = null,
|
||||
int $code = 0
|
||||
): static {
|
||||
return new static(
|
||||
message: $message,
|
||||
context: ExceptionContext::empty(),
|
||||
code: $code,
|
||||
previous: $previous
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception mit ErrorCode und automatischer Beschreibung
|
||||
*/
|
||||
public static function create(
|
||||
ErrorCode $errorCode,
|
||||
?string $message = null,
|
||||
?ExceptionContext $context = null,
|
||||
?\Throwable $previous = null,
|
||||
int $code = 0
|
||||
): static {
|
||||
$finalMessage = $message ?? $errorCode->getDescription();
|
||||
$finalContext = $context ?? ExceptionContext::empty();
|
||||
|
||||
return new static(
|
||||
message: $finalMessage,
|
||||
context: $finalContext,
|
||||
code: $code,
|
||||
previous: $previous,
|
||||
errorCode: $errorCode
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception mit Operation Context
|
||||
*/
|
||||
public static function forOperation(
|
||||
string $operation,
|
||||
?string $component = null,
|
||||
?string $message = null,
|
||||
?ErrorCode $errorCode = null,
|
||||
?\Throwable $previous = null,
|
||||
int $code = 0
|
||||
): static {
|
||||
$context = ExceptionContext::forOperation($operation, $component);
|
||||
$finalMessage = $message ?? $errorCode?->getDescription() ?? "Operation failed: $operation";
|
||||
|
||||
return new static(
|
||||
message: $finalMessage,
|
||||
context: $context,
|
||||
code: $code,
|
||||
previous: $previous,
|
||||
errorCode: $errorCode
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception mit vollständigem Context und Daten
|
||||
*/
|
||||
public static function fromContext(
|
||||
string $message,
|
||||
ExceptionContext $context,
|
||||
?ErrorCode $errorCode = null,
|
||||
?\Throwable $previous = null,
|
||||
int $code = 0
|
||||
): static {
|
||||
return new static(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: $code,
|
||||
previous: $previous,
|
||||
errorCode: $errorCode
|
||||
);
|
||||
}
|
||||
|
||||
// === ErrorCode Getter Methods ===
|
||||
|
||||
public function getErrorCode(): ?ErrorCode
|
||||
{
|
||||
return $this->errorCode;
|
||||
}
|
||||
|
||||
public function getRetryAfter(): ?int
|
||||
{
|
||||
return $this->retryAfter;
|
||||
}
|
||||
|
||||
public function isRecoverable(): bool
|
||||
{
|
||||
return $this->errorCode?->isRecoverable() ?? false;
|
||||
}
|
||||
|
||||
public function getRecoveryHint(): ?string
|
||||
{
|
||||
return $this->errorCode?->getRecoveryHint();
|
||||
}
|
||||
|
||||
// === ErrorCode Utility Methods ===
|
||||
|
||||
/**
|
||||
* Prüft ob Exception von bestimmtem Error Code Typ ist
|
||||
*/
|
||||
public function isErrorCode(ErrorCode $errorCode): bool
|
||||
{
|
||||
return $this->errorCode === $errorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Exception zu bestimmter Kategorie gehört
|
||||
*/
|
||||
public function isCategory(string $category): bool
|
||||
{
|
||||
return $this->errorCode?->getCategory() === strtoupper($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt ErrorCode nachträglich
|
||||
*/
|
||||
public function withErrorCode(ErrorCode $errorCode): self
|
||||
{
|
||||
$new = clone $this;
|
||||
$new->errorCode = $errorCode;
|
||||
$new->retryAfter = $errorCode->getRetryAfterSeconds();
|
||||
|
||||
return $new;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt Custom Retry-Zeit
|
||||
*/
|
||||
public function withRetryAfter(int $seconds): self
|
||||
{
|
||||
$new = clone $this;
|
||||
$new->retryAfter = $seconds;
|
||||
|
||||
return $new;
|
||||
}
|
||||
|
||||
/**
|
||||
* String-Representation für Logging
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
$errorCodePart = $this->errorCode ? '[' . $this->errorCode->value . ']' : '';
|
||||
|
||||
return sprintf(
|
||||
'%s %s: %s in %s:%d',
|
||||
static::class,
|
||||
$errorCodePart,
|
||||
$this->getMessage(),
|
||||
$this->getFile(),
|
||||
$this->getLine()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
314
src/Framework/Exception/Http/InvalidContentTypeException.php
Normal file
314
src/Framework/Exception/Http/InvalidContentTypeException.php
Normal file
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception\Http;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Ausnahme für ungültige Content-Types
|
||||
*
|
||||
* Verwendet OWASP-konforme Nachrichten für Content-Type-Validierung
|
||||
*/
|
||||
final class InvalidContentTypeException extends FrameworkException
|
||||
{
|
||||
/**
|
||||
* @param string $clientIp Client-IP für Security-Tracking
|
||||
* @param string $providedContentType Gesendeter Content-Type
|
||||
* @param array $allowedContentTypes Erlaubte Content-Types
|
||||
* @param string $endpoint Betroffener Endpoint
|
||||
* @param \Throwable|null $previous Vorherige Ausnahme
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $clientIp,
|
||||
public readonly string $providedContentType,
|
||||
public readonly array $allowedContentTypes,
|
||||
public readonly string $endpoint = '',
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
$allowedList = implode(', ', $this->allowedContentTypes);
|
||||
|
||||
// OWASP-konforme Nachricht mit Platzhaltern
|
||||
$message = "Client {$this->clientIp} sent invalid content type '{$this->providedContentType}' to {$this->endpoint}";
|
||||
|
||||
$context = ExceptionContext::forOperation('http.content_type_validation', 'Http')
|
||||
->withData([
|
||||
'client_ip' => $this->clientIp,
|
||||
'provided_content_type' => $this->providedContentType,
|
||||
'allowed_content_types' => $this->allowedContentTypes,
|
||||
'endpoint' => $this->endpoint,
|
||||
'allowed_types_count' => count($this->allowedContentTypes),
|
||||
'event_identifier' => "http_invalid_content_type:{$this->clientIp},{$this->endpoint}",
|
||||
'category' => 'http_validation',
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
])
|
||||
->withMetadata([
|
||||
'security_event' => true,
|
||||
'owasp_compliant' => true,
|
||||
'log_level' => 'INFO',
|
||||
'http_error' => true,
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: 415, // Unsupported Media Type
|
||||
previous: $previous,
|
||||
errorCode: ErrorCode::HTTP_UNSUPPORTED_MEDIA_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
// === Factory Methods für verschiedene Content-Type-Szenarien ===
|
||||
|
||||
public static function jsonRequired(string $clientIp, string $providedType, string $endpoint = ''): self
|
||||
{
|
||||
return new self($clientIp, $providedType, ['application/json'], $endpoint);
|
||||
}
|
||||
|
||||
public static function xmlRequired(string $clientIp, string $providedType, string $endpoint = ''): self
|
||||
{
|
||||
return new self($clientIp, $providedType, ['application/xml', 'text/xml'], $endpoint);
|
||||
}
|
||||
|
||||
public static function formDataRequired(string $clientIp, string $providedType, string $endpoint = ''): self
|
||||
{
|
||||
return new self($clientIp, $providedType, ['application/x-www-form-urlencoded'], $endpoint);
|
||||
}
|
||||
|
||||
public static function multipartRequired(string $clientIp, string $providedType, string $endpoint = ''): self
|
||||
{
|
||||
return new self($clientIp, $providedType, ['multipart/form-data'], $endpoint);
|
||||
}
|
||||
|
||||
public static function textRequired(string $clientIp, string $providedType, string $endpoint = ''): self
|
||||
{
|
||||
return new self($clientIp, $providedType, ['text/plain'], $endpoint);
|
||||
}
|
||||
|
||||
public static function apiEndpoint(string $clientIp, string $providedType, string $endpoint = ''): self
|
||||
{
|
||||
return new self($clientIp, $providedType, [
|
||||
'application/json',
|
||||
'application/xml',
|
||||
'application/x-www-form-urlencoded',
|
||||
], $endpoint);
|
||||
}
|
||||
|
||||
public static function fileUpload(string $clientIp, string $providedType, array $allowedTypes, string $endpoint = ''): self
|
||||
{
|
||||
return new self($clientIp, $providedType, $allowedTypes, $endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt OWASP-konforme Event-Daten zurück
|
||||
*/
|
||||
public function getSecurityEventData(): array
|
||||
{
|
||||
return [
|
||||
'event_identifier' => "http_invalid_content_type:{$this->clientIp},{$this->endpoint}",
|
||||
'description' => "Client {$this->clientIp} sent invalid content type '{$this->providedContentType}' to {$this->endpoint}",
|
||||
'category' => 'http_validation',
|
||||
'log_level' => 'INFO',
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
'client_ip' => $this->clientIp,
|
||||
'provided_content_type' => $this->providedContentType,
|
||||
'allowed_content_types' => $this->allowedContentTypes,
|
||||
'endpoint' => $this->endpoint,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Alert erforderlich ist (bei verdächtigen Content-Types)
|
||||
*/
|
||||
public function requiresAlert(): bool
|
||||
{
|
||||
// Alert bei potentiell gefährlichen Content-Types
|
||||
$dangerousTypes = [
|
||||
'application/x-shockwave-flash',
|
||||
'application/x-executable',
|
||||
'application/octet-stream',
|
||||
'text/html', // Wenn nicht erwartet
|
||||
'text/javascript',
|
||||
'application/javascript',
|
||||
];
|
||||
|
||||
foreach ($dangerousTypes as $dangerous) {
|
||||
if (str_contains(strtolower($this->providedContentType), $dangerous)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Alert bei API-Endpoints und ungewöhnlichen Types
|
||||
if (str_contains($this->endpoint, '/api/') && ! $this->isCommonApiContentType()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es ein häufiger API-Content-Type ist
|
||||
*/
|
||||
private function isCommonApiContentType(): bool
|
||||
{
|
||||
$commonApiTypes = [
|
||||
'application/json',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
'application/x-www-form-urlencoded',
|
||||
'multipart/form-data',
|
||||
'text/plain',
|
||||
];
|
||||
|
||||
foreach ($commonApiTypes as $common) {
|
||||
if (str_contains(strtolower($this->providedContentType), $common)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt benutzerfreundliche Fehlermeldung zurück
|
||||
*/
|
||||
public function getUserMessage(): string
|
||||
{
|
||||
$allowedList = $this->formatContentTypeList($this->allowedContentTypes);
|
||||
|
||||
if (empty($this->endpoint)) {
|
||||
return "Unsupported content type '{$this->providedContentType}'. Supported types: {$allowedList}";
|
||||
}
|
||||
|
||||
return "Endpoint '{$this->endpoint}' does not support content type '{$this->providedContentType}'. Supported types: {$allowedList}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert Content-Type-Liste für Benutzer
|
||||
*/
|
||||
private function formatContentTypeList(array $contentTypes): string
|
||||
{
|
||||
if (count($contentTypes) === 1) {
|
||||
return $contentTypes[0];
|
||||
}
|
||||
|
||||
if (count($contentTypes) === 2) {
|
||||
return $contentTypes[0] . ' and ' . $contentTypes[1];
|
||||
}
|
||||
|
||||
$last = array_pop($contentTypes);
|
||||
|
||||
return implode(', ', $contentTypes) . ', and ' . $last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schlägt passenden Content-Type vor
|
||||
*/
|
||||
public function getSuggestedContentType(): ?string
|
||||
{
|
||||
if (empty($this->allowedContentTypes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bei API-Endpoints JSON bevorzugen
|
||||
if (str_contains($this->endpoint, '/api/') && in_array('application/json', $this->allowedContentTypes)) {
|
||||
return 'application/json';
|
||||
}
|
||||
|
||||
// Ersten erlaubten Type zurückgeben
|
||||
return $this->allowedContentTypes[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt HTTP-Header für korrekte Content-Type-Information zurück
|
||||
*/
|
||||
public function getResponseHeaders(): array
|
||||
{
|
||||
$headers = [
|
||||
'Accept' => implode(', ', $this->allowedContentTypes),
|
||||
];
|
||||
|
||||
$suggested = $this->getSuggestedContentType();
|
||||
if ($suggested) {
|
||||
$headers['X-Suggested-Content-Type'] = $suggested;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert den gesendeten Content-Type
|
||||
*/
|
||||
public function analyzeProvidedContentType(): array
|
||||
{
|
||||
$analysis = [
|
||||
'main_type' => '',
|
||||
'sub_type' => '',
|
||||
'charset' => '',
|
||||
'boundary' => '',
|
||||
'is_multipart' => false,
|
||||
'is_text' => false,
|
||||
'is_binary' => false,
|
||||
];
|
||||
|
||||
if (empty($this->providedContentType)) {
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
// Parse Content-Type
|
||||
$parts = explode(';', $this->providedContentType);
|
||||
$mainType = trim($parts[0]);
|
||||
|
||||
if (str_contains($mainType, '/')) {
|
||||
[$type, $subtype] = explode('/', $mainType, 2);
|
||||
$analysis['main_type'] = trim($type);
|
||||
$analysis['sub_type'] = trim($subtype);
|
||||
}
|
||||
|
||||
// Parse Parameter
|
||||
for ($i = 1; $i < count($parts); $i++) {
|
||||
$param = trim($parts[$i]);
|
||||
if (str_contains($param, '=')) {
|
||||
[$key, $value] = explode('=', $param, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value, ' "');
|
||||
|
||||
switch ($key) {
|
||||
case 'charset':
|
||||
$analysis['charset'] = $value;
|
||||
|
||||
break;
|
||||
case 'boundary':
|
||||
$analysis['boundary'] = $value;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Typ-Analyse
|
||||
$analysis['is_multipart'] = str_starts_with($mainType, 'multipart/');
|
||||
$analysis['is_text'] = str_starts_with($mainType, 'text/') ||
|
||||
in_array($mainType, ['application/json', 'application/xml']);
|
||||
$analysis['is_binary'] = ! $analysis['is_text'] && ! $analysis['is_multipart'];
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Empfehlung für Client-Implementierung zurück
|
||||
*/
|
||||
public function getClientRecommendation(): string
|
||||
{
|
||||
$suggested = $this->getSuggestedContentType();
|
||||
|
||||
if ($suggested) {
|
||||
return "Set the Content-Type header to '{$suggested}' for this endpoint.";
|
||||
}
|
||||
|
||||
return "Check the API documentation for supported content types for this endpoint.";
|
||||
}
|
||||
}
|
||||
194
src/Framework/Exception/Http/MalformedJsonException.php
Normal file
194
src/Framework/Exception/Http/MalformedJsonException.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception\Http;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Ausnahme für fehlerhaft formatiertes JSON
|
||||
*
|
||||
* Verwendet OWASP-konforme Nachrichten für JSON-Parsing-Fehler
|
||||
*/
|
||||
final class MalformedJsonException extends FrameworkException
|
||||
{
|
||||
/**
|
||||
* @param string $clientIp Client-IP für Security-Tracking
|
||||
* @param string $jsonError JSON-Parser-Fehlermeldung
|
||||
* @param int $jsonErrorCode JSON-Error-Code
|
||||
* @param string $jsonData Ursprüngliche JSON-Daten (gekürzt)
|
||||
* @param \Throwable|null $previous Vorherige Ausnahme
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $clientIp,
|
||||
public readonly string $jsonError,
|
||||
public readonly int $jsonErrorCode = JSON_ERROR_SYNTAX,
|
||||
public readonly string $jsonData = '',
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
// OWASP-konforme Nachricht mit Platzhaltern
|
||||
$message = "Client {$this->clientIp} sent malformed JSON: {$this->jsonError}";
|
||||
|
||||
$context = ExceptionContext::forOperation('http.json_parsing', 'Http')
|
||||
->withData([
|
||||
'client_ip' => $this->clientIp,
|
||||
'json_error' => $this->jsonError,
|
||||
'json_error_code' => $this->jsonErrorCode,
|
||||
'json_data_length' => strlen($this->jsonData),
|
||||
'event_identifier' => "http_malformed_json:{$this->clientIp}",
|
||||
'category' => 'http_validation',
|
||||
'requires_alert' => false,
|
||||
])
|
||||
->withDebug([
|
||||
'json_data_sample' => substr($this->jsonData, 0, 100), // Erste 100 Zeichen für Debug
|
||||
])
|
||||
->withMetadata([
|
||||
'security_event' => true,
|
||||
'owasp_compliant' => true,
|
||||
'log_level' => 'INFO',
|
||||
'http_error' => true,
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: 400, // Bad Request
|
||||
previous: $previous,
|
||||
errorCode: ErrorCode::HTTP_MALFORMED_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
// === Factory Methods für verschiedene JSON-Fehler ===
|
||||
|
||||
public static function syntaxError(string $clientIp, string $jsonData = ''): self
|
||||
{
|
||||
return new self($clientIp, 'Syntax error', JSON_ERROR_SYNTAX, $jsonData);
|
||||
}
|
||||
|
||||
public static function depthExceeded(string $clientIp, string $jsonData = ''): self
|
||||
{
|
||||
return new self($clientIp, 'Maximum stack depth exceeded', JSON_ERROR_DEPTH, $jsonData);
|
||||
}
|
||||
|
||||
public static function ctrlCharError(string $clientIp, string $jsonData = ''): self
|
||||
{
|
||||
return new self($clientIp, 'Unexpected control character found', JSON_ERROR_CTRL_CHAR, $jsonData);
|
||||
}
|
||||
|
||||
public static function utf8Error(string $clientIp, string $jsonData = ''): self
|
||||
{
|
||||
return new self($clientIp, 'Malformed UTF-8 characters', JSON_ERROR_UTF8, $jsonData);
|
||||
}
|
||||
|
||||
public static function fromJsonLastError(string $clientIp, string $jsonData = ''): self
|
||||
{
|
||||
$error = json_last_error_msg();
|
||||
$code = json_last_error();
|
||||
|
||||
return new self($clientIp, $error, $code, $jsonData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt OWASP-konforme Event-Daten zurück
|
||||
*/
|
||||
public function getSecurityEventData(): array
|
||||
{
|
||||
return [
|
||||
'event_identifier' => "http_malformed_json:{$this->clientIp}",
|
||||
'description' => "Client {$this->clientIp} sent malformed JSON: {$this->jsonError}",
|
||||
'category' => 'http_validation',
|
||||
'log_level' => 'INFO',
|
||||
'requires_alert' => false,
|
||||
'client_ip' => $this->clientIp,
|
||||
'json_error' => $this->jsonError,
|
||||
'json_error_code' => $this->jsonErrorCode,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt benutzerfreundliche Fehlermeldung zurück
|
||||
*/
|
||||
public function getUserMessage(): string
|
||||
{
|
||||
return match ($this->jsonErrorCode) {
|
||||
JSON_ERROR_SYNTAX => 'Invalid JSON format. Please check your JSON syntax.',
|
||||
JSON_ERROR_DEPTH => 'JSON structure too complex. Please reduce nesting depth.',
|
||||
JSON_ERROR_CTRL_CHAR => 'Invalid characters in JSON. Please check for control characters.',
|
||||
JSON_ERROR_UTF8 => 'Invalid UTF-8 encoding in JSON. Please check character encoding.',
|
||||
default => 'Invalid JSON format. Please check your request body.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt detaillierte Fehlerbeschreibung für Entwickler zurück
|
||||
*/
|
||||
public function getDeveloperMessage(): string
|
||||
{
|
||||
$position = $this->findErrorPosition();
|
||||
$positionInfo = $position ? " at position {$position}" : '';
|
||||
|
||||
return "JSON parsing failed: {$this->jsonError}{$positionInfo}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Versucht die Position des JSON-Fehlers zu finden
|
||||
*/
|
||||
private function findErrorPosition(): ?int
|
||||
{
|
||||
if (empty($this->jsonData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Vereinfachte Positionsbestimmung
|
||||
// In einer echten Implementierung könnte hier ein detaillierterer Parser verwendet werden
|
||||
$testData = $this->jsonData;
|
||||
$position = 0;
|
||||
|
||||
while ($position < strlen($testData)) {
|
||||
$substr = substr($testData, 0, $position + 1);
|
||||
json_decode($substr);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return $position;
|
||||
}
|
||||
|
||||
$position++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt JSON-Error-Code als String zurück
|
||||
*/
|
||||
public function getJsonErrorName(): string
|
||||
{
|
||||
return match ($this->jsonErrorCode) {
|
||||
JSON_ERROR_NONE => 'JSON_ERROR_NONE',
|
||||
JSON_ERROR_DEPTH => 'JSON_ERROR_DEPTH',
|
||||
JSON_ERROR_STATE_MISMATCH => 'JSON_ERROR_STATE_MISMATCH',
|
||||
JSON_ERROR_CTRL_CHAR => 'JSON_ERROR_CTRL_CHAR',
|
||||
JSON_ERROR_SYNTAX => 'JSON_ERROR_SYNTAX',
|
||||
JSON_ERROR_UTF8 => 'JSON_ERROR_UTF8',
|
||||
JSON_ERROR_RECURSION => 'JSON_ERROR_RECURSION',
|
||||
JSON_ERROR_INF_OR_NAN => 'JSON_ERROR_INF_OR_NAN',
|
||||
JSON_ERROR_UNSUPPORTED_TYPE => 'JSON_ERROR_UNSUPPORTED_TYPE',
|
||||
default => 'UNKNOWN_JSON_ERROR',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob der Fehler ein häufiger Entwicklerfehler ist
|
||||
*/
|
||||
public function isCommonDeveloperError(): bool
|
||||
{
|
||||
return in_array($this->jsonErrorCode, [
|
||||
JSON_ERROR_SYNTAX,
|
||||
JSON_ERROR_CTRL_CHAR,
|
||||
JSON_ERROR_UTF8,
|
||||
]);
|
||||
}
|
||||
}
|
||||
241
src/Framework/Exception/Http/OversizedRequestException.php
Normal file
241
src/Framework/Exception/Http/OversizedRequestException.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception\Http;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Ausnahme für zu große HTTP-Requests
|
||||
*
|
||||
* Verwendet OWASP-konforme Nachrichten für Request-Size-Violations
|
||||
*/
|
||||
final class OversizedRequestException extends FrameworkException
|
||||
{
|
||||
/**
|
||||
* @param string $clientIp Client-IP für Security-Tracking
|
||||
* @param int $requestSize Größe des Requests in Bytes
|
||||
* @param int $maxSize Maximale erlaubte Größe in Bytes
|
||||
* @param string $requestType Art des Requests (body, header, file, etc.)
|
||||
* @param \Throwable|null $previous Vorherige Ausnahme
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $clientIp,
|
||||
public readonly int $requestSize,
|
||||
public readonly int $maxSize,
|
||||
public readonly string $requestType = 'body',
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
// OWASP-konforme Nachricht mit Platzhaltern
|
||||
$message = "Client {$this->clientIp} sent oversized {$this->requestType}: {$this->requestSize} bytes (max: {$this->maxSize} bytes)";
|
||||
|
||||
$context = ExceptionContext::forOperation('http.size_validation', 'Http')
|
||||
->withData([
|
||||
'client_ip' => $this->clientIp,
|
||||
'request_size' => $this->requestSize,
|
||||
'max_size' => $this->maxSize,
|
||||
'request_type' => $this->requestType,
|
||||
'size_ratio' => round($this->requestSize / $this->maxSize, 2),
|
||||
'excess_bytes' => $this->requestSize - $this->maxSize,
|
||||
'event_identifier' => "http_oversized_request:{$this->clientIp},{$this->requestType}",
|
||||
'category' => 'http_validation',
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
])
|
||||
->withMetadata([
|
||||
'security_event' => true,
|
||||
'owasp_compliant' => true,
|
||||
'log_level' => 'WARN',
|
||||
'http_error' => true,
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: 413, // Payload Too Large
|
||||
previous: $previous,
|
||||
errorCode: ErrorCode::HTTP_REQUEST_TOO_LARGE
|
||||
);
|
||||
}
|
||||
|
||||
// === Factory Methods für verschiedene Size-Violations ===
|
||||
|
||||
public static function requestBody(string $clientIp, int $size, int $maxSize = 10485760): self // 10MB default
|
||||
{
|
||||
return new self($clientIp, $size, $maxSize, 'body');
|
||||
}
|
||||
|
||||
public static function requestHeader(string $clientIp, int $size, int $maxSize = 8192): self // 8KB default
|
||||
{
|
||||
return new self($clientIp, $size, $maxSize, 'header');
|
||||
}
|
||||
|
||||
public static function uploadedFile(string $clientIp, int $size, int $maxSize = 52428800): self // 50MB default
|
||||
{
|
||||
return new self($clientIp, $size, $maxSize, 'file');
|
||||
}
|
||||
|
||||
public static function multipartForm(string $clientIp, int $size, int $maxSize = 20971520): self // 20MB default
|
||||
{
|
||||
return new self($clientIp, $size, $maxSize, 'multipart');
|
||||
}
|
||||
|
||||
public static function queryString(string $clientIp, int $size, int $maxSize = 2048): self // 2KB default
|
||||
{
|
||||
return new self($clientIp, $size, $maxSize, 'query');
|
||||
}
|
||||
|
||||
public static function jsonPayload(string $clientIp, int $size, int $maxSize = 5242880): self // 5MB default
|
||||
{
|
||||
return new self($clientIp, $size, $maxSize, 'json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt OWASP-konforme Event-Daten zurück
|
||||
*/
|
||||
public function getSecurityEventData(): array
|
||||
{
|
||||
return [
|
||||
'event_identifier' => "http_oversized_request:{$this->clientIp},{$this->requestType}",
|
||||
'description' => "Client {$this->clientIp} sent oversized {$this->requestType}: {$this->requestSize} bytes (max: {$this->maxSize} bytes)",
|
||||
'category' => 'http_validation',
|
||||
'log_level' => 'WARN',
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
'client_ip' => $this->clientIp,
|
||||
'request_type' => $this->requestType,
|
||||
'request_size' => $this->requestSize,
|
||||
'max_size' => $this->maxSize,
|
||||
'size_ratio' => $this->getSizeRatio(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Alert erforderlich ist (bei extremen Größenüberschreitungen)
|
||||
*/
|
||||
public function requiresAlert(): bool
|
||||
{
|
||||
$ratio = $this->getSizeRatio();
|
||||
|
||||
// Alert bei extremen Überschreitungen (>10x des Limits)
|
||||
if ($ratio > 10) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Alert bei sehr großen absoluten Größen (>100MB)
|
||||
if ($this->requestSize > 104857600) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Alert bei Header-Größenproblemen (potentielle Header-Injection)
|
||||
if ($this->requestType === 'header' && $ratio > 5) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Verhältnis von Request-Größe zu Maximum
|
||||
*/
|
||||
public function getSizeRatio(): float
|
||||
{
|
||||
return round($this->requestSize / $this->maxSize, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Überschuss in Bytes zurück
|
||||
*/
|
||||
public function getExcessBytes(): int
|
||||
{
|
||||
return max(0, $this->requestSize - $this->maxSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es ein potentieller DoS-Angriff ist
|
||||
*/
|
||||
public function isPotentialDoS(): bool
|
||||
{
|
||||
return $this->getSizeRatio() > 10 || $this->requestSize > 104857600; // >100MB
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt benutzerfreundliche Fehlermeldung zurück
|
||||
*/
|
||||
public function getUserMessage(): string
|
||||
{
|
||||
$requestSizeFormatted = $this->formatBytes($this->requestSize);
|
||||
$maxSizeFormatted = $this->formatBytes($this->maxSize);
|
||||
|
||||
return match ($this->requestType) {
|
||||
'body' => "Request body too large ({$requestSizeFormatted}). Maximum allowed size is {$maxSizeFormatted}.",
|
||||
'header' => "Request headers too large ({$requestSizeFormatted}). Maximum allowed size is {$maxSizeFormatted}.",
|
||||
'file' => "Uploaded file too large ({$requestSizeFormatted}). Maximum allowed size is {$maxSizeFormatted}.",
|
||||
'multipart' => "Form data too large ({$requestSizeFormatted}). Maximum allowed size is {$maxSizeFormatted}.",
|
||||
'query' => "Query string too long ({$requestSizeFormatted}). Maximum allowed size is {$maxSizeFormatted}.",
|
||||
'json' => "JSON payload too large ({$requestSizeFormatted}). Maximum allowed size is {$maxSizeFormatted}.",
|
||||
default => "Request too large ({$requestSizeFormatted}). Maximum allowed size is {$maxSizeFormatted}.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert Bytes in menschenlesbare Einheiten
|
||||
*/
|
||||
public function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
|
||||
$bytes /= pow(1024, $pow);
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$pow];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Empfehlung für Client-Implementierung zurück
|
||||
*/
|
||||
public function getClientRecommendation(): string
|
||||
{
|
||||
return match ($this->requestType) {
|
||||
'body' => 'Split large requests into smaller chunks or compress the data before sending.',
|
||||
'header' => 'Reduce header size by removing unnecessary headers or using shorter values.',
|
||||
'file' => 'Compress the file or split it into smaller parts for upload.',
|
||||
'multipart' => 'Reduce form data size or upload files separately.',
|
||||
'query' => 'Use POST request with body instead of long query strings.',
|
||||
'json' => 'Reduce JSON payload size or implement pagination for large datasets.',
|
||||
default => 'Reduce request size or contact support for higher limits.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt technische Details für Entwickler zurück
|
||||
*/
|
||||
public function getTechnicalDetails(): array
|
||||
{
|
||||
return [
|
||||
'request_type' => $this->requestType,
|
||||
'actual_size' => $this->requestSize,
|
||||
'max_allowed' => $this->maxSize,
|
||||
'excess_bytes' => $this->getExcessBytes(),
|
||||
'size_ratio' => $this->getSizeRatio(),
|
||||
'formatted_actual' => $this->formatBytes($this->requestSize),
|
||||
'formatted_max' => $this->formatBytes($this->maxSize),
|
||||
'formatted_excess' => $this->formatBytes($this->getExcessBytes()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schlägt alternative HTTP-Codes vor
|
||||
*/
|
||||
public function getAlternativeHttpCode(): int
|
||||
{
|
||||
return match ($this->requestType) {
|
||||
'header' => 431, // Request Header Fields Too Large
|
||||
'query' => 414, // URI Too Long
|
||||
default => 413, // Payload Too Large
|
||||
};
|
||||
}
|
||||
}
|
||||
236
src/Framework/Exception/Http/RateLimitExceededException.php
Normal file
236
src/Framework/Exception/Http/RateLimitExceededException.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception\Http;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Ausnahme für Rate-Limit-Überschreitungen
|
||||
*
|
||||
* Verwendet OWASP-konforme Nachrichten für Rate-Limiting-Violations
|
||||
*/
|
||||
final class RateLimitExceededException extends FrameworkException
|
||||
{
|
||||
/**
|
||||
* @param string $clientIp Client-IP für Security-Tracking
|
||||
* @param int $currentRequests Aktuelle Anzahl Requests
|
||||
* @param int $maxRequests Maximale erlaubte Requests
|
||||
* @param int $windowSeconds Zeitfenster in Sekunden
|
||||
* @param int $retryAfterSeconds Warten bis nächster Request erlaubt
|
||||
* @param string $limitType Art des Limits (ip, user, api_key, etc.)
|
||||
* @param \Throwable|null $previous Vorherige Ausnahme
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $clientIp,
|
||||
public readonly int $currentRequests,
|
||||
public readonly int $maxRequests,
|
||||
public readonly int $windowSeconds,
|
||||
public readonly int $retryAfterSeconds,
|
||||
public readonly string $limitType = 'ip',
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
// OWASP-konforme Nachricht mit Platzhaltern
|
||||
$message = "Client {$this->clientIp} exceeded rate limit: {$this->currentRequests}/{$this->maxRequests} requests in {$this->windowSeconds}s";
|
||||
|
||||
$context = ExceptionContext::forOperation('http.rate_limiting', 'Http')
|
||||
->withData([
|
||||
'client_ip' => $this->clientIp,
|
||||
'current_requests' => $this->currentRequests,
|
||||
'max_requests' => $this->maxRequests,
|
||||
'window_seconds' => $this->windowSeconds,
|
||||
'retry_after_seconds' => $this->retryAfterSeconds,
|
||||
'limit_type' => $this->limitType,
|
||||
'usage_percentage' => round(($this->currentRequests / $this->maxRequests) * 100, 2),
|
||||
'event_identifier' => "http_rate_limit_exceeded:{$this->clientIp},{$this->limitType}",
|
||||
'category' => 'rate_limiting',
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
])
|
||||
->withMetadata([
|
||||
'security_event' => true,
|
||||
'owasp_compliant' => true,
|
||||
'log_level' => 'WARN',
|
||||
'http_error' => true,
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: 429, // Too Many Requests
|
||||
previous: $previous,
|
||||
errorCode: ErrorCode::HTTP_RATE_LIMIT_EXCEEDED,
|
||||
retryAfter: $this->retryAfterSeconds
|
||||
);
|
||||
}
|
||||
|
||||
// === Factory Methods für verschiedene Rate-Limit-Szenarien ===
|
||||
|
||||
public static function ipLimit(string $clientIp, int $current, int $max, int $windowSeconds = 3600): self
|
||||
{
|
||||
$retryAfter = min(3600, $windowSeconds); // Maximal 1 Stunde
|
||||
|
||||
return new self($clientIp, $current, $max, $windowSeconds, $retryAfter, 'ip');
|
||||
}
|
||||
|
||||
public static function userLimit(string $clientIp, string $userId, int $current, int $max, int $windowSeconds = 3600): self
|
||||
{
|
||||
$retryAfter = min(1800, $windowSeconds); // Maximal 30 Minuten für User
|
||||
|
||||
return new self($clientIp, $current, $max, $windowSeconds, $retryAfter, 'user');
|
||||
}
|
||||
|
||||
public static function apiKeyLimit(string $clientIp, string $apiKey, int $current, int $max, int $windowSeconds = 3600): self
|
||||
{
|
||||
$retryAfter = min(900, $windowSeconds); // Maximal 15 Minuten für API Keys
|
||||
|
||||
return new self($clientIp, $current, $max, $windowSeconds, $retryAfter, 'api_key');
|
||||
}
|
||||
|
||||
public static function endpointLimit(string $clientIp, string $endpoint, int $current, int $max, int $windowSeconds = 300): self
|
||||
{
|
||||
$retryAfter = min(300, $windowSeconds); // Maximal 5 Minuten für Endpoints
|
||||
|
||||
return new self($clientIp, $current, $max, $windowSeconds, $retryAfter, 'endpoint');
|
||||
}
|
||||
|
||||
public static function globalLimit(string $clientIp, int $current, int $max, int $windowSeconds = 60): self
|
||||
{
|
||||
$retryAfter = min(60, $windowSeconds); // Maximal 1 Minute für Global
|
||||
|
||||
return new self($clientIp, $current, $max, $windowSeconds, $retryAfter, 'global');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt OWASP-konforme Event-Daten zurück
|
||||
*/
|
||||
public function getSecurityEventData(): array
|
||||
{
|
||||
return [
|
||||
'event_identifier' => "http_rate_limit_exceeded:{$this->clientIp},{$this->limitType}",
|
||||
'description' => "Client {$this->clientIp} exceeded rate limit: {$this->currentRequests}/{$this->maxRequests} requests in {$this->windowSeconds}s",
|
||||
'category' => 'rate_limiting',
|
||||
'log_level' => 'WARN',
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
'client_ip' => $this->clientIp,
|
||||
'limit_type' => $this->limitType,
|
||||
'current_requests' => $this->currentRequests,
|
||||
'max_requests' => $this->maxRequests,
|
||||
'usage_percentage' => $this->getUsagePercentage(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Alert erforderlich ist (bei extremen Überschreitungen)
|
||||
*/
|
||||
public function requiresAlert(): bool
|
||||
{
|
||||
$usagePercentage = $this->getUsagePercentage();
|
||||
|
||||
// Alert bei extremen Überschreitungen (>200% des Limits)
|
||||
if ($usagePercentage > 200) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Alert bei sehr hohen Request-Zahlen (potentielle DDoS)
|
||||
if ($this->currentRequests > 1000) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Alert bei API-Key-Limits (könnte kompromittiert sein)
|
||||
if ($this->limitType === 'api_key' && $usagePercentage > 150) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Nutzungsprozentsatz
|
||||
*/
|
||||
public function getUsagePercentage(): float
|
||||
{
|
||||
return round(($this->currentRequests / $this->maxRequests) * 100, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es ein potentieller DDoS-Angriff ist
|
||||
*/
|
||||
public function isPotentialDDoS(): bool
|
||||
{
|
||||
return $this->currentRequests > 1000 || $this->getUsagePercentage() > 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt benutzerfreundliche Fehlermeldung zurück
|
||||
*/
|
||||
public function getUserMessage(): string
|
||||
{
|
||||
$waitTime = $this->getWaitTimeMessage();
|
||||
|
||||
return match ($this->limitType) {
|
||||
'ip' => "Too many requests from your IP address. Please wait {$waitTime} before trying again.",
|
||||
'user' => "You have exceeded your request limit. Please wait {$waitTime} before trying again.",
|
||||
'api_key' => "API key rate limit exceeded. Please wait {$waitTime} before making more requests.",
|
||||
'endpoint' => "Too many requests to this endpoint. Please wait {$waitTime} before trying again.",
|
||||
'global' => "System is busy. Please wait {$waitTime} and try again.",
|
||||
default => "Rate limit exceeded. Please wait {$waitTime} before trying again.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Wartezeit als benutzerfreundlichen String zurück
|
||||
*/
|
||||
public function getWaitTimeMessage(): string
|
||||
{
|
||||
if ($this->retryAfterSeconds < 60) {
|
||||
return "{$this->retryAfterSeconds} seconds";
|
||||
} elseif ($this->retryAfterSeconds < 3600) {
|
||||
$minutes = ceil($this->retryAfterSeconds / 60);
|
||||
|
||||
return "{$minutes} minutes";
|
||||
} else {
|
||||
$hours = ceil($this->retryAfterSeconds / 3600);
|
||||
|
||||
return "{$hours} hours";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt HTTP-Header für Rate-Limiting zurück
|
||||
*/
|
||||
public function getRateLimitHeaders(): array
|
||||
{
|
||||
return [
|
||||
'X-RateLimit-Limit' => (string) $this->maxRequests,
|
||||
'X-RateLimit-Remaining' => (string) max(0, $this->maxRequests - $this->currentRequests),
|
||||
'X-RateLimit-Reset' => (string) (time() + $this->retryAfterSeconds),
|
||||
'X-RateLimit-Window' => (string) $this->windowSeconds,
|
||||
'Retry-After' => (string) $this->retryAfterSeconds,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet wann der nächste Request erlaubt ist
|
||||
*/
|
||||
public function getNextAllowedTime(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable("+{$this->retryAfterSeconds} seconds");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Empfehlung für Client-Implementierung zurück
|
||||
*/
|
||||
public function getClientRecommendation(): string
|
||||
{
|
||||
return match ($this->limitType) {
|
||||
'ip', 'global' => 'Implement exponential backoff and respect Retry-After headers.',
|
||||
'user' => 'Consider caching responses and batching requests to reduce API calls.',
|
||||
'api_key' => 'Review your API usage patterns and consider upgrading your plan if needed.',
|
||||
'endpoint' => 'This endpoint has specific rate limits. Consider alternative endpoints or batch operations.',
|
||||
default => 'Implement proper rate limiting in your client application.',
|
||||
};
|
||||
}
|
||||
}
|
||||
240
src/Framework/Exception/Http/RouteNotFoundException.php
Normal file
240
src/Framework/Exception/Http/RouteNotFoundException.php
Normal file
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception\Http;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Ausnahme für nicht gefundene Routen
|
||||
*
|
||||
* Verwendet OWASP-konforme Nachrichten für HTTP-Routing-Fehler
|
||||
*/
|
||||
final class RouteNotFoundException extends FrameworkException
|
||||
{
|
||||
/**
|
||||
* @param string $path Angeforderte Route/Pfad
|
||||
* @param string $method HTTP-Methode
|
||||
* @param string $clientIp Client-IP für Security-Tracking
|
||||
* @param array $availableRoutes Verfügbare Routen (für Debugging)
|
||||
* @param \Throwable|null $previous Vorherige Ausnahme
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $path,
|
||||
public readonly string $method,
|
||||
public readonly string $clientIp = 'unknown',
|
||||
public readonly array $availableRoutes = [],
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
// OWASP-konforme Nachricht mit Platzhaltern
|
||||
$message = "Client {$this->clientIp} requested non-existent route {$this->method} {$this->path}";
|
||||
|
||||
$context = ExceptionContext::forOperation('http.routing', 'Router')
|
||||
->withData([
|
||||
'path' => $this->path,
|
||||
'method' => $this->method,
|
||||
'client_ip' => $this->clientIp,
|
||||
'available_routes_count' => count($this->availableRoutes),
|
||||
'event_identifier' => "http_route_not_found:{$this->clientIp},{$this->path}",
|
||||
'category' => 'http_routing',
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
])
|
||||
->withDebug([
|
||||
'available_routes' => $this->availableRoutes, // Nur im Debug-Modus
|
||||
])
|
||||
->withMetadata([
|
||||
'security_event' => true,
|
||||
'owasp_compliant' => true,
|
||||
'log_level' => 'INFO',
|
||||
'http_error' => true,
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: 404, // Not Found
|
||||
previous: $previous,
|
||||
errorCode: ErrorCode::HTTP_ROUTE_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
// === Factory Methods für verschiedene 404-Szenarien ===
|
||||
|
||||
public static function path(string $path, string $method = 'GET', string $clientIp = 'unknown'): self
|
||||
{
|
||||
return new self($path, $method, $clientIp);
|
||||
}
|
||||
|
||||
public static function api(string $apiPath, string $method, string $clientIp = 'unknown'): self
|
||||
{
|
||||
return new self("/api{$apiPath}", $method, $clientIp);
|
||||
}
|
||||
|
||||
public static function admin(string $adminPath, string $method = 'GET', string $clientIp = 'unknown'): self
|
||||
{
|
||||
return new self("/admin{$adminPath}", $method, $clientIp);
|
||||
}
|
||||
|
||||
public static function asset(string $assetPath, string $clientIp = 'unknown'): self
|
||||
{
|
||||
return new self($assetPath, 'GET', $clientIp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt OWASP-konforme Event-Daten zurück
|
||||
*/
|
||||
public function getSecurityEventData(): array
|
||||
{
|
||||
return [
|
||||
'event_identifier' => "http_route_not_found:{$this->clientIp},{$this->path}",
|
||||
'description' => "Client {$this->clientIp} requested non-existent route {$this->method} {$this->path}",
|
||||
'category' => 'http_routing',
|
||||
'log_level' => 'INFO',
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
'path' => $this->path,
|
||||
'method' => $this->method,
|
||||
'client_ip' => $this->clientIp,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob der 404-Fehler verdächtig ist (Scanning, Brute Force, etc.)
|
||||
*/
|
||||
public function requiresAlert(): bool
|
||||
{
|
||||
// Alert bei verdächtigen Pfaden
|
||||
$suspiciousPaths = [
|
||||
'/admin', '/wp-admin', '/phpmyadmin', '/manager', '/console',
|
||||
'/.env', '/config', '/backup', '/test', '/debug',
|
||||
'/shell', '/cmd', '/execute', '/eval', '/system',
|
||||
'/../', '/..\\', '/etc/', '/var/', '/usr/', '/root/',
|
||||
'.php', '.asp', '.jsp', '.cgi', '.pl',
|
||||
];
|
||||
|
||||
foreach ($suspiciousPaths as $suspicious) {
|
||||
if (str_contains(strtolower($this->path), $suspicious)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Alert bei ungewöhnlichen HTTP-Methoden auf normalen Pfaden
|
||||
if (in_array($this->method, ['TRACE', 'TRACK', 'CONNECT', 'OPTIONS']) && ! str_starts_with($this->path, '/api/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es ein potentieller Scanner/Bot ist
|
||||
*/
|
||||
public function isScannerActivity(): bool
|
||||
{
|
||||
$scannerPaths = [
|
||||
'/robots.txt', '/sitemap.xml', '/.well-known/',
|
||||
'/favicon.ico', '/apple-touch-icon', '/browserconfig.xml',
|
||||
'/crossdomain.xml', '/clientaccesspolicy.xml',
|
||||
];
|
||||
|
||||
foreach ($scannerPaths as $scanner) {
|
||||
if (str_starts_with($this->path, $scanner)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schlägt ähnliche verfügbare Routen vor
|
||||
*/
|
||||
public function getSimilarRoutes(): array
|
||||
{
|
||||
if (empty($this->availableRoutes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$similar = [];
|
||||
$requestedPath = strtolower($this->path);
|
||||
|
||||
foreach ($this->availableRoutes as $route) {
|
||||
$routePath = strtolower($route);
|
||||
|
||||
// Exakte Teilstring-Matches
|
||||
if (str_contains($routePath, $requestedPath) || str_contains($requestedPath, $routePath)) {
|
||||
$similar[] = $route;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ähnlichkeit basierend auf Levenshtein-Distanz
|
||||
$distance = levenshtein($requestedPath, $routePath);
|
||||
if ($distance <= 3 && strlen($requestedPath) > 3) {
|
||||
$similar[] = $route;
|
||||
}
|
||||
}
|
||||
|
||||
return array_slice($similar, 0, 5); // Maximal 5 Vorschläge
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt benutzerfreundliche Fehlermeldung zurück
|
||||
*/
|
||||
public function getUserMessage(): string
|
||||
{
|
||||
if ($this->isScannerActivity()) {
|
||||
return "The requested resource was not found.";
|
||||
}
|
||||
|
||||
$similar = $this->getSimilarRoutes();
|
||||
if (! empty($similar)) {
|
||||
$suggestions = implode(', ', $similar);
|
||||
|
||||
return "The requested page was not found. Did you mean: {$suggestions}?";
|
||||
}
|
||||
|
||||
return "The requested page was not found. Please check the URL and try again.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert sichere Log-Nachricht (ohne sensible Daten)
|
||||
*/
|
||||
public function getLogMessage(): string
|
||||
{
|
||||
// IP-Adresse anonymisieren für Logs
|
||||
$anonymizedIp = $this->anonymizeIp($this->clientIp);
|
||||
|
||||
return "Route not found: {$this->method} {$this->path} (Client: {$anonymizedIp})";
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymisiert IP-Adresse für Logs
|
||||
*/
|
||||
private function anonymizeIp(string $ip): string
|
||||
{
|
||||
if ($ip === 'unknown') {
|
||||
return $ip;
|
||||
}
|
||||
|
||||
// IPv4
|
||||
if (str_contains($ip, '.')) {
|
||||
$parts = explode('.', $ip);
|
||||
if (count($parts) === 4) {
|
||||
return $parts[0] . '.' . $parts[1] . '.XXX.XXX';
|
||||
}
|
||||
}
|
||||
|
||||
// IPv6
|
||||
if (str_contains($ip, ':')) {
|
||||
$parts = explode(':', $ip);
|
||||
if (count($parts) >= 3) {
|
||||
return $parts[0] . ':' . $parts[1] . ':XXXX::';
|
||||
}
|
||||
}
|
||||
|
||||
return 'XXX.XXX.XXX.XXX';
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception;
|
||||
|
||||
use App\Framework\Http\RequestId;
|
||||
use App\Framework\UserAgent\UserAgent;
|
||||
|
||||
final readonly class RequestContext
|
||||
{
|
||||
public function __construct(
|
||||
@@ -13,10 +16,40 @@ final readonly class RequestContext
|
||||
public ?string $port = null,
|
||||
public ?string $requestUri = null,
|
||||
public ?string $requestMethod = null,
|
||||
public ?string $userAgent = null,
|
||||
public ?UserAgent $userAgent = null,
|
||||
public ?string $clientIp = null,
|
||||
public ?RequestId $requestId = null,
|
||||
public array $headers = []
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
// TODO: Get Requestdata from Request Object in Container
|
||||
|
||||
public static function create(
|
||||
?string $clientIp = null,
|
||||
?UserAgent $userAgent = null,
|
||||
?string $requestMethod = null,
|
||||
?string $requestUri = null,
|
||||
?string $hostIp = null,
|
||||
?string $hostname = null,
|
||||
?string $protocol = null,
|
||||
?string $port = null,
|
||||
?RequestId $requestId = null,
|
||||
array $headers = []
|
||||
): self {
|
||||
return new self(
|
||||
hostIp: $hostIp,
|
||||
hostname: $hostname,
|
||||
protocol: $protocol,
|
||||
port: $port,
|
||||
requestUri: $requestUri,
|
||||
requestMethod: $requestMethod,
|
||||
userAgent: $userAgent,
|
||||
clientIp: $clientIp,
|
||||
requestId: $requestId,
|
||||
headers: $headers
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromGlobals(): self
|
||||
{
|
||||
@@ -27,7 +60,7 @@ final readonly class RequestContext
|
||||
port: $_SERVER['SERVER_PORT'] ?? null,
|
||||
requestUri: $_SERVER['REQUEST_URI'] ?? null,
|
||||
requestMethod: $_SERVER['REQUEST_METHOD'] ?? null,
|
||||
userAgent: $_SERVER['HTTP_USER_AGENT'] ?? null,
|
||||
userAgent: isset($_SERVER['HTTP_USER_AGENT']) ? UserAgent::fromString($_SERVER['HTTP_USER_AGENT']) : null,
|
||||
clientIp: self::getClientIp(),
|
||||
headers: self::getHeaders()
|
||||
);
|
||||
@@ -47,9 +80,10 @@ final readonly class RequestContext
|
||||
'port' => $this->port,
|
||||
'request_uri' => $this->requestUri,
|
||||
'request_method' => $this->requestMethod,
|
||||
'user_agent' => $this->userAgent,
|
||||
'user_agent' => $this->userAgent?->value,
|
||||
'client_ip' => $this->clientIp,
|
||||
'headers' => $this->headers
|
||||
'request_id' => $this->requestId?->toString(),
|
||||
'headers' => $this->headers,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -62,11 +96,11 @@ final readonly class RequestContext
|
||||
'HTTP_X_CLUSTER_CLIENT_IP', // Cluster
|
||||
'HTTP_FORWARDED_FOR', // Proxy
|
||||
'HTTP_FORWARDED', // Proxy
|
||||
'REMOTE_ADDR' // Standard
|
||||
'REMOTE_ADDR', // Standard
|
||||
];
|
||||
|
||||
foreach ($ipKeys as $key) {
|
||||
if (!empty($_SERVER[$key])) {
|
||||
if (! empty($_SERVER[$key])) {
|
||||
$ips = explode(',', $_SERVER[$key]);
|
||||
$ip = trim($ips[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
@@ -81,9 +115,24 @@ final readonly class RequestContext
|
||||
private static function getHeaders(): array
|
||||
{
|
||||
$headers = [];
|
||||
$maxHeaders = 20; // EMERGENCY: Limit to 20 headers max
|
||||
$maxHeaderSize = 1024; // EMERGENCY: Max 1KB per header
|
||||
$count = 0;
|
||||
|
||||
foreach ($_SERVER as $key => $value) {
|
||||
if (str_starts_with($key, 'HTTP_')) {
|
||||
// EMERGENCY: Prevent memory exhaustion from massive headers
|
||||
if (++$count > $maxHeaders) {
|
||||
$headers['X-Headers-Truncated'] = 'true';
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// EMERGENCY: Truncate massive header values
|
||||
if (strlen($value) > $maxHeaderSize) {
|
||||
$value = substr($value, 0, $maxHeaderSize) . '...[TRUNCATED]';
|
||||
}
|
||||
|
||||
$header = str_replace('_', '-', substr($key, 5));
|
||||
$header = ucwords(strtolower($header), '-');
|
||||
$headers[$header] = $value;
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception\Security;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Ausnahme für Path-Traversal-Angriffs-Versuche
|
||||
*
|
||||
* Verwendet OWASP-konforme Nachrichten für Path-Traversal-Detection
|
||||
*/
|
||||
final class PathTraversalAttemptException extends FrameworkException
|
||||
{
|
||||
/**
|
||||
* @param string $clientIp Client-IP für Security-Tracking
|
||||
* @param string $requestedPath Angeforderte Pfad
|
||||
* @param string $pattern Erkanntes Path-Traversal-Pattern
|
||||
* @param string $attackContext Kontext (file_access, url_parameter, etc.)
|
||||
* @param \Throwable|null $previous Vorherige Ausnahme
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $clientIp,
|
||||
public readonly string $requestedPath,
|
||||
public readonly string $pattern,
|
||||
public readonly string $attackContext = 'file_access',
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
// OWASP-konforme Nachricht mit Platzhaltern
|
||||
$message = "Client {$this->clientIp} attempted path traversal: {$this->requestedPath}";
|
||||
|
||||
$context = ExceptionContext::forOperation('security.path_traversal_detection', 'Security')
|
||||
->withData([
|
||||
'client_ip' => $this->clientIp,
|
||||
'requested_path' => $this->requestedPath,
|
||||
'pattern' => $this->pattern,
|
||||
'context' => $this->attackContext,
|
||||
'path_depth' => $this->calculateTraversalDepth(),
|
||||
'event_identifier' => "security_path_traversal:{$this->clientIp}",
|
||||
'category' => 'file_access',
|
||||
'requires_alert' => true, // Path-Traversal-Versuche erfordern immer Alerts
|
||||
])
|
||||
->withMetadata([
|
||||
'security_event' => true,
|
||||
'owasp_compliant' => true,
|
||||
'log_level' => 'ERROR',
|
||||
'attack_type' => 'path_traversal',
|
||||
'critical_security_event' => true,
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: 400, // Bad Request
|
||||
previous: $previous,
|
||||
errorCode: ErrorCode::SECURITY_PATH_TRAVERSAL
|
||||
);
|
||||
}
|
||||
|
||||
// === Factory Methods für verschiedene Path-Traversal-Patterns ===
|
||||
|
||||
public static function dotDotSlash(string $clientIp, string $path): self
|
||||
{
|
||||
return new self($clientIp, $path, '../ pattern', 'file_access');
|
||||
}
|
||||
|
||||
public static function dotDotBackslash(string $clientIp, string $path): self
|
||||
{
|
||||
return new self($clientIp, $path, '..\ pattern', 'file_access');
|
||||
}
|
||||
|
||||
public static function encodedTraversal(string $clientIp, string $path): self
|
||||
{
|
||||
return new self($clientIp, $path, 'URL-encoded traversal', 'file_access');
|
||||
}
|
||||
|
||||
public static function unicodeTraversal(string $clientIp, string $path): self
|
||||
{
|
||||
return new self($clientIp, $path, 'Unicode-encoded traversal', 'file_access');
|
||||
}
|
||||
|
||||
public static function absolutePath(string $clientIp, string $path): self
|
||||
{
|
||||
return new self($clientIp, $path, 'Absolute path access', 'file_access');
|
||||
}
|
||||
|
||||
public static function systemPath(string $clientIp, string $path): self
|
||||
{
|
||||
return new self($clientIp, $path, 'System directory access', 'system_access');
|
||||
}
|
||||
|
||||
public static function configFileAccess(string $clientIp, string $path): self
|
||||
{
|
||||
return new self($clientIp, $path, 'Configuration file access', 'config_access');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt OWASP-konforme Event-Daten zurück
|
||||
*/
|
||||
public function getSecurityEventData(): array
|
||||
{
|
||||
return [
|
||||
'event_identifier' => "security_path_traversal:{$this->clientIp}",
|
||||
'description' => "Client {$this->clientIp} attempted path traversal: {$this->requestedPath}",
|
||||
'category' => 'file_access',
|
||||
'log_level' => 'ERROR',
|
||||
'requires_alert' => true,
|
||||
'client_ip' => $this->clientIp,
|
||||
'requested_path' => $this->requestedPath,
|
||||
'pattern' => $this->pattern,
|
||||
'context' => $this->attackContext,
|
||||
'attack_severity' => $this->getAttackSeverity(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt Schweregrad des Path-Traversal-Angriffs
|
||||
*/
|
||||
public function getAttackSeverity(): string
|
||||
{
|
||||
// Systemverzeichnisse sind kritisch
|
||||
$criticalPaths = ['/etc/', '/var/', '/usr/', '/sys/', '/proc/', 'C:\Windows', 'C:\System'];
|
||||
foreach ($criticalPaths as $critical) {
|
||||
if (str_contains($this->requestedPath, $critical)) {
|
||||
return 'CRITICAL';
|
||||
}
|
||||
}
|
||||
|
||||
// Konfigurationsdateien sind hochriskant
|
||||
$sensitiveFiles = ['.env', 'config', 'passwd', 'shadow', 'hosts', 'web.config'];
|
||||
foreach ($sensitiveFiles as $sensitive) {
|
||||
if (str_contains($this->requestedPath, $sensitive)) {
|
||||
return 'HIGH';
|
||||
}
|
||||
}
|
||||
|
||||
// Deep traversal ist verdächtig
|
||||
if ($this->calculateTraversalDepth() > 3) {
|
||||
return 'HIGH';
|
||||
}
|
||||
|
||||
return 'MEDIUM';
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Tiefe des Traversal-Versuchs
|
||||
*/
|
||||
public function calculateTraversalDepth(): int
|
||||
{
|
||||
return substr_count($this->requestedPath, '../') + substr_count($this->requestedPath, '..\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Path-Traversal-Versuche erfordern immer Alerts
|
||||
*/
|
||||
public function requiresAlert(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es ein automatisierter Angriff ist
|
||||
*/
|
||||
public function isAutomatedAttack(): bool
|
||||
{
|
||||
// Typische automatisierte Scanner-Patterns
|
||||
$automatedPatterns = [
|
||||
'../../../../../../../../etc/passwd',
|
||||
'..\\..\\..\\..\\windows\\system32',
|
||||
'%2e%2e%2f', // URL-encoded ../
|
||||
'\x2e\x2e\x2f', // Hex-encoded
|
||||
];
|
||||
|
||||
$lowerPath = strtolower($this->requestedPath);
|
||||
foreach ($automatedPatterns as $pattern) {
|
||||
if (str_contains($lowerPath, strtolower($pattern))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert das Path-Traversal-Pattern detailliert
|
||||
*/
|
||||
public function analyzePattern(): array
|
||||
{
|
||||
$analysis = [
|
||||
'encoding_type' => 'none',
|
||||
'target_os' => 'unknown',
|
||||
'target_files' => [],
|
||||
'traversal_depth' => $this->calculateTraversalDepth(),
|
||||
'automation_detected' => $this->isAutomatedAttack(),
|
||||
'evasion_techniques' => [],
|
||||
];
|
||||
|
||||
// Encoding-Erkennung
|
||||
if (str_contains($this->requestedPath, '%')) {
|
||||
$analysis['encoding_type'] = 'url_encoded';
|
||||
} elseif (str_contains($this->requestedPath, '\x')) {
|
||||
$analysis['encoding_type'] = 'hex_encoded';
|
||||
} elseif (preg_match('/\\u[0-9a-f]{4}/i', $this->requestedPath)) {
|
||||
$analysis['encoding_type'] = 'unicode_encoded';
|
||||
}
|
||||
|
||||
// OS-Erkennung
|
||||
if (str_contains($this->requestedPath, '\\')) {
|
||||
$analysis['target_os'] = 'windows';
|
||||
} elseif (str_contains($this->requestedPath, '/')) {
|
||||
$analysis['target_os'] = 'unix';
|
||||
}
|
||||
|
||||
// Zieldateien identifizieren
|
||||
$targetFiles = [
|
||||
'passwd' => 'unix_password_file',
|
||||
'shadow' => 'unix_shadow_file',
|
||||
'hosts' => 'hosts_file',
|
||||
'.env' => 'environment_file',
|
||||
'config' => 'configuration_file',
|
||||
'web.config' => 'iis_config',
|
||||
'httpd.conf' => 'apache_config',
|
||||
'nginx.conf' => 'nginx_config',
|
||||
];
|
||||
|
||||
foreach ($targetFiles as $file => $description) {
|
||||
if (str_contains(strtolower($this->requestedPath), $file)) {
|
||||
$analysis['target_files'][] = $description;
|
||||
}
|
||||
}
|
||||
|
||||
// Evasion-Techniken
|
||||
if (str_contains($this->requestedPath, './')) {
|
||||
$analysis['evasion_techniques'][] = 'current_directory_reference';
|
||||
}
|
||||
|
||||
if (preg_match('/\.{3,}/', $this->requestedPath)) {
|
||||
$analysis['evasion_techniques'][] = 'multiple_dots';
|
||||
}
|
||||
|
||||
if (str_contains($this->requestedPath, '//')) {
|
||||
$analysis['evasion_techniques'][] = 'double_slash';
|
||||
}
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt benutzerfreundliche Fehlermeldung zurück (ohne Details zu verraten)
|
||||
*/
|
||||
public function getUserMessage(): string
|
||||
{
|
||||
return "Invalid file path. Access denied.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt spezifische Verteidigungsempfehlung zurück
|
||||
*/
|
||||
public function getDefenseRecommendation(): string
|
||||
{
|
||||
return match ($this->attackContext) {
|
||||
'file_access' => 'Implement proper path validation, use allowlists, and restrict file access to specific directories.',
|
||||
'system_access' => 'Block access to system directories, implement strict path validation, and use security contexts.',
|
||||
'config_access' => 'Secure configuration files, implement access controls, and use environment variables.',
|
||||
default => 'Implement comprehensive path validation and access controls.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert IOC (Indicator of Compromise) für Security-Teams
|
||||
*/
|
||||
public function generateIOC(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'path_traversal_attempt',
|
||||
'source_ip' => $this->clientIp,
|
||||
'requested_path' => $this->requestedPath,
|
||||
'pattern' => $this->pattern,
|
||||
'context' => $this->attackContext,
|
||||
'severity' => $this->getAttackSeverity(),
|
||||
'timestamp' => date('Y-m-d H:i:s'),
|
||||
'automated' => $this->isAutomatedAttack(),
|
||||
'analysis' => $this->analyzePattern(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob sofortige Gegenmaßnahmen erforderlich sind
|
||||
*/
|
||||
public function requiresImmediateAction(): bool
|
||||
{
|
||||
return in_array($this->getAttackSeverity(), ['HIGH', 'CRITICAL']) ||
|
||||
$this->isAutomatedAttack() ||
|
||||
$this->calculateTraversalDepth() > 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt WAF-Regel-Vorschläge zurück
|
||||
*/
|
||||
public function getWafRuleSuggestions(): array
|
||||
{
|
||||
$rules = [
|
||||
'Block requests containing ../ or ..\\ patterns',
|
||||
'Block requests with URL-encoded path traversal sequences',
|
||||
'Block access to sensitive file extensions (.env, .config, etc.)',
|
||||
];
|
||||
|
||||
$analysis = $this->analyzePattern();
|
||||
|
||||
if ($analysis['encoding_type'] !== 'none') {
|
||||
$rules[] = 'Implement URL decoding before path traversal detection';
|
||||
}
|
||||
|
||||
if (! empty($analysis['target_files'])) {
|
||||
$rules[] = 'Block access to system configuration files and password files';
|
||||
}
|
||||
|
||||
if ($analysis['traversal_depth'] > 3) {
|
||||
$rules[] = 'Limit maximum directory traversal depth';
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt sichere Alternative für File-Access vor
|
||||
*/
|
||||
public function getSecureAlternatives(): array
|
||||
{
|
||||
return [
|
||||
'Use file IDs instead of file paths in URLs',
|
||||
'Implement a file mapping table',
|
||||
'Restrict file access to a specific directory',
|
||||
'Use symbolic links instead of direct paths',
|
||||
'Implement role-based file access controls',
|
||||
'Validate file paths against an allowlist',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception\Security;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Ausnahme für SQL-Injection-Versuche
|
||||
*
|
||||
* Verwendet OWASP-konforme Nachrichten für SQL-Injection-Detection
|
||||
*/
|
||||
final class SqlInjectionAttemptException extends FrameworkException
|
||||
{
|
||||
/**
|
||||
* @param string $clientIp Client-IP für Security-Tracking
|
||||
* @param string $field Betroffenes Feld/Parameter
|
||||
* @param string $pattern Erkanntes SQL-Injection-Pattern
|
||||
* @param string $originalValue Ursprünglicher Wert (sanitized)
|
||||
* @param string $detectionMethod Erkennungsmethode (regex, heuristic, etc.)
|
||||
* @param \Throwable|null $previous Vorherige Ausnahme
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $clientIp,
|
||||
public readonly string $field,
|
||||
public readonly string $pattern,
|
||||
public readonly string $originalValue,
|
||||
public readonly string $detectionMethod = 'pattern_match',
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
// OWASP-konforme Nachricht mit Platzhaltern
|
||||
$message = "Client {$this->clientIp} attempted SQL injection in field {$this->field}";
|
||||
|
||||
$context = ExceptionContext::forOperation('security.sql_injection_detection', 'Security')
|
||||
->withData([
|
||||
'client_ip' => $this->clientIp,
|
||||
'field' => $this->field,
|
||||
'pattern' => $this->pattern,
|
||||
'detection_method' => $this->detectionMethod,
|
||||
'value_length' => strlen($this->originalValue),
|
||||
'event_identifier' => "security_sql_injection:{$this->clientIp},{$this->field}",
|
||||
'category' => 'input_validation',
|
||||
'requires_alert' => true, // SQL-Injection-Versuche erfordern immer Alerts
|
||||
])
|
||||
->withDebug([
|
||||
'sanitized_value' => $this->sanitizeValueForLog($this->originalValue),
|
||||
])
|
||||
->withMetadata([
|
||||
'security_event' => true,
|
||||
'owasp_compliant' => true,
|
||||
'log_level' => 'ERROR',
|
||||
'attack_type' => 'sql_injection',
|
||||
'critical_security_event' => true,
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: 400, // Bad Request
|
||||
previous: $previous,
|
||||
errorCode: ErrorCode::SECURITY_SQL_INJECTION
|
||||
);
|
||||
}
|
||||
|
||||
// === Factory Methods für verschiedene SQL-Injection-Patterns ===
|
||||
|
||||
public static function unionSelect(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, 'UNION SELECT', $value, 'union_detection');
|
||||
}
|
||||
|
||||
public static function commentInjection(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, 'SQL Comment (-- or /*)', $value, 'comment_detection');
|
||||
}
|
||||
|
||||
public static function blindSqlInjection(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, 'Blind SQL Injection', $value, 'blind_detection');
|
||||
}
|
||||
|
||||
public static function timeBasedInjection(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, 'Time-based SQL Injection', $value, 'time_based_detection');
|
||||
}
|
||||
|
||||
public static function errorBasedInjection(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, 'Error-based SQL Injection', $value, 'error_based_detection');
|
||||
}
|
||||
|
||||
public static function booleanBasedInjection(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, 'Boolean-based SQL Injection', $value, 'boolean_detection');
|
||||
}
|
||||
|
||||
public static function stackedQueries(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, 'Stacked Queries', $value, 'stacked_queries_detection');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt OWASP-konforme Event-Daten zurück
|
||||
*/
|
||||
public function getSecurityEventData(): array
|
||||
{
|
||||
return [
|
||||
'event_identifier' => "security_sql_injection:{$this->clientIp},{$this->field}",
|
||||
'description' => "Client {$this->clientIp} attempted SQL injection in field {$this->field}",
|
||||
'category' => 'input_validation',
|
||||
'log_level' => 'ERROR',
|
||||
'requires_alert' => true,
|
||||
'client_ip' => $this->clientIp,
|
||||
'field' => $this->field,
|
||||
'pattern' => $this->pattern,
|
||||
'detection_method' => $this->detectionMethod,
|
||||
'attack_severity' => $this->getAttackSeverity(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt Schweregrad des Angriffs
|
||||
*/
|
||||
public function getAttackSeverity(): string
|
||||
{
|
||||
return match ($this->detectionMethod) {
|
||||
'union_detection' => 'HIGH',
|
||||
'stacked_queries_detection' => 'CRITICAL',
|
||||
'error_based_detection' => 'HIGH',
|
||||
'time_based_detection' => 'MEDIUM',
|
||||
'blind_detection' => 'MEDIUM',
|
||||
'boolean_detection' => 'MEDIUM',
|
||||
'comment_detection' => 'LOW',
|
||||
default => 'MEDIUM',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL-Injection-Versuche erfordern immer Alerts
|
||||
*/
|
||||
public function requiresAlert(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es ein automatisierter Angriff ist
|
||||
*/
|
||||
public function isAutomatedAttack(): bool
|
||||
{
|
||||
// Heuristiken für automatisierte Angriffe
|
||||
$automatedPatterns = [
|
||||
'sqlmap',
|
||||
'havij',
|
||||
'pangolin',
|
||||
'union select',
|
||||
'order by',
|
||||
'group by',
|
||||
];
|
||||
|
||||
$lowerValue = strtolower($this->originalValue);
|
||||
foreach ($automatedPatterns as $pattern) {
|
||||
if (str_contains($lowerValue, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert das SQL-Injection-Pattern
|
||||
*/
|
||||
public function analyzePattern(): array
|
||||
{
|
||||
$analysis = [
|
||||
'type' => 'unknown',
|
||||
'technique' => 'unknown',
|
||||
'payload_type' => 'unknown',
|
||||
'database_fingerprint' => 'unknown',
|
||||
'automation_detected' => $this->isAutomatedAttack(),
|
||||
];
|
||||
|
||||
$lowerValue = strtolower($this->originalValue);
|
||||
|
||||
// Typ-Erkennung
|
||||
if (str_contains($lowerValue, 'union')) {
|
||||
$analysis['type'] = 'union_based';
|
||||
} elseif (str_contains($lowerValue, 'sleep(') || str_contains($lowerValue, 'waitfor')) {
|
||||
$analysis['type'] = 'time_based';
|
||||
} elseif (str_contains($lowerValue, '1=1') || str_contains($lowerValue, '1=0')) {
|
||||
$analysis['type'] = 'boolean_based';
|
||||
} elseif (str_contains($lowerValue, 'error') || str_contains($lowerValue, 'convert(')) {
|
||||
$analysis['type'] = 'error_based';
|
||||
}
|
||||
|
||||
// Technik-Erkennung
|
||||
if (str_contains($lowerValue, '/*') || str_contains($lowerValue, '--')) {
|
||||
$analysis['technique'] = 'comment_bypassing';
|
||||
} elseif (str_contains($lowerValue, 'char(') || str_contains($lowerValue, 'ascii(')) {
|
||||
$analysis['technique'] = 'encoding_bypassing';
|
||||
} elseif (str_contains($lowerValue, ';')) {
|
||||
$analysis['technique'] = 'stacked_queries';
|
||||
}
|
||||
|
||||
// Database-Fingerprinting
|
||||
if (str_contains($lowerValue, 'mysql') || str_contains($lowerValue, 'information_schema')) {
|
||||
$analysis['database_fingerprint'] = 'mysql';
|
||||
} elseif (str_contains($lowerValue, 'postgres') || str_contains($lowerValue, 'pg_')) {
|
||||
$analysis['database_fingerprint'] = 'postgresql';
|
||||
} elseif (str_contains($lowerValue, 'mssql') || str_contains($lowerValue, 'sys.')) {
|
||||
$analysis['database_fingerprint'] = 'mssql';
|
||||
} elseif (str_contains($lowerValue, 'oracle') || str_contains($lowerValue, 'dual')) {
|
||||
$analysis['database_fingerprint'] = 'oracle';
|
||||
}
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt benutzerfreundliche Fehlermeldung zurück (ohne Details zu verraten)
|
||||
*/
|
||||
public function getUserMessage(): string
|
||||
{
|
||||
return "Invalid input detected. Please check your data and try again.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereinigt Wert für sicheres Logging
|
||||
*/
|
||||
private function sanitizeValueForLog(string $value): string
|
||||
{
|
||||
// Begrenzen auf 100 Zeichen und entferne gefährliche Zeichen
|
||||
$sanitized = substr($value, 0, 100);
|
||||
$sanitized = preg_replace('/[<>"\']/', '*', $sanitized);
|
||||
$sanitized = preg_replace('/\s+/', ' ', $sanitized);
|
||||
|
||||
return trim($sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Empfehlung für Verteidigung zurück
|
||||
*/
|
||||
public function getDefenseRecommendation(): string
|
||||
{
|
||||
return match ($this->detectionMethod) {
|
||||
'union_detection' => 'Implement parameterized queries and input validation.',
|
||||
'stacked_queries_detection' => 'Disable multiple statements and use stored procedures.',
|
||||
'error_based_detection' => 'Implement proper error handling and logging.',
|
||||
'time_based_detection' => 'Add request timeouts and rate limiting.',
|
||||
'blind_detection' => 'Implement comprehensive input validation and WAF rules.',
|
||||
default => 'Use parameterized queries, input validation, and proper escaping.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert IOC (Indicator of Compromise) für Security-Teams
|
||||
*/
|
||||
public function generateIOC(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'sql_injection_attempt',
|
||||
'source_ip' => $this->clientIp,
|
||||
'target_field' => $this->field,
|
||||
'pattern' => $this->pattern,
|
||||
'detection_method' => $this->detectionMethod,
|
||||
'severity' => $this->getAttackSeverity(),
|
||||
'timestamp' => date('Y-m-d H:i:s'),
|
||||
'automated' => $this->isAutomatedAttack(),
|
||||
'analysis' => $this->analyzePattern(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob sofortige Gegenmaßnahmen erforderlich sind
|
||||
*/
|
||||
public function requiresImmediateAction(): bool
|
||||
{
|
||||
return in_array($this->getAttackSeverity(), ['HIGH', 'CRITICAL']) || $this->isAutomatedAttack();
|
||||
}
|
||||
}
|
||||
365
src/Framework/Exception/Security/XssAttemptException.php
Normal file
365
src/Framework/Exception/Security/XssAttemptException.php
Normal file
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception\Security;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Ausnahme für XSS-Angriffs-Versuche
|
||||
*
|
||||
* Verwendet OWASP-konforme Nachrichten für XSS-Detection
|
||||
*/
|
||||
final class XssAttemptException extends FrameworkException
|
||||
{
|
||||
/**
|
||||
* @param string $clientIp Client-IP für Security-Tracking
|
||||
* @param string $field Betroffenes Feld/Parameter
|
||||
* @param string $pattern Erkanntes XSS-Pattern
|
||||
* @param string $originalValue Ursprünglicher Wert (sanitized)
|
||||
* @param string $xssType Art des XSS (reflected, stored, dom)
|
||||
* @param \Throwable|null $previous Vorherige Ausnahme
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $clientIp,
|
||||
public readonly string $field,
|
||||
public readonly string $pattern,
|
||||
public readonly string $originalValue,
|
||||
public readonly string $xssType = 'reflected',
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
// OWASP-konforme Nachricht mit Platzhaltern
|
||||
$message = "Client {$this->clientIp} attempted XSS attack in field {$this->field}";
|
||||
|
||||
$context = ExceptionContext::forOperation('security.xss_detection', 'Security')
|
||||
->withData([
|
||||
'client_ip' => $this->clientIp,
|
||||
'field' => $this->field,
|
||||
'pattern' => $this->pattern,
|
||||
'xss_type' => $this->xssType,
|
||||
'value_length' => strlen($this->originalValue),
|
||||
'event_identifier' => "security_xss_attempt:{$this->clientIp},{$this->field}",
|
||||
'category' => 'input_validation',
|
||||
'requires_alert' => true, // XSS-Versuche erfordern immer Alerts
|
||||
])
|
||||
->withDebug([
|
||||
'sanitized_value' => $this->sanitizeValueForLog($this->originalValue),
|
||||
])
|
||||
->withMetadata([
|
||||
'security_event' => true,
|
||||
'owasp_compliant' => true,
|
||||
'log_level' => 'ERROR',
|
||||
'attack_type' => 'xss',
|
||||
'critical_security_event' => true,
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: 400, // Bad Request
|
||||
previous: $previous,
|
||||
errorCode: ErrorCode::SECURITY_XSS_ATTEMPT
|
||||
);
|
||||
}
|
||||
|
||||
// === Factory Methods für verschiedene XSS-Patterns ===
|
||||
|
||||
public static function scriptTag(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, '<script> tag injection', $value, 'reflected');
|
||||
}
|
||||
|
||||
public static function onEventHandler(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, 'Event handler injection (onclick, onload, etc.)', $value, 'reflected');
|
||||
}
|
||||
|
||||
public static function javascriptProtocol(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, 'javascript: protocol injection', $value, 'reflected');
|
||||
}
|
||||
|
||||
public static function htmlInjection(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, 'HTML tag injection', $value, 'reflected');
|
||||
}
|
||||
|
||||
public static function domBasedXss(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, 'DOM-based XSS pattern', $value, 'dom');
|
||||
}
|
||||
|
||||
public static function storedXss(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, 'Stored XSS pattern', $value, 'stored');
|
||||
}
|
||||
|
||||
public static function cssInjection(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, 'CSS injection with expression()', $value, 'reflected');
|
||||
}
|
||||
|
||||
public static function svgXss(string $clientIp, string $field, string $value): self
|
||||
{
|
||||
return new self($clientIp, $field, 'SVG-based XSS injection', $value, 'reflected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt OWASP-konforme Event-Daten zurück
|
||||
*/
|
||||
public function getSecurityEventData(): array
|
||||
{
|
||||
return [
|
||||
'event_identifier' => "security_xss_attempt:{$this->clientIp},{$this->field}",
|
||||
'description' => "Client {$this->clientIp} attempted XSS attack in field {$this->field}",
|
||||
'category' => 'input_validation',
|
||||
'log_level' => 'ERROR',
|
||||
'requires_alert' => true,
|
||||
'client_ip' => $this->clientIp,
|
||||
'field' => $this->field,
|
||||
'pattern' => $this->pattern,
|
||||
'xss_type' => $this->xssType,
|
||||
'attack_severity' => $this->getAttackSeverity(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt Schweregrad des XSS-Angriffs
|
||||
*/
|
||||
public function getAttackSeverity(): string
|
||||
{
|
||||
return match ($this->xssType) {
|
||||
'stored' => 'CRITICAL', // Stored XSS ist am gefährlichsten
|
||||
'dom' => 'HIGH', // DOM-based XSS ist schwer zu erkennen
|
||||
'reflected' => 'MEDIUM', // Reflected XSS ist häufig aber weniger persistent
|
||||
default => 'MEDIUM',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* XSS-Versuche erfordern immer Alerts
|
||||
*/
|
||||
public function requiresAlert(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es ein automatisierter Angriff ist
|
||||
*/
|
||||
public function isAutomatedAttack(): bool
|
||||
{
|
||||
$automatedPatterns = [
|
||||
'alert(',
|
||||
'prompt(',
|
||||
'confirm(',
|
||||
'document.cookie',
|
||||
'xss',
|
||||
'<svg',
|
||||
'javascript:',
|
||||
'onerror=',
|
||||
'onload=',
|
||||
];
|
||||
|
||||
$lowerValue = strtolower($this->originalValue);
|
||||
foreach ($automatedPatterns as $pattern) {
|
||||
if (str_contains($lowerValue, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert das XSS-Pattern detailliert
|
||||
*/
|
||||
public function analyzePattern(): array
|
||||
{
|
||||
$analysis = [
|
||||
'vector_type' => 'unknown',
|
||||
'payload_complexity' => 'low',
|
||||
'encoding_used' => false,
|
||||
'obfuscation_detected' => false,
|
||||
'automation_detected' => $this->isAutomatedAttack(),
|
||||
'potential_impact' => 'low',
|
||||
];
|
||||
|
||||
$lowerValue = strtolower($this->originalValue);
|
||||
|
||||
// Vector-Typ-Erkennung
|
||||
if (str_contains($lowerValue, '<script')) {
|
||||
$analysis['vector_type'] = 'script_tag';
|
||||
} elseif (str_contains($lowerValue, 'on') && preg_match('/on\w+\s*=/', $lowerValue)) {
|
||||
$analysis['vector_type'] = 'event_handler';
|
||||
} elseif (str_contains($lowerValue, 'javascript:')) {
|
||||
$analysis['vector_type'] = 'javascript_protocol';
|
||||
} elseif (str_contains($lowerValue, '<svg') || str_contains($lowerValue, '<embed')) {
|
||||
$analysis['vector_type'] = 'svg_embed';
|
||||
} elseif (str_contains($lowerValue, 'expression(')) {
|
||||
$analysis['vector_type'] = 'css_expression';
|
||||
}
|
||||
|
||||
// Komplexität bewerten
|
||||
if (strlen($this->originalValue) > 100) {
|
||||
$analysis['payload_complexity'] = 'high';
|
||||
} elseif (strlen($this->originalValue) > 50) {
|
||||
$analysis['payload_complexity'] = 'medium';
|
||||
}
|
||||
|
||||
// Encoding-Erkennung
|
||||
if (str_contains($this->originalValue, '%') || str_contains($this->originalValue, '&#')) {
|
||||
$analysis['encoding_used'] = true;
|
||||
}
|
||||
|
||||
// Obfuskierung-Erkennung
|
||||
if (preg_match('/String\.fromCharCode|eval\(|unescape\(/', $this->originalValue)) {
|
||||
$analysis['obfuscation_detected'] = true;
|
||||
$analysis['payload_complexity'] = 'high';
|
||||
}
|
||||
|
||||
// Impact-Bewertung
|
||||
if (str_contains($lowerValue, 'cookie') || str_contains($lowerValue, 'document')) {
|
||||
$analysis['potential_impact'] = 'high';
|
||||
} elseif (str_contains($lowerValue, 'alert') || str_contains($lowerValue, 'prompt')) {
|
||||
$analysis['potential_impact'] = 'medium';
|
||||
}
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt benutzerfreundliche Fehlermeldung zurück (ohne Details zu verraten)
|
||||
*/
|
||||
public function getUserMessage(): string
|
||||
{
|
||||
return "Invalid input detected. Please check your data and try again.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereinigt Wert für sicheres Logging
|
||||
*/
|
||||
private function sanitizeValueForLog(string $value): string
|
||||
{
|
||||
// HTML-Entities kodieren und auf 100 Zeichen begrenzen
|
||||
$sanitized = htmlspecialchars(substr($value, 0, 100), ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Zusätzliche Bereinigung für Logs
|
||||
$sanitized = preg_replace('/\s+/', ' ', $sanitized);
|
||||
|
||||
return trim($sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt spezifische Verteidigungsempfehlung zurück
|
||||
*/
|
||||
public function getDefenseRecommendation(): string
|
||||
{
|
||||
return match ($this->xssType) {
|
||||
'stored' => 'Implement output encoding, Content Security Policy, and input validation.',
|
||||
'dom' => 'Use safe JavaScript APIs, validate DOM manipulation, implement CSP.',
|
||||
'reflected' => 'Implement output encoding, input validation, and Content Security Policy.',
|
||||
default => 'Use output encoding, input validation, and Content Security Policy.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Content Security Policy Empfehlungen
|
||||
*/
|
||||
public function getCspRecommendations(): array
|
||||
{
|
||||
$baseCSP = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"font-src 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-src 'none'",
|
||||
];
|
||||
|
||||
$analysis = $this->analyzePattern();
|
||||
|
||||
// Verschärfungen basierend auf Angriffsmuster
|
||||
if ($analysis['vector_type'] === 'script_tag') {
|
||||
$baseCSP[] = "script-src 'self' 'nonce-{random}'"; // Nonce-basierte Scripts
|
||||
}
|
||||
|
||||
if ($analysis['vector_type'] === 'css_expression') {
|
||||
$baseCSP[] = "style-src 'self'"; // Entferne unsafe-inline
|
||||
}
|
||||
|
||||
return $baseCSP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert IOC (Indicator of Compromise) für Security-Teams
|
||||
*/
|
||||
public function generateIOC(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'xss_attempt',
|
||||
'source_ip' => $this->clientIp,
|
||||
'target_field' => $this->field,
|
||||
'pattern' => $this->pattern,
|
||||
'xss_type' => $this->xssType,
|
||||
'severity' => $this->getAttackSeverity(),
|
||||
'timestamp' => date('Y-m-d H:i:s'),
|
||||
'automated' => $this->isAutomatedAttack(),
|
||||
'analysis' => $this->analyzePattern(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob sofortige Gegenmaßnahmen erforderlich sind
|
||||
*/
|
||||
public function requiresImmediateAction(): bool
|
||||
{
|
||||
return $this->xssType === 'stored' ||
|
||||
$this->getAttackSeverity() === 'CRITICAL' ||
|
||||
$this->isAutomatedAttack();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt WAF (Web Application Firewall) Regel-Vorschläge zurück
|
||||
*/
|
||||
public function getWafRuleSuggestions(): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
$analysis = $this->analyzePattern();
|
||||
|
||||
switch ($analysis['vector_type']) {
|
||||
case 'script_tag':
|
||||
$rules[] = 'Block requests containing <script tags in input fields';
|
||||
|
||||
break;
|
||||
case 'event_handler':
|
||||
$rules[] = 'Block requests containing on* event handlers in input fields';
|
||||
|
||||
break;
|
||||
case 'javascript_protocol':
|
||||
$rules[] = 'Block requests containing javascript: protocol in input fields';
|
||||
|
||||
break;
|
||||
case 'svg_embed':
|
||||
$rules[] = 'Block or sanitize SVG/embed tags in user input';
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if ($analysis['encoding_used']) {
|
||||
$rules[] = 'Implement URL decoding before XSS detection';
|
||||
}
|
||||
|
||||
if ($analysis['obfuscation_detected']) {
|
||||
$rules[] = 'Implement advanced obfuscation detection rules';
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@ final readonly class AuthenticationAccountLockedEvent implements SecurityEventIn
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public int $attempts
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function getEventIdentifier(): string
|
||||
{
|
||||
@@ -50,7 +51,7 @@ final readonly class AuthenticationAccountLockedEvent implements SecurityEventIn
|
||||
'event_identifier' => $this->getEventIdentifier(),
|
||||
'category' => $this->getCategory(),
|
||||
'log_level' => $this->getLogLevel()->value,
|
||||
'requires_alert' => $this->requiresAlert()
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ final readonly class AuthenticationLoginFailedEvent implements SecurityEventInte
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public string $reason = 'invalid_credentials'
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function getEventIdentifier(): string
|
||||
{
|
||||
@@ -50,7 +51,7 @@ final readonly class AuthenticationLoginFailedEvent implements SecurityEventInte
|
||||
'event_identifier' => $this->getEventIdentifier(),
|
||||
'category' => $this->getCategory(),
|
||||
'log_level' => $this->getLogLevel()->value,
|
||||
'requires_alert' => $this->requiresAlert()
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ final readonly class AuthenticationLoginSuccessAfterFailEvent implements Securit
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public int $retries
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function getEventIdentifier(): string
|
||||
{
|
||||
@@ -50,7 +51,7 @@ final readonly class AuthenticationLoginSuccessAfterFailEvent implements Securit
|
||||
'event_identifier' => $this->getEventIdentifier(),
|
||||
'category' => $this->getCategory(),
|
||||
'log_level' => $this->getLogLevel()->value,
|
||||
'requires_alert' => $this->requiresAlert()
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ final readonly class AuthenticationLoginSuccessEvent implements SecurityEventInt
|
||||
{
|
||||
public function __construct(
|
||||
public string $userId
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function getEventIdentifier(): string
|
||||
{
|
||||
@@ -47,7 +48,7 @@ final readonly class AuthenticationLoginSuccessEvent implements SecurityEventInt
|
||||
'event_identifier' => $this->getEventIdentifier(),
|
||||
'category' => $this->getCategory(),
|
||||
'log_level' => $this->getLogLevel()->value,
|
||||
'requires_alert' => $this->requiresAlert()
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ final readonly class AuthorizationAccessDeniedEvent implements SecurityEventInte
|
||||
public string $userId,
|
||||
public string $resource,
|
||||
public string $action = 'access'
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function getEventIdentifier(): string
|
||||
{
|
||||
@@ -51,7 +52,7 @@ final readonly class AuthorizationAccessDeniedEvent implements SecurityEventInte
|
||||
'event_identifier' => $this->getEventIdentifier(),
|
||||
'category' => $this->getCategory(),
|
||||
'log_level' => $this->getLogLevel()->value,
|
||||
'requires_alert' => $this->requiresAlert()
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ final readonly class AuthorizationAdminActionEvent implements SecurityEventInter
|
||||
public string $userId,
|
||||
public string $resource,
|
||||
public string $action = 'admin_action'
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function getEventIdentifier(): string
|
||||
{
|
||||
@@ -52,7 +53,7 @@ final readonly class AuthorizationAdminActionEvent implements SecurityEventInter
|
||||
'event_identifier' => $this->getEventIdentifier(),
|
||||
'category' => $this->getCategory(),
|
||||
'log_level' => $this->getLogLevel()->value,
|
||||
'requires_alert' => $this->requiresAlert()
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception\SecurityEvent;
|
||||
|
||||
use App\Framework\Exception\SecurityException;
|
||||
use App\Framework\Exception\SecurityLogLevel;
|
||||
|
||||
/**
|
||||
@@ -15,7 +14,8 @@ final readonly class InputSqlInjectionAttemptEvent implements SecurityEventInter
|
||||
public function __construct(
|
||||
public string $field,
|
||||
public string $detectedPattern = 'generic_sql_pattern'
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function getEventIdentifier(): string
|
||||
{
|
||||
@@ -51,7 +51,7 @@ final readonly class InputSqlInjectionAttemptEvent implements SecurityEventInter
|
||||
'event_identifier' => $this->getEventIdentifier(),
|
||||
'category' => $this->getCategory(),
|
||||
'log_level' => $this->getLogLevel()->value,
|
||||
'requires_alert' => $this->requiresAlert()
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ final readonly class InputXssAttemptEvent implements SecurityEventInterface
|
||||
public function __construct(
|
||||
public string $field,
|
||||
public string $detectedPattern = 'generic_xss_pattern'
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function getEventIdentifier(): string
|
||||
{
|
||||
@@ -49,7 +50,7 @@ final readonly class InputXssAttemptEvent implements SecurityEventInterface
|
||||
'event_identifier' => $this->getEventIdentifier(),
|
||||
'category' => $this->getCategory(),
|
||||
'log_level' => $this->getLogLevel()->value,
|
||||
'requires_alert' => $this->requiresAlert()
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ final readonly class SystemExcessiveUseEvent implements SecurityEventInterface
|
||||
public string $identifier,
|
||||
public int $limit,
|
||||
public int $currentUsage
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function getEventIdentifier(): string
|
||||
{
|
||||
@@ -52,7 +53,7 @@ final readonly class SystemExcessiveUseEvent implements SecurityEventInterface
|
||||
'event_identifier' => $this->getEventIdentifier(),
|
||||
'category' => $this->getCategory(),
|
||||
'log_level' => $this->getLogLevel()->value,
|
||||
'requires_alert' => $this->requiresAlert()
|
||||
'requires_alert' => $this->requiresAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class SecurityException extends FrameworkException
|
||||
// Verwende Event-Beschreibung als Message falls nicht gesetzt
|
||||
$finalMessage = $message ?: $securityEvent->getDescription();
|
||||
|
||||
parent::__construct($finalMessage, $code, $previous, $context);
|
||||
parent::__construct($finalMessage, $context, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,13 +57,13 @@ class SecurityException extends FrameworkException
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
|
||||
'request_uri' => $_SERVER['REQUEST_URI'] ?? null,
|
||||
'request_method' => $_SERVER['REQUEST_METHOD'] ?? null,
|
||||
'timestamp' => time()
|
||||
'timestamp' => time(),
|
||||
])->withMetadata([
|
||||
'security_event' => $securityEvent->getEventIdentifier(),
|
||||
'security_level' => $securityEvent->getLogLevel()->value,
|
||||
'security_description' => $securityEvent->getDescription(),
|
||||
'requires_alert' => $securityEvent->requiresAlert(),
|
||||
'event_category' => $securityEvent->getCategory()
|
||||
'event_category' => $securityEvent->getCategory(),
|
||||
]);
|
||||
|
||||
// Merge mit zusätzlichem Context falls vorhanden
|
||||
|
||||
@@ -15,20 +15,6 @@ enum SecurityLogLevel: string
|
||||
case ERROR = 'ERROR';
|
||||
case FATAL = 'FATAL';
|
||||
|
||||
/**
|
||||
* Konvertiert zu Standard-PSR-Log-Level
|
||||
*/
|
||||
public function toPsrLevel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DEBUG => 'debug',
|
||||
self::INFO => 'info',
|
||||
self::WARN => 'warning',
|
||||
self::ERROR => 'error',
|
||||
self::FATAL => 'critical',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Numerischer Wert für Vergleiche
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception;
|
||||
|
||||
use App\Framework\Performance\MemoryMonitor;
|
||||
|
||||
final readonly class SystemContext
|
||||
{
|
||||
public function __construct(
|
||||
@@ -12,12 +14,15 @@ final readonly class SystemContext
|
||||
public ?string $phpVersion = null,
|
||||
public ?string $frameworkVersion = null,
|
||||
public array $environment = []
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public static function current(): self
|
||||
public static function current(?MemoryMonitor $memoryMonitor = null): self
|
||||
{
|
||||
return new self(
|
||||
memoryUsage: self::formatBytes(memory_get_usage(true)),
|
||||
memoryUsage: $memoryMonitor
|
||||
? $memoryMonitor->getCurrentMemory()->toHumanReadable()
|
||||
: self::formatBytes(memory_get_usage(true)),
|
||||
executionTime: isset($_SERVER['REQUEST_TIME_FLOAT'])
|
||||
? microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']
|
||||
: null,
|
||||
@@ -28,7 +33,7 @@ final readonly class SystemContext
|
||||
'sapi' => PHP_SAPI,
|
||||
'timezone' => date_default_timezone_get(),
|
||||
'memory_limit' => ini_get('memory_limit'),
|
||||
'max_execution_time' => ini_get('max_execution_time')
|
||||
'max_execution_time' => ini_get('max_execution_time'),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -45,7 +50,7 @@ final readonly class SystemContext
|
||||
'execution_time' => $this->executionTime,
|
||||
'php_version' => $this->phpVersion,
|
||||
'framework_version' => $this->frameworkVersion,
|
||||
'environment' => $this->environment
|
||||
'environment' => $this->environment,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Exception;
|
||||
|
||||
final class ValidationException extends FrameworkException
|
||||
{
|
||||
public readonly ValidationResult $validationResult;
|
||||
public readonly array $errors;
|
||||
public readonly string $field;
|
||||
|
||||
/**
|
||||
* @param ValidationResult $validationResult Das Validierungsergebnis mit allen Fehlern
|
||||
* @param string|null $field Optionaler einzelner Feldname für Rückwärtskompatibilität
|
||||
*/
|
||||
public function __construct(
|
||||
ValidationResult $validationResult,
|
||||
?string $field = null
|
||||
) {
|
||||
$this->validationResult = $validationResult;
|
||||
|
||||
// Für Rückwärtskompatibilität: Wenn nur ein Feld angegeben wurde, verwende dessen Fehler
|
||||
if ($field !== null && $validationResult->getFieldErrors($field)) {
|
||||
$this->field = $field;
|
||||
$this->errors = $validationResult->getFieldErrors($field);
|
||||
} else {
|
||||
// Andernfalls verwende das erste Feld oder einen Standard
|
||||
$allErrors = $validationResult->getAll();
|
||||
$firstField = array_key_first($allErrors);
|
||||
$this->field = $firstField ?? 'unknown';
|
||||
$this->errors = $firstField ? $allErrors[$firstField] : [];
|
||||
}
|
||||
|
||||
// Erstelle eine aussagekräftige Fehlernachricht aus allen Fehlern
|
||||
$message = $this->createErrorMessage();
|
||||
|
||||
// Erstelle Exception-Kontext
|
||||
$context = ExceptionContext::forOperation('validation.validate', 'Validator')
|
||||
->withData([
|
||||
'failed_fields' => array_keys($validationResult->getAll()),
|
||||
'error_count' => count($validationResult->getAllErrorMessages()),
|
||||
'primary_field' => $this->field,
|
||||
'validation_errors' => $validationResult->getAll()
|
||||
]);
|
||||
|
||||
parent::__construct(message: $message, context: $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine strukturierte Fehlernachricht aus allen Validierungsfehlern
|
||||
*/
|
||||
private function createErrorMessage(): string
|
||||
{
|
||||
$allErrors = $this->validationResult->getAll();
|
||||
|
||||
if (empty($allErrors)) {
|
||||
return 'Unbekannter Validierungsfehler.';
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
foreach ($allErrors as $field => $fieldErrors) {
|
||||
$fieldMessage = $field . ': ' . implode(', ', $fieldErrors);
|
||||
$messages[] = $fieldMessage;
|
||||
}
|
||||
|
||||
return implode('; ', $messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Fehlermeldungen für ein bestimmtes Feld zurück
|
||||
*
|
||||
* @param string $field Feldname
|
||||
* @return array<string> Liste der Fehlermeldungen für das Feld
|
||||
*/
|
||||
public function getFieldErrors(string $field): array
|
||||
{
|
||||
return $this->validationResult->getFieldErrors($field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Fehlermeldungen als Array zurück
|
||||
*
|
||||
* @return array<string, string[]> Alle Fehlermeldungen gruppiert nach Feldern
|
||||
*/
|
||||
public function getAllErrors(): array
|
||||
{
|
||||
return $this->validationResult->getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Fehlermeldungen als flache Liste zurück
|
||||
*
|
||||
* @return array<string> Liste aller Fehlermeldungen
|
||||
*/
|
||||
public function getAllErrorMessages(): array
|
||||
{
|
||||
return $this->validationResult->getAllErrorMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob ein bestimmtes Feld Fehler hat
|
||||
*/
|
||||
public function hasFieldErrors(string $field): bool
|
||||
{
|
||||
return !empty($this->validationResult->getFieldErrors($field));
|
||||
}
|
||||
|
||||
/**
|
||||
* Statische Factory-Methode für einfache Einzelfeld-Fehler (Rückwärtskompatibilität)
|
||||
*
|
||||
* @param array<string> $errors Liste der Fehlermeldungen
|
||||
* @param string $field Feldname
|
||||
* @return self
|
||||
*/
|
||||
public static function forField(array $errors, string $field): self
|
||||
{
|
||||
$validationResult = new ValidationResult();
|
||||
$validationResult->addErrors($field, $errors);
|
||||
|
||||
return new self($validationResult, $field);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user