Skip to content

How-to: Budget-Tracking-API ​

FT-Referenz: FT244 (NENE2-FT/budgetlog) — Budget-Tracking-API ATK: FT244 — Cracker-Mindset-Angriffstest (ATK-01 bis ATK-12)

Demonstriert eine Multi-Konto-Budget-Tracking-API mit den Transaktionstypen income/expense/transfer, TransferFundsUseCase mit Saldoprüfung innerhalb einer DB-Transaktion, Multi-Filter-Transaktionsauflistung mit QueryStringParser und Kategorieaggregation.


Routen ​

MethodePfadBeschreibung
GET/accountsAlle Konten auflisten
POST/accountsEin Konto erstellen (optionaler Anfangssaldo)
GET/accounts/{id}Ein einzelnes Konto abrufen
POST/accounts/{id}/transactionsEinnahmen- oder Ausgabentransaktion erfassen
GET/accounts/{id}/transactionsTransaktionen auflisten (filterbar, paginiert)
GET/accounts/{id}/summarySaldo + Einnahmen/Ausgaben nach Kategorie
POST/transfersGeld zwischen zwei Konten überweisen

Schema ​

sql
CREATE TABLE IF NOT EXISTS accounts (
    id      INTEGER PRIMARY KEY AUTOINCREMENT,
    name    TEXT    NOT NULL,
    balance INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE IF NOT EXISTS transactions (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    account_id  INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    amount      INTEGER NOT NULL,
    type        TEXT    NOT NULL CHECK(type IN ('income','expense','transfer')),
    category    TEXT    NOT NULL,
    description TEXT    NOT NULL DEFAULT '',
    recurring   INTEGER NOT NULL DEFAULT 0,
    created_at  TEXT    NOT NULL
);

balance und amount werden als Integer gespeichert (kleinste Währungseinheit, z. B. Cent). type wird auf DB-Ebene durch CHECK(type IN ('income','expense','transfer')) eingeschränkt. recurring wird als INTEGER (0/1) gespeichert und auf ein PHP-bool abgebildet.


Transaktionstyp-Allowlist ​

Der Controller validiert type gegen eine explizite Allowlist:

php
if (!in_array($type, ['income', 'expense'], true)) {
    $errors[] = new ValidationError('type', 'Type must be income or expense.', 'invalid_value');
}

Über die API werden nur income und expense akzeptiert. Der Typ transfer wird intern von TransferFundsUseCase gesetzt — Aufrufer können ihn nicht direkt über POST /accounts/{id}/transactions einschleusen.


Saldo-Update: Lesen-dann-Schreiben-Muster ​

POST /accounts/{id}/transactions aktualisiert den Kontosaldo nach der Erfassung der Transaktion:

php
$delta = $type === 'income' ? $amount : -$amount;
$this->accounts->updateBalance($id, $account->balance + $delta);

Der Saldo wird zuerst gelesen (findById), das Delta in PHP berechnet, dann zurückgeschrieben (updateBalance). Dies ist nicht atomar — gleichzeitige Anfragen können eine Race Condition erzeugen (siehe ATK-09).


TransferFundsUseCase: Saldoprüfung + DB-Transaktion ​

Überweisungen werden in einer DB-Transaktion durchgeführt, um Konsistenz zu gewährleisten:

php
public function execute(int $fromId, int $toId, int $amount, string $description): void
{
    if ($amount <= 0) {
        throw new ValidationException([
            new ValidationError('amount', 'Amount must be greater than zero.', 'out_of_range'),
        ]);
    }

    $this->txManager->transactional(function (DatabaseQueryExecutorInterface $tx) use ($fromId, $toId, $amount, $description): void {
        // Repositories innerhalb des Callbacks mit dem Transaktions-Executor instanzieren
        $accounts     = new SqliteAccountRepository($tx);
        $transactions = new SqliteTransactionRepository($tx);

        $from = $accounts->findById($fromId);
        $to   = $accounts->findById($toId);

        if ($from === null) {
            throw new ValidationException([new ValidationError('from_account_id', 'Source account not found.', 'not_found')]);
        }
        if ($to === null) {
            throw new ValidationException([new ValidationError('to_account_id', 'Destination account not found.', 'not_found')]);
        }
        if ($from->balance < $amount) {
            throw new ValidationException([new ValidationError('amount', 'Insufficient balance.', 'insufficient_balance')]);
        }

        $accounts->updateBalance($fromId, $from->balance - $amount);
        $accounts->updateBalance($toId, $to->balance + $amount);

        $transactions->create($fromId, $amount, 'transfer', 'transfer', $description, false, $now);
        $transactions->create($toId, $amount, 'transfer', 'transfer', $description, false, $now);
    });
}

Repositories werden innerhalb des Transaktions-Closures mit dem $tx-Executor instanziert — dies stellt sicher, dass alle Lese- und Schreibvorgänge dieselbe Verbindung und Transaktionsgrenze teilen. Wenn ein Schritt eine Exception wirft, wird die gesamte Transaktion zurückgerollt.

Die Gleich-Konto-Prüfung befindet sich im Controller:

php
if ($fromId === $toId && $fromId > 0) {
    $errors[] = new ValidationError('to_account_id', 'Cannot transfer to the same account.', 'invalid_value');
}

Multi-Filter-Transaktionsauflistung ​

GET /accounts/{id}/transactions unterstützt mehrere gleichzeitige Filter:

php
$category  = QueryStringParser::string($req, 'category');
$minAmount = QueryStringParser::int($req, 'min_amount');
$maxAmount = QueryStringParser::int($req, 'max_amount');
$recurring = QueryStringParser::bool($req, 'recurring');

QueryStringParser::int() gibt null zurück, wenn der Parameter fehlt — kein Filter. QueryStringParser::bool() gibt null zurück bei Fehlen, true für "true"/"1", false für "false"/"0".

Das Repository erstellt die WHERE-Klausel dynamisch:

php
if ($category !== null)  { $where[] = 'category = ?'; $params[] = $category; }
if ($minAmount !== null) { $where[] = 'amount >= ?';  $params[] = $minAmount; }
if ($maxAmount !== null) { $where[] = 'amount <= ?';  $params[] = $maxAmount; }
if ($recurring !== null) { $where[] = 'recurring = ?'; $params[] = (int) $recurring; }

Kategorie-Zusammenfassungsaggregation ​

GET /accounts/{id}/summary gibt Saldo und Summen gruppiert nach Kategorie zurück:

php
return $this->json->create([
    'balance'             => $account->balance,
    'income_by_category'  => $incomeByCategory,
    'expense_by_category' => $expenseByCategory,
]);

Das Repository verwendet GROUP BY category mit SUM(amount):

sql
SELECT category, SUM(amount) AS total
FROM transactions
WHERE account_id = ? AND type = ?
GROUP BY category
ORDER BY total DESC

ATK — Cracker-Mindset-Angriffstest (FT244) ​

ATK-01 — Keine Authentifizierung: Konten und Transaktionen sind öffentlich ​

Angriff: Alle Konten ohne Anmeldeinformationen auflisten.

bash
curl -s http://localhost:8200/accounts
curl -s http://localhost:8200/accounts/1/transactions

Beobachtet: Beide Endpunkte geben Daten ohne Authentifizierung zurück. Jeder Aufrufer kann alle Konten und ihre Salden enumerieren.

Urteil: EXPOSED — Authentifizierung (API-Key, JWT oder Session) zu allen Endpunkten hinzufügen. Konten sollten benutzerbezogen sein.


ATK-02 — Konto mit negativem Anfangssaldo erstellen ​

Angriff: Negativsaldo-Prüfung umgehen.

json
{"name": "Attack", "initial_balance": -99999}

Beobachtet: $initialBalance < 0-Prüfung greift → 422 Unprocessable Entity mit out_of_range-Fehler.

Urteil: BLOCKED — expliziter Guard lehnt negative Anfangssalden ab.


ATK-03 — Ausgabe treibt Kontosaldo negativ ​

Angriff: Eine Ausgabe größer als der Kontosaldo über eine direkte Transaktion erfassen.

bash
# Konto hat Saldo 100
curl -X POST /accounts/1/transactions \
  -d '{"amount": 99999, "type": "expense", "category": "food"}'

Beobachtet: Der createTransaction-Handler liest den Saldo und subtrahiert ohne Ausreichlichkeitsprüfung. 100 - 99999 = -99899 — der Saldo wird als negative Ganzzahl geschrieben.

Urteil: EXPOSED — POST /accounts/{id}/transactions erzwingt keine nicht-negative Saldobeschränkung. Nur POST /transfers (via TransferFundsUseCase) prüft if ($from->balance < $amount). Eine Saldo-Ausreichlichkeitsprüfung in createTransaction für Ausgabentransaktionen hinzufügen.


ATK-04 — SQL-Injection via Kategorie oder Beschreibung ​

Angriff: SQL-Metazeichen in category oder description einbetten.

json
{"amount": 1, "type": "income", "category": "'; DROP TABLE transactions; --"}

Beobachtet: Alle Werte werden als parametrisierte ?-Werte gebunden. Keine String-Verkettung mit SQL. Der Injection-Payload wird als Literal-Text gespeichert.

Urteil: BLOCKED — parametrisierte Abfragen verhindern SQL-Injection.


ATK-05 — Float-Betrag: (int)-Cast-Abschneidung ​

Angriff: Einen Gleitkomma-Betrag senden.

json
{"amount": 1.9, "type": "income", "category": "x"}

Beobachtet: (int) $body['amount'] schneidet 1.9 auf 1 ab. Der Betrag 1.9 wird stillschweigend akzeptiert und als 1 gespeichert. Ein Aufrufer, der erwartet, dass 1.9 abgelehnt (oder auf 2 gerundet) wird, wäre überrascht.

Urteil: TEILWEISE BLOCKED — Nicht-Integer-Floats werden akzeptiert und stillschweigend abgeschnitten. is_int($body['amount']) verwenden, um Nicht-Integer-Typen explizit abzulehnen und 422 für 1.9 zurückzugeben.


ATK-06 — Null- oder negativer Betrag ​

Angriff: amount: 0 oder amount: -100 übermitteln.

json
{"amount": 0, "type": "income", "category": "x"}
{"amount": -100, "type": "income", "category": "x"}

Beobachtet: $amount <= 0-Prüfung greift für beide → 422 Unprocessable Entity.

Urteil: BLOCKED — expliziter Guard lehnt Null- und Negativbeträge ab.


ATK-07 — Überweisung auf dasselbe Konto ​

Angriff: Geld von einem Konto auf dasselbe Konto überweisen.

json
{"from_account_id": 1, "to_account_id": 1, "amount": 100}

Beobachtet: $fromId === $toId && $fromId > 0 greift → 422 Unprocessable Entity mit invalid_value-Fehler auf to_account_id.

Urteil: BLOCKED — Gleich-Konto-Überweisung wird explizit abgelehnt.


ATK-08 — Überweisung mit unzureichendem Saldo ​

Angriff: Mehr als den Saldo des Quellkontos überweisen.

json
{"from_account_id": 1, "to_account_id": 2, "amount": 99999}

Beobachtet: Innerhalb der Transaktion greift $from->balance < $amount → ValidationException mit insufficient_balance → Transaktion wird zurückgerollt → 422. Kein Saldo ändert sich.

Urteil: BLOCKED — TransferFundsUseCase prüft den Saldo innerhalb der DB-Transaktion. Rollback stellt Atomarität sicher.


ATK-09 — Race Condition bei direkter Ausgabentransaktion ​

Angriff: Zwei gleichzeitige Ausgaben-Anfragen senden, die beide die Saldoprüfung bestehen (es gibt keine), aber zusammen den Saldo überschreiten.

Beobachtet: createTransaction verwendet ein Lesen-dann-Schreiben-Muster ohne Transaktion:

  1. Thread A liest balance = 100
  2. Thread B liest balance = 100
  3. Thread A erfasst Ausgabe von 80 → schreibt balance = 20
  4. Thread B erfasst Ausgabe von 80 → schreibt balance = 20 (sollte -60 sein)

Die balance-Spalte endet bei 20 statt dem korrekten -60 — aber noch kritischer: die Geschäftsbeschränkung (nicht-negativer Saldo) wird für direkte Transaktionen überhaupt nicht erzwungen.

Urteil: EXPOSED — der createTransaction-Pfad hat keinen Saldo-Guard und kein Transaktions-Wrapping. Beheben durch: (1) Hinzufügen von if ($type === 'expense' && $account->balance < $amount) → 422, und (2) das Lesen-dann-Schreiben in einer DB-Transaktion einschließen.


ATK-10 — Zugriff auf Transaktionen eines anderen Kontos (keine Eigentumsrechte) ​

Angriff: Transaktionen lesen, die einem anderen Benutzerkonto gehören.

bash
curl -s http://localhost:8200/accounts/2/transactions

Beobachtet: Der Endpunkt gibt alle Transaktionen für Konto 2 ohne Eigentümerprüfung zurück. Da keine Authentifizierung vorhanden ist, kann jeder Aufrufer jedes Konto lesen.

Urteil: EXPOSED (gleiche Grundursache wie ATK-01). Konten müssen auf einen authentifizierten Benutzer begrenzt sein — WHERE account_id = ? AND owner_id = ?.


ATK-11 — recurring-Feld: Wahrheitswert-Zwang ​

Angriff: Nicht-boolesche Werte für recurring senden.

json
{"amount": 1, "type": "income", "category": "x", "recurring": "yes"}
{"amount": 1, "type": "income", "category": "x", "recurring": 1}
{"amount": 1, "type": "income", "category": "x", "recurring": 0}

Beobachtet: (bool) $body['recurring'] wandelt "yes" → true, 1 → true, 0 → false um. Jeder truthy-String-Wert setzt recurring = true. Es gibt keine strikte is_bool()-Prüfung.

Urteil: TEILWEISE BLOCKED — Nicht-boolesche Typen werden stillschweigend umgewandelt. is_bool($body['recurring']) für strikte Typprüfung verwenden und 422 für nicht-booleschen Input zurückgeben.


ATK-12 — Nicht-numerische Konto-ID im Pfad ​

Angriff: Einen String als ID im Pfadparameter übergeben.

GET /accounts/abc/transactions
GET /accounts/1.5/transactions

Beobachtet: (int) 'abc' = 0, (int) '1.5' = 1.

  • abc → findById(0) → gibt null zurück → 404 Not Found.
  • 1.5 → findById(1) → wenn Konto 1 existiert, wird es stillschweigend zurückgegeben.

Urteil: TEILWEISE BLOCKED — nicht-numerische Strings werden auf 404 abgebildet. Float-Strings werden stillschweigend abgeschnitten. ctype_digit()-Validierung für strenge Pfadparameter-Prüfung hinzufügen.


ATK-Zusammenfassung ​

#AngriffsvektorUrteil
ATK-01Keine Authentifizierung (alle Endpunkte öffentlich)EXPOSED
ATK-02Negativer AnfangssaldoBLOCKED
ATK-03Ausgabe treibt Saldo negativEXPOSED
ATK-04SQL-Injection via Kategorie/BeschreibungBLOCKED
ATK-05Float-Betrag stillschweigend abgeschnittenTEILWEISE BLOCKED
ATK-06Null- oder negativer BetragBLOCKED
ATK-07Überweisung auf dasselbe KontoBLOCKED
ATK-08Überweisung mit unzureichendem SaldoBLOCKED
ATK-09Race Condition bei direkter AusgabeEXPOSED
ATK-10Kontoübergreifender Datenzugriff (keine Eigentumsrechte)EXPOSED
ATK-11recurring-Nicht-Boolean-ZwangTEILWEISE BLOCKED
ATK-12Nicht-numerische Konto-IDTEILWEISE BLOCKED

Echte Schwachstellen, die vor der Produktion behoben werden müssen:

  1. ATK-01 / ATK-10 — Authentifizierung und Pro-Benutzer-Kontoeigentümerschaft hinzufügen
  2. ATK-03 / ATK-09 — Saldo-Ausreichlichkeitsprüfung + DB-Transaktion in createTransaction hinzufügen
  3. ATK-05 — (int)-Cast durch is_int()-Prüfung für strikte Typprüfung ersetzen
  4. ATK-11 — (bool)-Cast durch is_bool()-Prüfung ersetzen
  5. ATK-12 — ctype_digit()-Guard für ID-Pfadparameter hinzufügen

Verwandte Anleitungen ​

Veröffentlicht unter der MIT-Lizenz.