Files
qrcode-generator/src/proxy.ts
T
Florian Wilde 367d9c540b Bremsen, Sperren und CSP: die offenen Punkte schliessen
S3: Aus der Kontosperre wird eine Wartezeit. Die alte Sperre wies auch das
richtige Passwort ab — wer die Adresse eines Administrators kannte, konnte
ihn alle 15 Minuten neu aussperren. Jetzt bleiben vier Fehlversuche
folgenlos, danach verdoppelt sich die Wartezeit bis zu einer Minute. Der
Besitzer kommt spaetestens nach einer Minute hinein, ein Fremder bekommt
einen Versuch pro Minute. Ausgewertet wird sie vor der Passwortpruefung,
damit ein ohnehin abgewiesener Versuch keine 128 MiB kostet.

S4: Drei Stufen statt einer. Die Bremse je Absender liest
X-Forwarded-For nur noch mit TRUST_PROXY=true — ohne Proxy waehlt den Wert
jeder Aufrufer selbst, und eine so umgehbare Bremse ist eine Behauptung.
Dazu die Wartezeit je Konto, die an der Datenbank haengt, und eine
Obergrenze fuer die ganze Installation: 60 Passwortpruefungen und 120
Rueckmeldungen je Minute. Die letzte Stufe ist die einzige, die sich nicht
ueber Kopfzeilen umgehen laesst, und sie schuetzt den Speicher.

CSP: Eine vollstaendige Policy mit Nonce je Anfrage, strict-dynamic,
default-src self, object-src none, base-uri und form-action self. Skripte
ohne den Nonce dieser Anfrage laufen nicht mehr. Zwei Lockerungen bleiben
und stehen begruendet in der Doku: style-src erlaubt unsafe-inline, weil
ein Nonce keine style-Attribute deckt, und media-src erlaubt https, weil
der Typ Video ausdruecklich Direktlinks annimmt.

Protokoll: Sicherheitsereignisse als JSON auf die Standardausgabe —
Fehlversuche, OIDC-Verknuepfungen samt abgelehnten, Vergabe von
Administratorrechten. Ohne Passwoerter, Token und Adressen.

Refs #20
2026-08-24 01:43:00 +02:00

150 lines
6.1 KiB
TypeScript

