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";
|
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`,
|
||||||
|
|||||||
@@ -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