diff --git a/proxy.ts b/proxy.ts index e41236c..b8f4539 100644 --- a/proxy.ts +++ b/proxy.ts @@ -78,6 +78,41 @@ export async function proxy(...args: Parameters>) { return (await gate)(...args); } +// ═══ Was der Proxy gar nicht erst sieht ═══ +// +// Der Abgleich ist eine Verneinung: alles ausser dem hier Aufgezaehlten laeuft +// durch den Gate. Das ist die richtige Richtung — eine vergessene Route ist +// dann geschuetzt und nicht offen. +// +// Es fehlten allerdings die statischen Dateien aus public/. Eine Anfrage nach +// /brand/manner-logo.png lief damit in den Gate, fand keine Sitzung und +// bekam eine Umleitung nach /login. Der Browser bekommt auf ein also +// HTML statt eines Bildes und zeigt den Ersatztext. +// +// Aufgefallen ist es nur an einer Stelle, und zwar an der einzigen, an der es +// auffallen konnte: die Anmeldeseite ist die einzige Seite, die jemand ohne +// Sitzung zu sehen bekommt — und ausgerechnet dort steht das Logo. Ueberall +// sonst ist man angemeldet, der Gate laesst das Bild durch, und es erscheint. +// Daher auch das Sprunghafte: einmal angemeldet geladen, liegt es im +// Zwischenspeicher des Browsers und erscheint auf /login weiter, bis jemand +// hart neu laedt. +// +// Aufgenommen wird nur, was eine nicht angemeldete Person auf der +// Anmeldeseite braucht, und jeder Eintrag einzeln statt einer Regel ueber +// Dateiendungen: „alles mit einem Punkt darin" haette auch Routen +// durchgelassen, die keine Datei sind. +// +// _next/static, _next/image die Ausgabe des Bauwerkzeugs +// favicon.ico, icon.svg, die Symbole, die der Browser von sich aus holt +// apple-icon.png +// manifest.webmanifest dito, fuer die Installation als App +// brand/ Logo und App-Symbole aus public/brand +// +// In public/ liegt sonst nichts; personenbezogen ist dort nichts und darf +// dort auch nie etwas liegen — alles, was Daten fuehrt, geht durch withUser() +// und die Policies. export const config = { - matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], + matcher: [ + "/((?!_next/static|_next/image|favicon\\.ico|icon\\.svg|apple-icon\\.png|manifest\\.webmanifest|brand/).*)", + ], }; diff --git a/tests/unit/proxy.test.ts b/tests/unit/proxy.test.ts index 26b3b34..5c575bc 100644 --- a/tests/unit/proxy.test.ts +++ b/tests/unit/proxy.test.ts @@ -27,10 +27,12 @@ type Session = { user: { id: string } } | null; // Signatur eines Route Handlers (Anfrage plus Kontext), und der zweite // Parameter spielt hier keine Rolle. let proxy: (req: NextRequest) => unknown; +let matcher: string; beforeAll(async () => { const mod = await import("@/proxy"); proxy = mod.proxy as unknown as typeof proxy; + matcher = (mod.config as { matcher: string[] }).matcher[0]; }); async function visit(path: string, session: Session): Promise { @@ -93,3 +95,51 @@ describe("proxy", () => { expect(new URL(res.headers.get("location")!).pathname).toBe("/login"); }); }); + +// ═══ Der Abgleich selbst ═══ +// +// Die Tests darueber rufen proxy() unmittelbar auf und gehen damit am +// `matcher` vorbei — der entscheidet aber, ob der Gate ueberhaupt laeuft. Ein +// Fehler dort ist deshalb von den Tests oben nicht zu sehen, und genau einer +// ist passiert: die Dateien aus public/ standen nicht in der Ausnahmeliste. +// Eine Anfrage nach /brand/manner-logo.png lief in den Gate, fand keine +// Sitzung und wurde nach /login umgeleitet; auf der Anmeldeseite — der +// einzigen Seite ohne Sitzung — blieb das Logo deshalb leer. +describe("config.matcher", () => { + // Erst im Test gebaut, nicht beim Einsammeln: `matcher` steht erst nach dem + // beforeAll oben zur Verfuegung. + const laeuftDurchDenGate = (pfad: string) => new RegExp(`^${matcher}$`).test(pfad); + + it("laesst die statischen Dateien aus public/ am Gate vorbei", () => { + // Ohne diese Ausnahme ist das Logo auf der Anmeldeseite eine Umleitung. + expect(laeuftDurchDenGate("/brand/manner-logo.png")).toBe(false); + expect(laeuftDurchDenGate("/brand/icon-512.png")).toBe(false); + }); + + it("laesst die Symbole vorbei, die der Browser von sich aus holt", () => { + for (const pfad of ["/favicon.ico", "/icon.svg", "/apple-icon.png", "/manifest.webmanifest"]) { + expect(laeuftDurchDenGate(pfad), pfad).toBe(false); + } + }); + + it("laesst die Ausgabe des Bauwerkzeugs vorbei", () => { + expect(laeuftDurchDenGate("/_next/static/chunks/main.js")).toBe(false); + expect(laeuftDurchDenGate("/_next/image?url=x")).toBe(false); + }); + + // Die andere Haelfte, und die wichtigere: die Ausnahmen duerfen nichts + // aufmachen, was Daten fuehrt. + it("schickt alles Uebrige weiterhin durch den Gate", () => { + for (const pfad of ["/", "/employees", "/employees/123", "/audit", "/reports", "/import", + "/api/export/employees", "/api/import", "/passwort-aendern", "/login"]) { + expect(laeuftDurchDenGate(pfad), pfad).toBe(true); + } + }); + + it("laesst sich nicht mit einem aehnlich aussehenden Pfad umgehen", () => { + // Kein Prefix-Treffer auf halbem Weg: /brandneu ist keine Datei aus + // public/brand, und /icon.svg.php ist kein Symbol. + expect(laeuftDurchDenGate("/brandneu")).toBe(true); + expect(laeuftDurchDenGate("/employees/brand/1")).toBe(true); + }); +});