Zum Inhalt springen

API Defense in Depth: Rate Limiting, Validierung, Header

API-Sicherheitsebenen jenseits der Authentifizierung: Rate Limiting, Validierung, Output-Encoding, Header und Audit-Logging mit TypeScript-Middleware.

5 Min. Lesezeit
Diagramm einer mehrschichtigen Sicherheitsarchitektur, das Anfragen zeigt, die die Ebenen Rate Limiting, Authentifizierung, Validierung, Autorisierung und Audit-Logging durchlaufen

Die Authentifizierung verifiziert die Identität. Sie beantwortet die Frage „Wer bist du?“ Aber ein korrekt authentifizierter Benutzer kann deine API trotzdem missbrauchen — durch fehlerhafte Daten, das Überlasten von Endpunkten, das Ausnutzen der Geschäftslogik oder das Extrahieren von Daten über unbegrenzte Abfragen. Die Ebenen jenseits der Authentifizierung entscheiden darüber, ob deine API den Kontakt mit dem echten Traffic übersteht.

Defense in Depth bedeutet, dass jede Ebene davon ausgeht, dass die vorherige versagen könnte. Rate Limiting setzt nicht voraus, dass die Firewall den Angriff blockiert hat. Die Eingabevalidierung setzt nicht voraus, dass der Client wohlgeformte Daten gesendet hat. Output-Encoding setzt nicht voraus, dass die Datenbank saubere Werte enthielt.

Rate Limiting: Kapazität schützen

Rate Limiting verhindert, dass ein einzelner Client unverhältnismäßig viele Ressourcen verbraucht. Ohne es kann ein einziger aggressiver Client — böswillig oder fehlerhaft — den Dienst für alle anderen lahmlegen.

tstypescript
// ❌ No rate limiting — any client can overwhelm the API
app.post("/api/search", async (req, res) => {
  const results = await db.fullTextSearch(req.body.query);
  res.json(results);
  // An attacker sends 10,000 requests/second
  // Database overwhelmed, other users get timeouts
});
tstypescript
// ✅ Layered rate limiting middleware
import { Redis } from "ioredis";
 
const redis = new Redis(process.env.REDIS_URL);
 
interface RateLimitConfig {
  windowMs: number;
  maxRequests: number;
  keyPrefix: string;
}
 
async function checkRateLimit(
  key: string,
  config: RateLimitConfig
): Promise<{
  allowed: boolean;
  remaining: number;
  resetAt: number;
}> {
  const windowKey =
    `${config.keyPrefix}:${key}:` +
    `${Math.floor(Date.now() / config.windowMs)}`;
 
  const count = await redis.incr(windowKey);
 
  if (count === 1) {
    await redis.pexpire(windowKey, config.windowMs);
  }
 
  const remaining = Math.max(
    0,
    config.maxRequests - count
  );
  const resetAt =
    Math.ceil(Date.now() / config.windowMs) *
    config.windowMs;
 
  return {
    allowed: count <= config.maxRequests,
    remaining,
    resetAt,
  };
}
 
function rateLimiter(config: RateLimitConfig) {
  return async (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    // Use authenticated user ID, fall back to IP
    const key =
      req.user?.id ?? req.ip ?? "unknown";
 
    const result = await checkRateLimit(key, config);
 
    // Always set rate limit headers
    res.set({
      "X-RateLimit-Limit": String(config.maxRequests),
      "X-RateLimit-Remaining": String(result.remaining),
      "X-RateLimit-Reset": String(result.resetAt),
    });
 
    if (!result.allowed) {
      res.status(429).json({
        error: "Too many requests",
        retryAfter: Math.ceil(
          (result.resetAt - Date.now()) / 1000
        ),
      });
      return;
    }
 
    next();
  };
}
 
// Different limits for different endpoints
app.use(
  "/api/search",
  rateLimiter({
    windowMs: 60_000,
    maxRequests: 30,
    keyPrefix: "rl:search",
  })
);
 
app.use(
  "/api/auth/login",
  rateLimiter({
    windowMs: 900_000, // 15 minutes
    maxRequests: 5,     // Strict for auth
    keyPrefix: "rl:login",
  })
);

