Skip to content

How-to: Habit Tracker API

FT-Referenz: FT24 (NENE2-FT/habitlog) — Habit-Tracking-API mit Streak-Berechnung ATK: FT224 — Cracker-Mindset-Angriffstest (ATK-01 bis ATK-12)

Demonstriert eine Habit-Tracking-REST-API mit Streak-Berechnung, doppelter Abschlussschutz (409 Conflict) und Frequenz-Allowlisting. Der ATK-Abschnitt dokumentiert jede Angriffsfläche, die der Cracker-Mindset findet, und notiert, ob jede verteidigt oder exponiert ist.


Routen

MethodePfadBeschreibung
GET/habitsAlle Habits auflisten (?frequency=)
POST/habitsHabit erstellen
GET/habits/{id}Einzelnes Habit abrufen
DELETE/habits/{id}Habit löschen (kaskadiert)
POST/habits/{id}/completionsAbschluss erfassen (idempotent auf Datums-Ebene)
GET/habits/{id}/completionsAbschlüsse für ein Habit auflisten
GET/habits/{id}/streakAktueller Streak (?today=YYYY-MM-DD)

Habits erstellen

php
// POST /habits
$body = [
    'name'        => 'Morgenläufe',     // erforderlich, nicht leerer String
    'description' => '5 km laufen',     // optional
    'frequency'   => 'daily',           // 'daily' | 'weekly' | 'monthly'
];

frequency wird gegen eine explizite Allowlist validiert. Jeder andere Wert gibt 422 zurück.

php
private function createHabit(ServerRequestInterface $req): mixed
{
    $body      = JsonRequestBodyParser::parse($req);
    $name      = isset($body['name']) ? trim((string) $body['name']) : '';
    $frequency = isset($body['frequency']) ? (string) $body['frequency'] : 'daily';

    $errors = [];
    if ($name === '') {
        $errors[] = new ValidationError('name', 'Name must not be empty.', 'required');
    }

    $validFrequencies = ['daily', 'weekly', 'monthly'];
    if (!in_array($frequency, $validFrequencies, true)) {
        $errors[] = new ValidationError('frequency', 'Frequency must be daily, weekly, or monthly.', 'invalid_value');
    }

    if ($errors !== []) {
        throw new ValidationException($errors);
    }
    // ...
}

Abschlüsse mit doppeltem Schutz erfassen

Abschlüsse sind durch (habit_id, completed_on) via eine UNIQUE-Constraint gekennzeichnet. Ein zweiter POST für dasselbe Datum gibt 409 Conflict zurück, ohne die Datenbankzeile zu berühren.

sql
-- schema.sql
UNIQUE(habit_id, completed_on)
php
public function complete(int $habitId, string $completedOn, string $note): Completion
{
    try {
        $this->executor->execute(
            'INSERT INTO completions (habit_id, completed_on, note) VALUES (?, ?, ?)',
            [$habitId, $completedOn, $note],
        );
    } catch (DatabaseConnectionException $e) {
        $previous = $e->getPrevious();
        if ($previous !== null && str_contains($previous->getMessage(), 'UNIQUE constraint failed')) {
            throw new AlreadyCompletedException($habitId, $completedOn);
        }
        throw $e;
    }

    return new Completion($this->executor->lastInsertId(), $habitId, $completedOn, $note);
}

Der Controller mappt AlreadyCompletedException → 409, bevor NECEs globaler Fehlerhandler es sieht, sodass die Antwort korrekt Problem Details verwendet.


Streak-Berechnung

Streak zählt rückwärts von $today durch aufeinanderfolgende tägliche Abschlüsse.

php
public function currentStreak(int $habitId, string $today): int
{
    $rows = $this->executor->fetchAll(
        'SELECT completed_on FROM completions WHERE habit_id = ? ORDER BY completed_on DESC',
        [$habitId],
    );

    $streak   = 0;
    $expected = new \DateTimeImmutable($today);

    foreach ($rows as $row) {
        $date = new \DateTimeImmutable((string) $row['completed_on']);
        if ($date->format('Y-m-d') !== $expected->format('Y-m-d')) {
            break;
        }
        $streak++;
        $expected = $expected->modify('-1 day');
    }

    return $streak;
}

