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)
|
## 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.
|
- 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
|
public static function validateCsrfToken(string $token): void
|
||||||
{
|
{
|
||||||
self::startSession();
|
self::startSession();
|
||||||
$stored = $_SESSION['csrf_token'] ?? '';
|
if (!self::isValidCsrfToken($token)) {
|
||||||
if ($stored === '' || !hash_equals($stored, $token)) {
|
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
exit('CSRF-Token ungültig.');
|
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;
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
|
use Steuerkram\Auth;
|
||||||
use Steuerkram\Config;
|
use Steuerkram\Config;
|
||||||
use Steuerkram\Models\BankProvider;
|
use Steuerkram\Models\BankProvider;
|
||||||
use Steuerkram\Models\BankSource;
|
use Steuerkram\Models\BankSource;
|
||||||
|
|
@ -510,6 +511,8 @@ class BankController extends BaseController
|
||||||
|
|
||||||
public function direktRun(array $params): void
|
public function direktRun(array $params): void
|
||||||
{
|
{
|
||||||
|
Auth::validateCsrfToken((string)($_GET['csrf_token'] ?? ''));
|
||||||
|
|
||||||
$id = (int)($params['id'] ?? 0);
|
$id = (int)($params['id'] ?? 0);
|
||||||
$source = BankSource::find($id);
|
$source = BankSource::find($id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -470,6 +470,7 @@ $csvDateFormatOptions = [
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const connections = <?= $connectionsJson ?>;
|
const connections = <?= $connectionsJson ?>;
|
||||||
|
const csrfToken = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||||
|
|
||||||
// ── NEU-Modal ────────────────────────────────────────────────
|
// ── NEU-Modal ────────────────────────────────────────────────
|
||||||
const modalNew = document.getElementById('modal-new-connection');
|
const modalNew = document.getElementById('modal-new-connection');
|
||||||
|
|
@ -847,7 +848,7 @@ $csvDateFormatOptions = [
|
||||||
modalRun.style.display = 'flex';
|
modalRun.style.display = 'flex';
|
||||||
|
|
||||||
if (activeEs) activeEs.close();
|
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) => {
|
activeEs.addEventListener('log', (ev) => {
|
||||||
runLogArea.textContent += ev.data + '\n';
|
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