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
+16
View File
@@ -1,3 +1,4 @@
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { GitApiError } from "./types"; import { GitApiError } from "./types";
const DEFAULT_TIMEOUT = Number(process.env.GIT_API_TIMEOUT_MS ?? 15000); const DEFAULT_TIMEOUT = Number(process.env.GIT_API_TIMEOUT_MS ?? 15000);
@@ -44,6 +45,10 @@ export async function getJson<T>(
); );
try { try {
// Vor jedem ausgehenden Aufruf pruefen, wohin er geht. Die Basis-URL
// stammt aus einem Formular, also aus Benutzereingabe.
await assertAllowedUrl(url);
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
Accept: "application/json", Accept: "application/json",
@@ -52,8 +57,16 @@ export async function getJson<T>(
}, },
signal: controller.signal, signal: controller.signal,
cache: "no-store", cache: "no-store",
redirect: "follow",
}); });
// Ein Redirect koennte auf eine interne Adresse zeigen (etwa bei einem
// umbenannten GitHub-Repo, aber eben auch boesartig). Deshalb das
// tatsaechliche Ziel nachtraeglich noch einmal pruefen.
if (response.redirected && response.url && response.url !== url) {
await assertAllowedUrl(response.url);
}
if (response.status === 404 && options.allow404) { if (response.status === 404 && options.allow404) {
return null as T; return null as T;
} }
@@ -68,6 +81,9 @@ export async function getJson<T>(
return JSON.parse(text) as T; return JSON.parse(text) as T;
} catch (error) { } catch (error) {
if (error instanceof GitApiError) throw error; if (error instanceof GitApiError) throw error;
if (error instanceof BlockedHostError) {
throw new GitApiError(error.message, 403, url);
}
if (error instanceof Error && error.name === "AbortError") { if (error instanceof Error && error.name === "AbortError") {
throw new GitApiError( throw new GitApiError(
`Zeitueberschreitung nach ${options.timeoutMs ?? DEFAULT_TIMEOUT} ms`, `Zeitueberschreitung nach ${options.timeoutMs ?? DEFAULT_TIMEOUT} ms`,
+77
View File
@@ -0,0 +1,77 @@
/**
* Schlanker Ratenbegrenzer im Arbeitsspeicher.
*
* Nebula ist eine Einzelinstanz ohne geteilten Cache – ein Map genuegt.
* Der Zaehler ueberlebt keinen Neustart, was fuer den Zweck (Bremse gegen
* Passwortraten, Portscans ueber die Git-Endpunkte und das Verheizen fremder
* API-Kontingente) ausreicht.
*/
interface Bucket {
count: number;
resetAt: number;
}
const buckets = new Map<string, Bucket>();
/** Verhindert, dass die Map bei vielen unterschiedlichen Schluesseln waechst. */
function sweep(now: number) {
if (buckets.size < 5000) return;
for (const [key, bucket] of buckets) {
if (bucket.resetAt <= now) buckets.delete(key);
}
}
export interface RateLimitResult {
ok: boolean;
remaining: number;
/** Sekunden bis zum naechsten erlaubten Versuch. */
retryAfter: number;
}
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
const now = Date.now();
sweep(now);
const bucket = buckets.get(key);
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return { ok: true, remaining: limit - 1, retryAfter: 0 };
}
bucket.count += 1;
if (bucket.count > limit) {
return {
ok: false,
remaining: 0,
retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
};
}
return { ok: true, remaining: limit - bucket.count, retryAfter: 0 };
}
/** Nach erfolgreicher Anmeldung die Bremse fuer diesen Schluessel loesen. */
export function resetRateLimit(key: string) {
buckets.delete(key);
}
/** Vorkonfigurierte Kontingente je Anwendungsfall. */
export const LIMITS = {
/** Anmeldeversuche je IP – die eigentliche Bremse gegen Durchprobieren */
login: { limit: 10, windowMs: 5 * 60 * 1000 },
/**
* Anmeldeversuche je Benutzername. Bewusst grosszuegiger als die
* Kontosperre (8 Fehlversuche), damit diese zuerst greift: der Benutzer
* bekommt dann die verstaendliche Meldung "Konto gesperrt" statt eines
* nackten 429. Ausserdem liesse sich ein Konto sonst schon mit fuenf
* falschen Versuchen von aussen blockieren.
*/
loginUser: { limit: 20, windowMs: 15 * 60 * 1000 },
/** Ausgehende Git-Abfragen (Sync, Verbindungstest, Repo-Liste) */
outbound: { limit: 30, windowMs: 60 * 1000 },
/** Schreibende Zugriffe allgemein */
mutation: { limit: 240, windowMs: 60 * 1000 },
/** WebAuthn-Optionen anfordern */
webauthn: { limit: 30, windowMs: 5 * 60 * 1000 },
} as const;
+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";
}
+172
View File
@@ -0,0 +1,172 @@
import { isIP } from "node:net";
import { lookup } from "node:dns/promises";
/**
* Schutz vor Server Side Request Forgery.
*
* Die Basis-URL einer Git-Verbindung kommt aus einem Formular. Ohne Pruefung
* koennte darueber jede vom Server erreichbare Adresse abgefragt werden –
* Cloud-Metadaten (169.254.169.254), lokal gebundene Dienste oder ein
* Portscan im Heimnetz.
*
* Voreinstellung:
* verboten – Loopback, Link-Local (inkl. Metadaten), unspezifiziert,
* Multicast, CGNAT
* erlaubt – privates LAN (10/8, 172.16/12, 192.168/16, fc00::/7),
* weil eine selbst gehostete Gitea-Instanz genau dort steht
*
* Ueber Umgebungsvariablen anpassbar:
* GIT_ALLOW_LOOPBACK=true Gitea laeuft auf demselben Rechner
* GIT_BLOCK_PRIVATE=true kein Zugriff ins private Netz
* GIT_ALLOWED_HOSTS=a,b,c strengster Modus: nur diese Hosts
*/
export class BlockedHostError extends Error {
constructor(message: string) {
super(message);
this.name = "BlockedHostError";
}
}
function envFlag(name: string): boolean {
return /^(1|true|yes|on)$/i.test(process.env[name]?.trim() ?? "");
}
function allowlist(): string[] {
return (process.env.GIT_ALLOWED_HOSTS ?? "")
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
}
function ipv4Parts(address: string): number[] | null {
const parts = address.split(".").map(Number);
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) return null;
return parts;
}
type Verdict = { blocked: true; reason: string } | { blocked: false };
function classifyIpv4(address: string): Verdict {
const parts = ipv4Parts(address);
if (!parts) return { blocked: true, reason: "unlesbare IPv4-Adresse" };
const [a, b] = parts;
if (a === 0) return { blocked: true, reason: "unspezifizierte Adresse (0.0.0.0/8)" };
if (a === 127) {
return envFlag("GIT_ALLOW_LOOPBACK")
? { blocked: false }
: { blocked: true, reason: "Loopback (127.0.0.0/8) – mit GIT_ALLOW_LOOPBACK=true erlauben" };
}
if (a === 169 && b === 254) {
return { blocked: true, reason: "Link-Local/Metadaten-Endpunkt (169.254.0.0/16)" };
}
if (a >= 224) return { blocked: true, reason: "Multicast oder reservierter Bereich" };
if (a === 100 && b >= 64 && b <= 127) {
return { blocked: true, reason: "Carrier-Grade-NAT (100.64.0.0/10)" };
}
const isPrivate =
a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
if (isPrivate && envFlag("GIT_BLOCK_PRIVATE")) {
return { blocked: true, reason: "privates Netz ist per GIT_BLOCK_PRIVATE gesperrt" };
}
return { blocked: false };
}
function classifyIpv6(address: string): Verdict {
const value = address.toLowerCase().replace(/^\[|\]$/g, "");
// IPv4-gemappte Adressen wie ::ffff:127.0.0.1 nach IPv4 weiterreichen.
const mapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) return classifyIpv4(mapped[1]);
if (value === "::" ) return { blocked: true, reason: "unspezifizierte Adresse (::)" };
if (value === "::1") {
return envFlag("GIT_ALLOW_LOOPBACK")
? { blocked: false }
: { blocked: true, reason: "Loopback (::1) – mit GIT_ALLOW_LOOPBACK=true erlauben" };
}
if (value.startsWith("fe80")) return { blocked: true, reason: "Link-Local (fe80::/10)" };
if (value.startsWith("ff")) return { blocked: true, reason: "Multicast (ff00::/8)" };
const isUniqueLocal = value.startsWith("fc") || value.startsWith("fd");
if (isUniqueLocal && envFlag("GIT_BLOCK_PRIVATE")) {
return { blocked: true, reason: "privates Netz ist per GIT_BLOCK_PRIVATE gesperrt" };
}
return { blocked: false };
}
function classifyAddress(address: string): Verdict {
const version = isIP(address);
if (version === 4) return classifyIpv4(address);
if (version === 6) return classifyIpv6(address);
return { blocked: true, reason: `keine gueltige IP-Adresse: ${address}` };
}
/**
* Prueft eine URL, bevor der Server sie abruft. Wirft BlockedHostError,
* wenn das Ziel nicht erlaubt ist.
*/
export async function assertAllowedUrl(rawUrl: string): Promise<URL> {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new BlockedHostError(`Ungueltige URL: ${rawUrl}`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new BlockedHostError(`Nur http und https sind erlaubt, nicht ${url.protocol}`);
}
if (url.username || url.password) {
throw new BlockedHostError("Zugangsdaten in der URL sind nicht erlaubt.");
}
const host = url.hostname.toLowerCase();
const allowed = allowlist();
if (allowed.length > 0) {
if (!allowed.includes(host)) {
throw new BlockedHostError(
`Host "${host}" steht nicht in GIT_ALLOWED_HOSTS.`,
);
}
// Eine ausdrueckliche Freigabe schlaegt die Bereichspruefung.
return url;
}
// IP-Literal direkt pruefen, Namen erst aufloesen.
if (isIP(host)) {
const verdict = classifyAddress(host);
if (verdict.blocked) {
throw new BlockedHostError(`Zugriff auf ${host} blockiert: ${verdict.reason}`);
}
return url;
}
let addresses: Array<{ address: string }>;
try {
addresses = await lookup(host, { all: true });
} catch {
throw new BlockedHostError(`Hostname "${host}" konnte nicht aufgeloest werden.`);
}
if (addresses.length === 0) {
throw new BlockedHostError(`Hostname "${host}" liefert keine Adresse.`);
}
// Jede Antwort muss zulaessig sein, sonst greift DNS-Rebinding ueber einen
// zweiten A-Record.
for (const { address } of addresses) {
const verdict = classifyAddress(address);
if (verdict.blocked) {
throw new BlockedHostError(
`Zugriff auf ${host} (${address}) blockiert: ${verdict.reason}`,
);
}
}
return url;
}