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:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user