?today=YYYY-MM-DD überschreibt das Referenzdatum, sodass Tests deterministisch sind ohne date() zu mocken.


Datumsformat-Validierung

Das completed_on-Feld wird durch Regex validiert, nicht durch semantisches Parsen:

php
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $completedOn)) {
    throw new ValidationException([
        new ValidationError('completed_on', 'Date must be in YYYY-MM-DD format.', 'invalid_format'),
    ]);
}

Dies lehnt "not-a-date" korrekt ab, akzeptiert aber "2026-02-30". Für strenge semantische Validierung einen DateTimeImmutable-Roundtrip hinzufügen:

php
// Strengere Validierung (für Produktion empfohlen):
$dt = DateTimeImmutable::createFromFormat('Y-m-d', $completedOn);
if ($dt === false || $dt->format('Y-m-d') !== $completedOn) {
    throw new ValidationException([...]);
}

Pfadparameter-Sicherheit

Pfad-{id} wird mit Zero-Fallback zu int gecastet:

php
$id = (int) ($req->getAttribute(Router::PARAMETERS_ATTRIBUTE, [])['id'] ?? 0);

Nicht-numerische Strings werden zu 0. Kein Habit mit id = 0 existiert, sodass der Handler zu einer null-Prüfung fällt und 404 zurückgibt. Dies vermeidet den Bedarf nach ctype_digit() hier, aber beachten Sie, dass (int) "9abc" 9 ergibt — eine Route, die Nicht-Ziffern-Pfade ablehnen muss, sollte stattdessen ctype_digit() verwenden.


Schema: Kaskaden-Delete

sql
CREATE TABLE completions (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    habit_id     INTEGER NOT NULL REFERENCES habits(id) ON DELETE CASCADE,
    completed_on TEXT    NOT NULL,
    note         TEXT    NOT NULL DEFAULT '',
    UNIQUE(habit_id, completed_on)
);

ON DELETE CASCADE stellt sicher, dass Abschlüsse entfernt werden, wenn das übergeordnete Habit gelöscht wird. Fremdschlüssel-Durchsetzung mit PRAGMA foreign_keys = ON bei SQLite aktivieren.


ATK — Cracker-Angriffstest (FT224)

Jeder Befund unten dokumentiert einen Angriffsvektor, das beobachtete Ergebnis und das Urteil: BLOCKED (sicher), EXPOSED (echte Schwachstelle) oder ACCEPTED BY DESIGN (absichtlicher Kompromiss dokumentiert).

ATK-01 — Keine Authentifizierung auf einem Endpunkt

Angriff: Habits erstellen, lesen oder löschen ohne Anmeldedaten.

http
POST /habits
Content-Type: application/json

{"name": "Angreifer-Habit", "frequency": "daily"}

Beobachtet: 201 Created — Erfolg ohne Token, Session oder Key.

Urteil: EXPOSED (by design für FT24 Demo). Produktions-Habit-Tracker MÜSSEN Mutationen hinter Authentifizierung schützen. NECEs MachineApiKeyMiddleware oder JWT-Bearer-Middleware deckt dies ab.


ATK-02 — Keine Eigentümerschaft: Beliebiges Habit lesen/löschen

Angriff: Ohne zu wissen, wessen Habit es ist, alle Habits enumerieren und löschen.

http
GET /habits         → listet alle Habits im System
DELETE /habits/1    → löscht Habit #1 unabhängig vom Ersteller

Beobachtet: 200 OK bei Liste, 200 OK beim Löschen.

Urteil: EXPOSED (by design für FT24 Demo). Eine user_id-Spalte, Eigentümerschatsprüfung auf Schreibpfaden und 404 (nicht 403) bei unbefugtem Zugriff hinzufügen (IDOR-Schutz — siehe FT222 notificationlog).


ATK-03 — SQL-Injection via parametrisierte Abfragen

Angriff: SQL durch name, frequency oder completed_on einschleusen.

json
{"name": "x' OR '1'='1", "frequency": "daily"}
{"completed_on": "2026-01-01' OR '1'='1"}

Beobachtet: Name wörtlich gespeichert. Abschluss durch Datumsformat-Regex abgelehnt, bevor die DB-Schicht erreicht wird.

Urteil: BLOCKED — alle Abfragen verwenden PDO-parametrisierte Statements. Die Frequenz-Allowlist blockiert Injection über dieses Feld auf Anwendungsebene.


ATK-04 — Semantisch ungültiges Datum akzeptiert

Angriff: Strukturell korrektes, aber kalendarisch ungültiges Datum einreichen.

json
{"completed_on": "2026-02-30"}
{"completed_on": "2026-13-01"}
{"completed_on": "0000-00-00"}

Beobachtet: 201 Created — Regex ^\d{4}-\d{2}-\d{2}$ passiert; PDO speichert den String wörtlich; DateTimeImmutable normalisiert es still (z. B. 2026-02-30 wird zu 2026-03-02), was Streak-Zählungen korrumpiert.

Urteil: EXPOSED — Roundtrip-Prüfung hinzufügen:

php
$dt = DateTimeImmutable::createFromFormat('Y-m-d', $completedOn);
if ($dt === false || $dt->format('Y-m-d') !== $completedOn) {
    throw new ValidationException([...]);
}

ATK-05 — Nicht-numerische Pfad-IDs

Angriff: Nicht-Ziffern- oder negative Werte als {id} senden.

http
GET  /habits/abc
GET  /habits/-1
GET  /habits/0
GET  /habits/1.5

Beobachtet: Alle geben 404 Not Found zurück. (int) "abc" = 0, (int) "-1" = -1, (int) "1.5" = 1. Kein Habit existiert bei diesen IDs, also gibt findById() null zurück.

Urteil: BLOCKED in der Praxis (kein Habit mit ID ≤ 0 existiert). Jedoch ergibt (int) "9abc" = 9 — wenn ein Habit mit ID 9 existiert, würde es zurückgegeben. ctype_digit() für strikte Pfad-ID-Validierung verwenden, wenn der Unterschied wichtig ist.


ATK-06 — Doppelter Abschluss am selben Datum

Angriff: Dasselbe (habit_id, completed_on) zweimal posten, um Streaks aufzublähen.

http
POST /habits/1/completions {"completed_on": "2026-05-20"}
POST /habits/1/completions {"completed_on": "2026-05-20"}

Beobachtet: Zweite Anfrage gibt 409 Conflict zurück — die UNIQUE-Constraint auf DB-Ebene löst aus, AlreadyCompletedException wird abgefangen, und eine Problem-Details-Antwort wird zurückgegeben.

Urteil: BLOCKED — die DB-Constraint ist der maßgebliche Guard; die Anwendungsschicht mappt es auf eine wohlgeformte 409.


ATK-07 — XSS-Payload in name/note

Angriff: Ein Script-Tag in name oder note speichern.

json
{"name": "<script>alert(document.cookie)</script>", "frequency": "daily"}

Beobachtet: 201 Created. Der Payload wird wörtlich gespeichert und unverändert in JSON-Antworten zurückgegeben.

Urteil: ACCEPTED BY DESIGN — dies ist eine JSON-API; Escaping ist die Verantwortung des Rendering-Clients. Der Server produziert kein HTML aus diesen Feldern. Diesen Vertrag klar in der API-Spezifikation dokumentieren.


ATK-08 — Extrem langer Habit-Name

Angriff: Einen Namen mit zehntausenden Zeichen senden, um Speicher zu erschöpfen oder langsame Serialisierung zu verursachen.

php
'name' => str_repeat('A', 50_000)

Beobachtet: 201 Created — kein Längenlimit wird auf Anwendungsebene durchgesetzt. SQLite TEXT ist unbegrenzt; die Zeile wird eingefügt.

Urteil: EXPOSED — eine Max-Längenprüfung hinzufügen (z. B. 200 Zeichen) im Validierungsblock des Controllers und 422 zurückgeben:

php
if (mb_strlen($name) > 200) {
    $errors[] = new ValidationError('name', 'Name must not exceed 200 characters.', 'max_length');
}

ATK-09 — Nur-Leerzeichen Habit-Name

Angriff: Einen Namen senden, der nur aus Leerzeichen besteht.

json
{"name": "   "}

Beobachtet: 422 Unprocessable Entitytrim() reduziert den Wert auf '', was den required-Validierungsfehler auslöst.

Urteil: BLOCKEDtrim() vor der Leerzeichenprüfung deckt dies ab.


ATK-10 — Streak-Manipulation via ?today=-Query-Param

Angriff: Das Referenzdatum überschreiben, um einen historischen Streak zu beanspruchen.

http
GET /habits/1/streak?today=2099-12-31
GET /habits/1/streak?today=not-a-date

Beobachtet: today=2099-12-31 → Streak = 0 (keine Abschlüsse in der Zukunft). today=not-a-date → PHP DateTimeImmutable wirft intern eine Exception auf dem fehlerhaften Wert (wird zu 500 im Standard-Fehlerhandler).

Urteil: TEILWEISE EXPOSEDtoday mit einer Regex- oder Roundtrip-Prüfung validieren, bevor es an currentStreak() übergeben wird:

php
$today = QueryStringParser::string($req, 'today') ?? date('Y-m-d');
$dt    = DateTimeImmutable::createFromFormat('Y-m-d', $today);
if ($dt === false || $dt->format('Y-m-d') !== $today) {
    $today = date('Y-m-d'); // auf Server-Datum zurückfallen
}

ATK-11 — Nicht-existierendes Habit abschließen

Angriff: POST eines Abschlusses für eine Habit-ID, die nicht existiert.

http
POST /habits/99999/completions
{"completed_on": "2026-05-20"}

Beobachtet: 404 Not FoundfindById(99999) gibt null zurück und der Controller gibt die Not-Found-Antwort zurück, bevor das INSERT versucht wird.

Urteil: BLOCKED — die Existenzprüfung erfolgt vor dem DB-Schreibvorgang.


ATK-12 — Path Traversal / Injection in Query-Parametern

Angriff: Path-Traversal- oder Shell-Injection-Strings via frequency-Filter einschleusen.

http
GET /habits?frequency=../../../etc/passwd
GET /habits?frequency='; DROP TABLE habits; --

Beobachtet: Beide geben 200 OK mit einem leeren habits-Array zurück. Der frequency-Wert wird nur in array_filter mit einem strengen ===-Vergleich gegen gespeicherte Werte verwendet. Keine DB-Abfrage wird daraus konstruiert.

Urteil: BLOCKED — Filter-by-Query-Param wird im PHP-Speicher angewendet, nicht als rohe SQL-WHERE-Klausel. Keine Datei-I/O oder Shell-Ausführung wird ausgelöst.


ATK-Zusammenfassung

#VektorUrteil
ATK-01Keine AuthentifizierungEXPOSED (by design)
ATK-02Keine Eigentümerschaft / IDOREXPOSED (by design)
ATK-03SQL-InjectionBLOCKED
ATK-04Semantisch ungültiges DatumEXPOSED
ATK-05Nicht-numerische Pfad-IDBLOCKED
ATK-06Doppelter AbschlussBLOCKED
ATK-07XSS-Payload-SpeicherungACCEPTED BY DESIGN
ATK-08Unbegrenzter NamensstringEXPOSED
ATK-09Nur-Leerzeichen-NameBLOCKED
ATK-10?today=-ManipulationTEILWEISE EXPOSED
ATK-11Nicht-existierender Habit-AbschlussBLOCKED
ATK-12Path Traversal / Injection in QSBLOCKED

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

  1. ATK-01/02 — Authentifizierung und Eigentümerschaft hinzufügen
  2. ATK-04 — Semantische Datumsvalidierung hinzufügen (Roundtrip via DateTimeImmutable)
  3. ATK-08mb_strlen() Max-Längenprüfung auf name/note hinzufügen
  4. ATK-10?today= vor der Weitergabe an die Geschäftslogik validieren

Verwandte Anleitungen

Veröffentlicht unter der MIT-Lizenz.