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`,
|
||||
|
||||
Reference in New Issue
Block a user