fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled

This commit is contained in:
2025-11-24 21:28:25 +01:00
parent 4eb7134853
commit 77abc65cd7
1327 changed files with 91915 additions and 9909 deletions

View File

@@ -6,6 +6,8 @@ namespace App\Framework\Http\Session;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DateTime\Clock;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Security\CsrfToken;
use App\Framework\Security\CsrfTokenData;
use App\Framework\Security\CsrfTokenGenerator;
@@ -13,48 +15,103 @@ use App\Framework\Security\CsrfTokenGenerator;
final readonly class CsrfProtection
{
private const int TOKEN_LIFETIME = 7200; // 2h - Better UX for long forms
private const int MAX_TOKENS_PER_FORM = 3;
private const int RE_SUBMIT_WINDOW = 30; // 30 seconds for re-submits
private const int USED_TOKEN_CLEANUP_TIME = 300; // 5 minutes
private Session $session;
private ?SessionManager $sessionManager;
public function __construct(
Session $session,
private CsrfTokenGenerator $tokenGenerator,
private Clock $clock
private Clock $clock,
private Logger $logger,
private Environment $environment,
?SessionManager $sessionManager = null
) {
$this->session = $session;
$this->sessionManager = $sessionManager;
// Initialization happens via SessionData now, no need to set here
}
/**
* Log debug information only when debug mode is enabled.
* Sensitive data like tokens and session IDs are hashed before logging.
*/
private function debugLog(string $message, array $context = []): void
{
if (!$this->environment->isDebug()) {
return;
}
$logContext = null;
if (!empty($context)) {
$logContext = LogContext::create();
foreach ($context as $key => $value) {
// Hash sensitive data (tokens, session IDs)
if (in_array($key, ['token', 'session_id', 'stored_token', 'request_token'], true)) {
$value = hash('sha256', (string) $value);
}
$logContext = $logContext->with($key, $value);
}
}
$this->logger->debug($message, $logContext);
}
public function generateToken(string $formId): CsrfToken
{
// Always generate a new token - simplified strategy
$token = $this->tokenGenerator->generate();
$this->debugLog('CsrfProtection::generateToken - Generating new token', ['form_id' => $formId]);
// Use atomic update if SessionManager is available, otherwise fall back to regular update
if ($this->sessionManager !== null) {
$this->debugLog('CsrfProtection::generateToken - Using atomic update', ['form_id' => $formId]);
$success = $this->sessionManager->updateSessionDataAtomically(
$this->session,
function ($data) use ($formId, $token) {
$csrfData = $data->csrfData;
$tokenData = CsrfTokenData::create($token, $this->clock);
// Directly set token (one token per form ID)
$newCsrfData = $csrfData->withFormData($formId, $tokenData);
return $data->withCsrfData($newCsrfData);
}
);
if (!$success) {
$this->debugLog('CsrfProtection::generateToken - Atomic update failed, falling back to regular update');
// Fallback to regular update
$this->updateSessionDataRegular($formId, $token);
} else {
$this->debugLog('CsrfProtection::generateToken - Atomic update successful', ['form_id' => $formId]);
}
} else {
$this->debugLog('CsrfProtection::generateToken - SessionManager not available, using regular update', ['form_id' => $formId]);
// Fallback to regular update if SessionManager is not available
$this->updateSessionDataRegular($formId, $token);
}
$this->debugLog('CsrfProtection::generateToken - Token generated and session updated', ['form_id' => $formId]);
return $token;
}
private function updateSessionDataRegular(string $formId, CsrfToken $token): void
{
// Get current CSRF data from session
$sessionData = $this->session->getSessionData();
$csrfData = $sessionData->csrfData;
// Get tokens for this form
$formTokens = $csrfData->getFormData($formId);
// Add new token
$formTokens[] = CsrfTokenData::create($token, $this->clock)->toArray();
// Cleanup old tokens
$formTokens = $this->cleanupOldTokens($formTokens);
// Update CSRF data
$newCsrfData = $csrfData->withFormData($formId, $formTokens);
// Directly set token (one token per form ID)
$tokenData = CsrfTokenData::create($token, $this->clock);
$newCsrfData = $csrfData->withFormData($formId, $tokenData);
// Update session with new CSRF data
$this->session->updateSessionData(function ($data) use ($newCsrfData) {
return $data->withCsrfData($newCsrfData);
});
return $token;
}
/**
@@ -63,19 +120,7 @@ final readonly class CsrfProtection
*/
public function rotateToken(string $formId): CsrfToken
{
// Invalidate all existing tokens for this form
$sessionData = $this->session->getSessionData();
$csrfData = $sessionData->csrfData;
// Clear tokens for this form
$newCsrfData = $csrfData->withFormData($formId, []);
// Update session
$this->session->updateSessionData(function ($data) use ($newCsrfData) {
return $data->withCsrfData($newCsrfData);
});
// Generate a fresh token
// Simply generate a new token (old one will be replaced)
return $this->generateToken($formId);
}
@@ -86,93 +131,180 @@ final readonly class CsrfProtection
{
$sessionData = $this->session->getSessionData();
$csrfData = $sessionData->csrfData;
$formTokens = $csrfData->getFormData($formId);
$tokenData = $csrfData->getFormData($formId);
if (empty($formTokens)) {
if ($tokenData === null) {
return false;
}
$currentTime = $this->clock->time();
foreach ($formTokens as $tokenData) {
if ($token->equalsString($tokenData['token'])) {
$age = $currentTime - $tokenData['created_at'];
return $age < self::TOKEN_LIFETIME;
}
if (!$tokenData->matches($token->toString())) {
return false;
}
return false;
// Check if token is expired
$lifetime = Duration::fromSeconds(self::TOKEN_LIFETIME);
return !$tokenData->isExpired($this->clock, $lifetime);
}
/**
* Get count of active tokens for a form
* Simplified: Returns 1 if token exists, 0 otherwise
*/
public function getActiveTokenCount(string $formId): int
{
$sessionData = $this->session->getSessionData();
$csrfData = $sessionData->csrfData;
return count($csrfData->getFormData($formId));
return $csrfData->hasForm($formId) ? 1 : 0;
}
/**
* Validate token and return debug information if validation fails
*
* @return array{valid: bool, debug?: array{session_id: string, token_count: int, available_form_ids: array<string>, token_preview: string}}
*/
public function validateTokenWithDebug(string $formId, CsrfToken $token): array
{
// Load fresh session data from storage if SessionManager is available
// This ensures we're working with the latest data, not stale data from the session object
if ($this->sessionManager !== null) {
// Reload session data from storage to get the latest version
$freshData = $this->sessionManager->loadSessionData($this->session->id);
$sessionData = $freshData;
// Update the session object with fresh data
$this->session->setSessionData($freshData);
} else {
// Fallback to session object data if SessionManager is not available
$sessionData = $this->session->getSessionData();
}
$csrfData = $sessionData->csrfData;
$tokenData = $csrfData->getFormData($formId);
$sessionId = $this->session->id->toString();
$allFormIds = $csrfData->getFormIds();
$this->debugLog('CsrfProtection::validateToken - Session ID', ['session_id' => $sessionId]);
$this->debugLog('CsrfProtection::validateToken - formId and token', [
'form_id' => $formId,
'token' => $token->toString()
]);
if ($tokenData === null) {
$this->debugLog('CsrfProtection::validateToken - No token found for formId', ['form_id' => $formId]);
$this->debugLog('CsrfProtection::validateToken - Available form IDs in session', ['available_form_ids' => $allFormIds]);
// Check if token exists for another form ID (common mistake)
$tokenString = $token->toString();
$foundInOtherForm = null;
foreach ($allFormIds as $otherFormId) {
if ($otherFormId === $formId) {
continue;
}
$otherTokenData = $csrfData->getFormData($otherFormId);
if ($otherTokenData !== null && $otherTokenData->matches($tokenString)) {
$foundInOtherForm = $otherFormId;
error_log("CsrfProtection::validateToken - Token found in different form ID: $otherFormId (requested: $formId)");
break;
}
}
return [
'valid' => false,
'debug' => [
'session_id' => $sessionId,
'token_count' => 0,
'available_form_ids' => $allFormIds,
'token_preview' => substr($token->toString(), 0, 20),
'reason' => $foundInOtherForm ? "Token belongs to form ID '$foundInOtherForm' but was used for '$formId'" : 'No matching token found in session',
'token_belongs_to_form_id' => $foundInOtherForm,
],
];
}
// Debug: Log token comparison
$storedTokenString = $tokenData->token->toString();
$requestTokenString = $token->toString();
error_log("CsrfProtection::validateToken - Comparing tokens:");
error_log(" Stored: " . substr($storedTokenString, 0, 20) . "... (length: " . strlen($storedTokenString) . ")");
error_log(" Request: " . substr($requestTokenString, 0, 20) . "... (length: " . strlen($requestTokenString) . ")");
error_log(" Match: " . ($tokenData->matches($token->toString()) ? 'YES' : 'NO'));
if ($tokenData->matches($token->toString())) {
// Check if token is expired
$lifetime = Duration::fromSeconds(self::TOKEN_LIFETIME);
if ($tokenData->isExpired($this->clock, $lifetime)) {
return [
'valid' => false,
'debug' => [
'session_id' => $sessionId,
'token_count' => 1,
'available_form_ids' => $allFormIds,
'token_preview' => substr($token->toString(), 0, 20),
'reason' => 'Token has expired',
],
];
}
// Token validated - rotate to new token
$newToken = $this->tokenGenerator->generate();
error_log("CsrfProtection::validateToken - Token validated, rotating to new token for formId: $formId");
if ($this->sessionManager !== null) {
$this->sessionManager->updateSessionDataAtomically(
$this->session,
function ($data) use ($formId, $newToken) {
$csrfData = $data->csrfData;
$newTokenData = CsrfTokenData::create($newToken, $this->clock);
return $data->withCsrfData($csrfData->withFormData($formId, $newTokenData));
}
);
} else {
// Fallback to regular update
$this->session->updateSessionData(function ($data) use ($formId, $newToken) {
$csrfData = $data->csrfData;
$newTokenData = CsrfTokenData::create($newToken, $this->clock);
return $data->withCsrfData($csrfData->withFormData($formId, $newTokenData));
});
}
return ['valid' => true, 'new_token' => $newToken];
}
// No matching token found - add more debug info
error_log("CsrfProtection::validateToken - No matching token found. Stored token: " . substr($tokenData->token->toString(), 0, 20) . "...");
// Check if token exists for another form ID (common mistake)
$tokenString = $token->toString();
$foundInOtherForm = null;
foreach ($allFormIds as $otherFormId) {
if ($otherFormId === $formId) {
continue;
}
$otherTokenData = $csrfData->getFormData($otherFormId);
if ($otherTokenData !== null && $otherTokenData->matches($tokenString)) {
$foundInOtherForm = $otherFormId;
error_log("CsrfProtection::validateToken - Token found in different form ID: $otherFormId (requested: $formId)");
break;
}
}
return [
'valid' => false,
'debug' => [
'session_id' => $sessionId,
'token_count' => 1,
'available_form_ids' => $allFormIds,
'token_preview' => substr($token->toString(), 0, 20),
'reason' => $foundInOtherForm ? "Token belongs to form ID '$foundInOtherForm' but was used for '$formId'" : 'No matching token found in session',
'token_belongs_to_form_id' => $foundInOtherForm,
],
];
}
public function validateToken(string $formId, CsrfToken $token): bool
{
$sessionData = $this->session->getSessionData();
$csrfData = $sessionData->csrfData;
$formTokens = $csrfData->getFormData($formId);
if (empty($formTokens)) {
return false;
}
$reSubmitWindow = Duration::fromSeconds(self::RE_SUBMIT_WINDOW);
foreach ($formTokens as $index => $tokenArray) {
$tokenData = CsrfTokenData::fromArray($tokenArray, $this->clock);
if ($tokenData->matches($token->toString())) {
// Check if token was already used and if re-submit window has passed
if ($tokenData->usedAt && ! $tokenData->wasUsedRecently($this->clock, $reSubmitWindow)) {
return false; // Token was used too long ago
}
// Mark token as used and save back to session
$updatedTokenData = $tokenData->markAsUsed($this->clock);
$formTokens[$index] = $updatedTokenData->toArray();
// Update CSRF data with marked token
$newCsrfData = $csrfData->withFormData($formId, $formTokens);
// Update session
$this->session->updateSessionData(function ($data) use ($newCsrfData) {
return $data->withCsrfData($newCsrfData);
});
return true;
}
}
return false;
$result = $this->validateTokenWithDebug($formId, $token);
return $result['valid'];
}
private function cleanupOldTokens(array $tokens): array
{
$lifetime = Duration::fromSeconds(self::TOKEN_LIFETIME);
$usedWindow = Duration::fromSeconds(self::USED_TOKEN_CLEANUP_TIME);
$cleaned = [];
foreach ($tokens as $tokenArray) {
$tokenData = CsrfTokenData::fromArray($tokenArray, $this->clock);
// Keep tokens that are either not expired or were used recently
if ($tokenData->shouldKeep($this->clock, $lifetime, $usedWindow)) {
$cleaned[] = $tokenArray; // Keep original array format for session storage
}
}
// Keep only the newest N tokens
return array_slice($cleaned, -self::MAX_TOKENS_PER_FORM);
}
}