Fix #136: CSRF-Schutz für GET /bank/direkt/{id}/run ergänzt

This commit is contained in:
fds2610-bot 2026-07-27 11:53:48 +02:00
parent a1d4009b49
commit 254761082f
5 changed files with 67 additions and 3 deletions

View file

@ -2,6 +2,14 @@
---
## 2026-07-27 – Fix: CSRF-Schutz für GET /bank/direkt/{id}/run (Issue #136)
- Der SSE-Endpunkt `GET /bank/direkt/{id}/run` löste einen echten Bankabruf (PayPal/FinTS) allein anhand der Session aus, ohne CSRF-Prüfung – da `EventSource` nur `GET` unterstützt, griff die bestehende CSRF-Middleware (nur für `POST`/`PUT`/`DELETE`/`PATCH`) nicht. Ein Angreifer konnte per Top-Level-Navigation (SameSite=Lax) einen eingeloggten Nutzer dazu bringen, unbeabsichtigt einen Abruf für eine beliebige Verbindungs-ID auszulösen.
- **`src/Auth.php`:** `validateCsrfToken()` prüft jetzt über die neue, seiteneffektfreie `isValidCsrfToken()` (analog zu `cookieParams()`), damit sich die Vergleichslogik isoliert testen lässt.
- **`src/Controllers/BankController.php`:** `direktRun()` validiert vor dem eigentlichen Abruf das per `csrf_token`-Query-Parameter übergebene Token gegen die Session.
- **`src/Views/bank/fints.php`:** Die `EventSource`-URL hängt das bereits vorhandene, in der Seite eingebettete CSRF-Token (`meta[name="csrf-token"]`) als Query-Parameter an.
- **`tests/62_BankDirektRunCsrfTest.php`:** Neuer Test prüft `Auth::isValidCsrfToken()` gegen korrektes, falsches, leeres und fehlendes Session-Token.
## 2026-07-27 – Feature: Netto/Brutto-Umschalter in der Buchungsmaske (Issue #137)
- Beim Erfassen einer Buchung musste bisher immer der Nettobetrag eingegeben werden – lag nur der Bruttobetrag vor (z. B. von einem Kassenbon), führte die manuelle Rückrechnung regelmäßig zu Rundungsdifferenzen.

View file

@ -102,10 +102,19 @@ class Auth
public static function validateCsrfToken(string $token): void
{
self::startSession();
$stored = $_SESSION['csrf_token'] ?? '';
if ($stored === '' || !hash_equals($stored, $token)) {
if (!self::isValidCsrfToken($token)) {
http_response_code(403);
exit('CSRF-Token ungültig.');
}
}
/**
* Reiner Vergleich ohne session_start()-Seiteneffekt, damit sich die
* Prüflogik isoliert testen lässt (siehe cookieParams()).
*/
public static function isValidCsrfToken(string $token): bool
{
$stored = $_SESSION['csrf_token'] ?? '';
return $stored !== '' && hash_equals($stored, $token);
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Auth;
use Steuerkram\Config;
use Steuerkram\Models\BankProvider;
use Steuerkram\Models\BankSource;
@ -510,6 +511,8 @@ class BankController extends BaseController
public function direktRun(array $params): void
{
Auth::validateCsrfToken((string)($_GET['csrf_token'] ?? ''));
$id = (int)($params['id'] ?? 0);
$source = BankSource::find($id);

View file

@ -470,6 +470,7 @@ $csvDateFormatOptions = [
<script>
(function () {
const connections = <?= $connectionsJson ?>;
const csrfToken = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
// ── NEU-Modal ────────────────────────────────────────────────
const modalNew = document.getElementById('modal-new-connection');
@ -847,7 +848,7 @@ $csvDateFormatOptions = [
modalRun.style.display = 'flex';
if (activeEs) activeEs.close();
activeEs = new EventSource('/bank/direkt/' + id + '/run');
activeEs = new EventSource('/bank/direkt/' + id + '/run?csrf_token=' + encodeURIComponent(csrfToken));
activeEs.addEventListener('log', (ev) => {
runLogArea.textContent += ev.data + '\n';

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Steuerkram\Auth;
class BankDirektRunCsrfTest extends TestCase
{
protected string $testName = 'Test 62: CSRF-Schutz für GET /bank/direkt/{id}/run';
public function run(): void
{
$backup = $_SESSION['csrf_token'] ?? null;
try {
$_SESSION['csrf_token'] = 'geheimes-session-token';
$this->assert(
Auth::isValidCsrfToken('geheimes-session-token'),
'Korrektes Token wird akzeptiert'
);
$this->assert(
!Auth::isValidCsrfToken('falsches-token'),
'Falsches Token wird abgelehnt'
);
$this->assert(
!Auth::isValidCsrfToken(''),
'Leeres Token (fehlender Query-Parameter) wird abgelehnt'
);
unset($_SESSION['csrf_token']);
$this->assert(
!Auth::isValidCsrfToken('irgendein-token'),
'Ohne Session-Token wird jedes Token abgelehnt'
);
} finally {
if ($backup === null) {
unset($_SESSION['csrf_token']);
} else {
$_SESSION['csrf_token'] = $backup;
}
}
}
}