Fix #136: CSRF-Schutz für GET /bank/direkt/{id}/run ergänzt
This commit is contained in:
parent
a1d4009b49
commit
254761082f
5 changed files with 67 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
13
src/Auth.php
13
src/Auth.php
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
43
tests/62_BankDirektRunCsrfTest.php
Normal file
43
tests/62_BankDirektRunCsrfTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue