Skip to content

How-to: Gleitendes-Fenster-Ratenbegrenzer

Überblick

Diese Anleitung beschreibt den Aufbau eines pro-Benutzer und pro-Endpunkt gleitenden Fenster-Ratenbegrenzers mit NENE2. Anfragen werden innerhalb eines gleitenden Zeitfensters gezählt; sobald das Limit erreicht ist, werden weitere Anfragen mit 429 Too Many Requests abgelehnt.

Referenzimplementierung: ../NENE2-FT/ratelog/


Schema-Design

sql
CREATE TABLE IF NOT EXISTS rate_events (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    endpoint   TEXT    NOT NULL,
    created_at TEXT    NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_rate_events_user_endpoint
    ON rate_events (user_id, endpoint, created_at);

Der Index auf (user_id, endpoint, created_at) macht die COUNT-Abfrage bei größerem Datenvolumen schnell.


Routentabelle

MethodePfadAuthBeschreibung
POST/rate/checkBenutzerAnfrage protokollieren; gibt 429 zurück wenn Limit überschritten
GET/rate/statusBenutzerAktuelle Nutzung für einen Benutzer/Endpunkt
DELETE/rate/reset/{userId}AdminZähler für einen Benutzer zurücksetzen

Kernalgorithmus

php
private const int LIMIT = 10;
private const int WINDOW_SECONDS = 60;

public function check(int $userId, string $endpoint): string
{
    $since = $this->windowStart();   // now() - 60s
    $count = $this->countInWindow($userId, $endpoint, $since);

    if ($count >= self::LIMIT) {
        return 'rate_limited';
    }

    $this->recordEvent($userId, $endpoint);
    return 'ok';
}

Gleitendes Fenster: jeder check()-Aufruf schaut genau WINDOW_SECONDS vom aktuellen Zeitpunkt zurück, sodass alte Ereignisse natürlich aus dem Gültigkeitsbereich fallen.


Admin-Reset mit Fail-Closed-Muster

php
private function isAdmin(ServerRequestInterface $req): bool
{
    if ($this->adminKey === '') {
        return false;     // fail-closed: unkonfigurierter Key blockiert alle Admin-Zugriffe
    }
    return hash_equals($this->adminKey, $req->getHeaderLine('X-Admin-Key'));
}

Alle Zähler für einen Benutzer zurücksetzen (alle Endpunkte):

sql
DELETE FROM rate_events WHERE user_id = :uid

Für einen bestimmten Endpunkt zurücksetzen:

sql
DELETE FROM rate_events WHERE user_id = :uid AND endpoint = :ep

Pfadparameter-Extraktion (ohne Router::param())

Wenn Router::param() in der installierten Version nicht verfügbar ist, das Attribut direkt verwenden:

php
/** @var array<string, string> $params */
$params = $req->getAttribute(Router::PARAMETERS_ATTRIBUTE, []);
$raw    = $params['userId'] ?? '';

Validierung

  • endpoint: nicht-leerer String, max. 128 Zeichen
  • X-User-Id: ctype_digit() + positive Integer
  • Pfad userId: ctype_digit() + positive Integer (Fehler → 404)
  • Admin-Key: hash_equals()-Vergleich (Fehler → 403)

HTTP-Statuscodes

SituationStatus
Anfrage erlaubt200
Status abgerufen200
Zähler zurückgesetzt200
Kein X-User-Id400
Kein Body400
Leerer / fehlender Endpunkt422
Endpunkt zu lang422
Kein Admin-Key403
Falscher Admin-Key403
Ungültige userId im Pfad404
Ratenlimit überschritten429

Abgedeckte ATK-Angriffsmuster

ATKMusterAbwehr
ATK-01Fehlender X-User-Id400 mit Meldung
ATK-02Leere Endpunkt-Zeichenkette422 Validierung
ATK-03129-Zeichen-Endpunkt (DoS)Längenbegrenzung 422
ATK-04SQL-Injection im EndpunktParametrisierte Abfragen
ATK-05Nicht-Admin-Reset-Versuch403 fail-closed
ATK-06Falscher Admin-Key403 hash_equals()
ATK-07Negative userId im Pfad404
ATK-08userId null404
ATK-09Nicht-Ziffer-userId (abc)404 ctype_digit
ATK-10Status ohne Endpunkt-Parameter422
ATK-11Check ohne Body400
ATK-12Body ohne Endpunkt-Schlüssel422

Hinweise

  • Gleichzeitigkeit: Das gleitende Fenster hat ein kleines TOCTOU-Fenster. Für produktive Hochlastanwendungen atomare Zähler in Betracht ziehen (Redis INCR + EXPIRE) oder Datenbanksperren.
  • Uhrenabweichung: Alle Zeitstempel sollten UTC verwenden, um DST- oder Zeitzonenprobleme zu vermeiden.
  • Speicherwachstum: Alte Ereignisse akkumulieren sich. Einen periodischen Bereinigungsjob hinzufügen: DELETE FROM rate_events WHERE created_at < :cutoff.

Veröffentlicht unter der MIT-Lizenz.