import { NextResponse, type NextRequest } from "next/server";
import { isSameOrigin } from "@/lib/http";
/**
* Läuft vor jeder Anfrage: setzt Schutzheader und weist schreibende Zugriffe
* von fremden Seiten ab.
*
* Heißt `proxy.ts`, weil Next.js die Datei `middleware.ts` in Version 16
* umbenannt hat — siehe node_modules/next/dist/docs/01-app/03-api-reference/
* 03-file-conventions/proxy.md.
*
* ## Warum eine Origin-Prüfung, obwohl der Cookie `SameSite=Lax` ist
*
* Das OWASP-Merkblatt zu CSRF sagt ausdrücklich, dass `SameSite` allein nicht
* genügt: Es wirkt auf der Ebene der registrierbaren Domain, nicht des
* Ursprungs, und bei `Lax` gehen Cookies bei einfachen Navigationen weiterhin
* mit. Der Upload-Endpunkt nimmt zudem `multipart/form-data` entgegen — das
* kann ein HTML-Formular von einer fremden Seite abschicken.
*
* Fehlt der `Origin`-Header ganz, wird durchgelassen: Browser setzen ihn bei
* fremden Zugriffen immer, Skripte und Kommandozeilenwerkzeuge oft gar nicht.
* Ein Abweisen träfe also nur die Falschen.
*/
const SCHREIBEND = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/**
* Woher eingebettete Videos kommen dürfen.
*
* Der Video-Typ bettet YouTube und Vimeo ein — beide brauchen einen eigenen
* Eintrag, sonst bliebe der Rahmen nach dem Klick leer. Mehr steht hier
* nicht: Was nicht eingebettet wird, gehört auch nicht in die Liste.
*/
const VIDEO_QUELLEN = [
"https://www.youtube-nocookie.com",
"https://www.youtube.com",
"https://player.vimeo.com",
].join(" ");
/**
* Die Content-Security-Policy dieser Anfrage.
*
* Skripte laufen nur mit dem Nonce dieser einen Anfrage oder weil ein Skript
* sie geladen hat, das selbst eines hatte (`strict-dynamic`). Ein Skript,
* das über eine Lücke in die Seite gerät, hat keines — und läuft nicht.
*
* **Warum `style-src` weiter `unsafe-inline` erlaubt:** Ein Nonce wirkt nur
* auf `<style>`-Blöcke, nicht auf `style="…"` an einzelnen Elementen. Die
* Oberfläche benutzt solche Attribute an rund einem Dutzend Stellen, etwa
* für die gewählte Akzentfarbe. Sie zu verbieten hieße, sie vorher alle
* aufzulösen; der Gewinn wäre gering, denn über ein Stilattribut lässt sich
* kein Code ausführen. Das ist eine bewusste Grenze, keine Nachlässigkeit.
*
* `upgrade-insecure-requests` nur bei HTTPS: Auf einer Installation, die
* bewusst über HTTP läuft, würde die Regel die eigenen Ressourcen auf HTTPS
* hochstufen und damit alles zerlegen.
*/
function contentSecurityPolicy(nonce: string): string {
const entwicklung = process.env.NODE_ENV === "development";
const ueberHttps = (process.env.BASE_URL ?? "").startsWith("https://");
return [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
// `unsafe-eval` braucht React nur in der Entwicklung, für die
// Fehleraufbereitung im Browser.
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
// `data:` für die Vorschau des QR-Codes, `blob:` für den Download.
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self'",
`frame-src ${VIDEO_QUELLEN}`,
// Videos duerfen als Direktlink irgendwo liegen — der Typ „Video" nimmt
// ausdruecklich auch eine Adresse auf eine MP4-Datei entgegen. `https:`
// statt einer Liste: Welche Adresse jemand eintraegt, weiss nur er.
"media-src 'self' https:",
...(ueberHttps ? ["upgrade-insecure-requests"] : []),
].join("; ");
}
/** Schutzheader, die unabhängig von der Anfrage gelten. */
const HEADER: Record<string, string> = {
// Kein Einbetten in fremde Seiten — sonst ließe sich die Verwaltung hinter
// einer harmlos aussehenden Seite verstecken und fernsteuern. Steht auch
// in der CSP; dieser Header erreicht ältere Browser.
"X-Frame-Options": "DENY",
// Der Browser soll den Inhaltstyp nicht raten.
"X-Content-Type-Options": "nosniff",
// Keine vollständigen Adressen an fremde Seiten weitergeben — die
// Kurz-URLs stünden sonst in fremden Protokollen.
"Referrer-Policy": "strict-origin-when-cross-origin",
// Nichts davon braucht die Anwendung.
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()",
};
export function proxy(request: NextRequest) {
if (SCHREIBEND.has(request.method) && !isSameOrigin(request.headers)) {
return NextResponse.json(
{
error:
"Diese Anfrage kam von einer fremden Seite und wurde abgewiesen.",
},
{ status: 403 },
);
}
// Ein frischer Nonce je Anfrage. Next.js liest ihn aus der CSP der
// **Anfrage** und hängt ihn an seine eigenen Skripte — deshalb muss die
// Kopfzeile an beiden Stellen stehen, an der Anfrage und an der Antwort.
const nonce = crypto.randomUUID().replaceAll("-", "");
const csp = contentSecurityPolicy(nonce);
const anfrage = new Headers(request.headers);
anfrage.set("x-nonce", nonce);
anfrage.set("Content-Security-Policy", csp);
const response = NextResponse.next({ request: { headers: anfrage } });
if (!response.headers.has("Content-Security-Policy")) {
response.headers.set("Content-Security-Policy", csp);
}
for (const [name, wert] of Object.entries(HEADER)) {
// Routen, die eigene Regeln setzen — etwa die Sandbox für hochgeladene
// Dateien —, sollen sie behalten.
if (!response.headers.has(name)) response.headers.set(name, wert);
}
return response;
}
export const config = {
/*
* Ausgenommen sind statische Dateien und `/api/uploads` — letzteres aus
* zwei Gründen:
*
* 1. Next.js puffert den Anfragekörper, damit ihn Proxy und Route beide
* lesen können; jenseits von 10 MB wird abgeschnitten. Eine hochgeladene
* Datei an der Größengrenze käme dann unvollständig an.
* 2. Die Auslieferung setzt eigene Kopfzeilen — eine Sandbox-Policy, die
* schärfer ist als die hier.
*
* Die Herkunftsprüfung entfällt dadurch nicht: Die Upload-Route ruft
* `assertSameOrigin` selbst auf.
*/
matcher: ["/((?!_next/static|_next/image|favicon.ico|api/uploads).*)"],
};