Eingabevalidierung: Fehlerhafte Daten früh ablehnen

Jeder Anfrageparameter ist eine nicht vertrauenswürdige Eingabe. Validiere Form, Typ und Einschränkungen vor der Verarbeitung.

tstypescript
import { z } from "zod";
 
// ❌ Trusting client input
app.post("/api/users", async (req, res) => {
  // req.body could be anything — no validation
  await db.query(
    "INSERT INTO users (name, email, role) VALUES ($1, $2, $3)",
    [req.body.name, req.body.email, req.body.role]
    // Attacker sets role: "admin" — privilege escalation
  );
});
tstypescript
// ✅ Strict schema validation with Zod
const createUserSchema = z.object({
  name: z
    .string()
    .min(1)
    .max(100)
    .regex(
      /^[\p{L}\p{N}\s\-'.]+$/u,
      "Invalid characters in name"
    ),
  email: z.string().email().max(254),
  // Role is NOT user-settable — determined by backend
});
 
const searchSchema = z.object({
  query: z
    .string()
    .min(1)
    .max(200)
    .transform((q) => q.trim()),
  page: z.coerce
    .number()
    .int()
    .min(1)
    .max(100)
    .default(1),
  limit: z.coerce
    .number()
    .int()
    .min(1)
    .max(50)
    .default(20),
  // Prevent unbounded queries
});
 
function validate<T>(schema: z.ZodSchema<T>) {
  return (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const result = schema.safeParse(req.body);
 
    if (!result.success) {
      res.status(400).json({
        error: "Validation failed",
        details: result.error.issues.map((issue) => ({
          field: issue.path.join("."),
          message: issue.message,
        })),
      });
      return;
    }
 
    req.body = result.data;
    next();
  };
}
 
app.post(
  "/api/users",
  validate(createUserSchema),
  async (req, res) => {
    // req.body is now typed and validated
    const { name, email } = req.body;
    // Role assigned by backend logic, never from input
    const role = "user";
    await db.createUser({ name, email, role });
    res.status(201).json({ name, email, role });
  }
);

Sicherheitsheader: Antworten absichern

Sicherheitsheader weisen Browser an, wie sie mit deinen Antworten umgehen sollen. Fehlende Header machen Clients anfällig für Clickjacking, XSS und Protocol-Downgrade-Angriffe.

tstypescript
function securityHeaders(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Prevent clickjacking
  res.set("X-Frame-Options", "DENY");
 
  // Block MIME-type sniffing
  res.set("X-Content-Type-Options", "nosniff");
 
  // Enable strict transport security
  res.set(
    "Strict-Transport-Security",
    "max-age=31536000; includeSubDomains; preload"
  );
 
  // Content Security Policy for APIs
  res.set(
    "Content-Security-Policy",
    "default-src 'none'; frame-ancestors 'none'"
  );
 
  // Control referrer information
  res.set("Referrer-Policy", "strict-origin");
 
  // Permissions policy
  res.set(
    "Permissions-Policy",
    "camera=(), microphone=(), geolocation=()"
  );
 
  next();
}
 
app.use(securityHeaders);

Output-Encoding: Antworten bereinigen

In deiner Datenbank gespeicherte Daten können schädliche Inhalte enthalten. Kodiere die Ausgabe, bevor du sie an Clients sendest.

tstypescript
// ❌ Raw database values in response
app.get("/api/comments/:postId", async (req, res) => {
  const comments = await db.getComments(req.params.postId);
  res.json(comments);
  // If a comment contains <script>alert('xss')</script>
  // and the client renders it as HTML — XSS
});
tstypescript
// ✅ Sanitize output
import DOMPurify from "isomorphic-dompurify";
 
function sanitizeOutput<T extends Record<string, unknown>>(
  obj: T,
  htmlFields: string[] = []
): T {
  const sanitized = { ...obj };
 
  for (const [key, value] of Object.entries(sanitized)) {
    if (typeof value === "string") {
      if (htmlFields.includes(key)) {
        // Allow safe HTML in designated fields
        (sanitized as Record<string, unknown>)[key] =
          DOMPurify.sanitize(value, {
            ALLOWED_TAGS: [
              "b",
              "i",
              "em",
              "strong",
              "a",
              "p",
              "br",
            ],
            ALLOWED_ATTR: ["href"],
          });
      } else {
        // Strip all HTML from non-HTML fields
        (sanitized as Record<string, unknown>)[key] =
          value
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;");
      }
    }
  }
 
  return sanitized;
}
 
app.get(
  "/api/comments/:postId",
  async (req, res) => {
    const comments = await db.getComments(
      req.params.postId
    );
    const safe = comments.map((c) =>
      sanitizeOutput(c, ["body"])
    );
    res.json(safe);
  }
);

Audit-Logging: Aufzeichnen, was passiert ist

Wenn ein Sicherheitsvorfall auftritt, verraten dir Audit-Logs, was passiert ist, wann und wer beteiligt war. Ohne sie ist die Reaktion auf Vorfälle reines Rätselraten.

tstypescript
interface AuditEvent {
  timestamp: string;
  userId: string | null;
  action: string;
  resource: string;
  resourceId: string;
  ip: string;
  userAgent: string;
  outcome: "success" | "failure" | "denied";
  details?: Record<string, unknown>;
}
 
class AuditLogger {
  async log(event: AuditEvent): Promise<void> {
    // Write to append-only audit store
    await db.query(
      `INSERT INTO audit_log
       (timestamp, user_id, action, resource,
        resource_id, ip, user_agent, outcome, details)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
      [
        event.timestamp,
        event.userId,
        event.action,
        event.resource,
        event.resourceId,
        event.ip,
        event.userAgent,
        event.outcome,
        JSON.stringify(event.details ?? {}),
      ]
    );
  }
}
 
const audit = new AuditLogger();
 
// Audit middleware for sensitive operations
function auditAction(action: string, resource: string) {
  return async (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const originalJson = res.json.bind(res);
    const startTime = new Date().toISOString();
 
    res.json = function (body: unknown) {
      const outcome =
        res.statusCode >= 400 ? "failure" : "success";
 
      audit.log({
        timestamp: startTime,
        userId: req.user?.id ?? null,
        action,
        resource,
        resourceId:
          req.params.id ?? "unknown",
        ip: req.ip ?? "unknown",
        userAgent:
          req.headers["user-agent"] ?? "unknown",
        outcome,
      });
 
      return originalJson(body);
    };
 
    next();
  };
}
 
app.delete(
  "/api/users/:id",
  auditAction("delete", "user"),
  async (req, res) => {
    await db.deleteUser(req.params.id);
    res.json({ deleted: true });
    // Audit log automatically captures: who deleted
    // which user, when, from what IP
  }
);

Wichtigste Erkenntnisse

Rate Limiting muss mehrschichtig und endpunktspezifisch sein: Authentifizierungsendpunkte brauchen strenge Limits (5 Versuche pro 15 Minuten), Suchendpunkte moderate Limits und Leseendpunkte können großzügiger sein — mit der authentifizierten Benutzer-ID als primärem Schlüssel und der IP als Fallback für nicht authentifizierte Anfragen. Die Eingabevalidierung mit Schema-Bibliotheken wie Zod sollte ungültige Anfragen an der Grenze ablehnen, bevor irgendwelche Geschäftslogik ausgeführt wird, und dabei Typbeschränkungen, Längenlimits und explizite Allowlists für aufgezählte Werte durchsetzen, ohne jemals vom Client gelieferte Rollen- oder Berechtigungsfelder zu vertrauen. Sicherheitsheader bilden eine passive Verteidigungsebene, deren Implementierung nichts kostet — Strict-Transport-Security, X-Content-Type-Options, Content-Security-Policy und X-Frame-Options verhindern ganze Angriffskategorien, unabhängig davon, ob die Anwendungslogik korrekt ist. Audit-Logging bei sensiblen Operationen schafft eine Untersuchungsspur, die die Reaktion auf Vorfälle von Rätselraten zu evidenzbasierter Analyse macht: Protokolliere, wer welche Aktion an welcher Ressource mit welchem Ergebnis ausgeführt hat, und speichere diese Aufzeichnungen in einem Append-only-Speicher getrennt von den Anwendungsdaten.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX