Files
nebula/src/lib/security/request.ts
T
florian.wildeandClaude Opus 5 dbcf30efc1 feat(security): SSRF-Schutz, Ratenbegrenzung und harte Body-Pruefung
Die Basis-URL einer Git-Verbindung ist eine Benutzereingabe, die der Server
anschliessend selbst abruft. Ohne Pruefung liesse sich darueber jede vom
Server erreichbare Adresse ansprechen - Cloud-Metadaten unter
169.254.169.254, lokal gebundene Dienste oder ein Portscan im Heimnetz.

ssrf.ts prueft deshalb vor jedem ausgehenden Aufruf:
- Loopback, Link-Local samt Metadaten-Endpunkt, unspezifizierte Adressen,
  Multicast und CGNAT sind gesperrt
- das private LAN bleibt erlaubt, weil dort ueblicherweise die eigene
  Gitea-Instanz steht; abschaltbar ueber GIT_BLOCK_PRIVATE
- GIT_ALLOW_LOOPBACK fuer eine Instanz auf demselben Rechner,
  GIT_ALLOWED_HOSTS als strengster Modus
- Hostnamen werden aufgeloest und *jede* zurueckgegebene Adresse geprueft,
  sonst genuegte ein zweiter A-Record fuer DNS-Rebinding
- nach einem Redirect wird das tatsaechliche Ziel erneut geprueft

Dazu:
- rateLimit.ts, ein Zaehler im Arbeitsspeicher mit vorkonfigurierten
  Kontingenten fuer Anmeldung, ausgehende Aufrufe und WebAuthn
- request.ts liest JSON-Bodies nur mit Content-Type application/json und
  hoechstens 256 KB. Die Content-Type-Pflicht ist zugleich CSRF-Schutz in
  der Tiefe: ein Cross-Site-Formular kommt ohne Preflight nur mit
  text/plain, multipart oder urlencoded durch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:29:45 +02:00

78 lines
2.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from "next/server";
/** Groesste akzeptierte JSON-Nutzlast. */
const MAX_BODY_BYTES = 256 * 1024;
export function jsonError(
message: string,
status: number,
extra?: Record<string, unknown>,
): NextResponse {
return NextResponse.json({ error: message, ...extra }, { status });
}
export function tooManyRequests(retryAfter: number): NextResponse {
return NextResponse.json(
{ error: `Zu viele Anfragen. Bitte in ${retryAfter} Sekunden erneut versuchen.` },
{ status: 429, headers: { "Retry-After": String(retryAfter) } },
);
}
export type BodyResult<T> =
| { ok: true; data: T }
| { ok: false; response: NextResponse };
/**
* Liest einen JSON-Body und erzwingt dabei drei Dinge:
*
* 1. Content-Type application/json. Das ist zugleich CSRF-Schutz in der
* Tiefe: ein browserseitiger Cross-Site-Angriff kommt ohne Preflight nur
* mit text/plain, multipart/form-data oder urlencoded durch.
* 2. Eine Obergrenze fuer die Groesse, damit ein grosser Body nicht Speicher
* bindet.
* 3. Sauberes JSON – Parserfehler werden zu 400 statt zu einer Ausnahme.
*/
export async function readJsonBody<T = unknown>(
request: Request,
): Promise<BodyResult<T>> {
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
return {
ok: false,
response: jsonError("Content-Type muss application/json sein.", 415),
};
}
const declaredLength = Number(request.headers.get("content-length") ?? "0");
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
}
let raw: string;
try {
raw = await request.text();
} catch {
return { ok: false, response: jsonError("Anfrage konnte nicht gelesen werden.", 400) };
}
if (raw.length > MAX_BODY_BYTES) {
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
}
try {
return { ok: true, data: JSON.parse(raw) as T };
} catch {
return { ok: false, response: jsonError("Ungueltiger JSON-Body.", 400) };
}
}
/** Beste verfuegbare Naeherung der Client-Adresse – nur fuer Ratenbegrenzung. */
export function clientIp(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
return request.headers.get("x-real-ip")?.trim() || "unbekannt";
}