diff --git a/src/lib/git/http.ts b/src/lib/git/http.ts index 62ae587..8a3a71b 100644 --- a/src/lib/git/http.ts +++ b/src/lib/git/http.ts @@ -1,3 +1,4 @@ +import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf"; import { GitApiError } from "./types"; const DEFAULT_TIMEOUT = Number(process.env.GIT_API_TIMEOUT_MS ?? 15000); @@ -44,6 +45,10 @@ export async function getJson( ); 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, { headers: { Accept: "application/json", @@ -52,8 +57,16 @@ export async function getJson( }, signal: controller.signal, 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) { return null as T; } @@ -68,6 +81,9 @@ export async function getJson( return JSON.parse(text) as T; } catch (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") { throw new GitApiError( `Zeitueberschreitung nach ${options.timeoutMs ?? DEFAULT_TIMEOUT} ms`, diff --git a/src/lib/security/rateLimit.ts b/src/lib/security/rateLimit.ts new file mode 100644 index 0000000..07c9412 --- /dev/null +++ b/src/lib/security/rateLimit.ts @@ -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(); + +/** 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; diff --git a/src/lib/security/request.ts b/src/lib/security/request.ts new file mode 100644 index 0000000..f2e30e4 --- /dev/null +++ b/src/lib/security/request.ts @@ -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, +): 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 = + | { 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( + request: Request, +): Promise> { + 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"; +} diff --git a/src/lib/security/ssrf.ts b/src/lib/security/ssrf.ts new file mode 100644 index 0000000..51e6358 --- /dev/null +++ b/src/lib/security/ssrf.ts @@ -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 { + 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; +}