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