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