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>
This commit is contained in:
2026-07-25 21:29:45 +02:00
co-authored by Claude Opus 5
parent 950bca1b81
commit dbcf30efc1
4 changed files with 342 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
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";
}