From d7176339b82ee4cc0d57ae142c5583de4c2d5bb3 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 27 Jul 2026 00:14:31 +0200 Subject: [PATCH] test: Bereinigung des Markdowns festhalten, Ratenbegrenzung nachziehen Elf Tests halten fest, was nicht durchkommen darf: script-Elemente, Ereignis-Attribute aus rohem HTML, javascript: als Link und als Bildquelle, data:text/html. Dazu die Aufloesung relativer Verweise und der Fall "Bild im Link", der kein a im a erzeugen darf. Gerendert wird serverseitig - fuer die Frage nach dem erzeugten HTML braucht es kein DOM. Damit .tsx ueberhaupt laeuft, muss Vitest das JSX selbst uebersetzen; die tsconfig setzt jsx auf "preserve", weil sonst Next zustaendig ist. Vite nimmt inzwischen oxc statt esbuild, die esbuild-Option wuerde stillschweigend ignoriert. Umsortieren im Brett und das Anlegen von Vorlagen hatten als einzige schreibende Endpunkte keine Ratenbegrenzung. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/api/projects/reorder/route.ts | 10 ++- src/app/api/templates/from-project/route.ts | 10 ++- src/app/api/templates/route.ts | 10 ++- src/components/Markdown.test.tsx | 90 +++++++++++++++++++++ vitest.config.ts | 10 ++- 5 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 src/components/Markdown.test.tsx diff --git a/src/app/api/projects/reorder/route.ts b/src/app/api/projects/reorder/route.ts index 3a688ec..e965222 100644 --- a/src/app/api/projects/reorder/route.ts +++ b/src/app/api/projects/reorder/route.ts @@ -3,7 +3,8 @@ import type { Prisma, ProjectStatus } from "@prisma/client"; import { describeStatus, recordActivities } from "@/lib/activity"; import { requireUserApi } from "@/lib/auth/guard"; import { prisma } from "@/lib/db"; -import { readJsonBody } from "@/lib/security/request"; +import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; +import { clientIp, readJsonBody, tooManyRequests } from "@/lib/security/request"; import { formatZodError, projectReorderSchema } from "@/lib/validation"; export const dynamic = "force-dynamic"; @@ -20,6 +21,13 @@ export async function PATCH(request: Request) { const auth = await requireUserApi(); if (!auth.ok) return auth.response; + const limited = rateLimit( + `mutate:${clientIp(request)}`, + LIMITS.mutation.limit, + LIMITS.mutation.windowMs, + ); + if (!limited.ok) return tooManyRequests(limited.retryAfter); + const body = await readJsonBody(request); if (!body.ok) return body.response; diff --git a/src/app/api/templates/from-project/route.ts b/src/app/api/templates/from-project/route.ts index ee7fb29..049be4c 100644 --- a/src/app/api/templates/from-project/route.ts +++ b/src/app/api/templates/from-project/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { requireUserApi } from "@/lib/auth/guard"; import { prisma } from "@/lib/db"; -import { jsonError, readJsonBody } from "@/lib/security/request"; +import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; +import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; import { formatZodError, templateFromProjectSchema } from "@/lib/validation"; export const dynamic = "force-dynamic"; @@ -21,6 +22,13 @@ export async function POST(request: Request) { const auth = await requireUserApi(); if (!auth.ok) return auth.response; + const limited = rateLimit( + `mutate:${clientIp(request)}`, + LIMITS.mutation.limit, + LIMITS.mutation.windowMs, + ); + if (!limited.ok) return tooManyRequests(limited.retryAfter); + const body = await readJsonBody(request); if (!body.ok) return body.response; diff --git a/src/app/api/templates/route.ts b/src/app/api/templates/route.ts index d1f8863..c474883 100644 --- a/src/app/api/templates/route.ts +++ b/src/app/api/templates/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { requireUserApi } from "@/lib/auth/guard"; import { prisma } from "@/lib/db"; -import { jsonError, readJsonBody } from "@/lib/security/request"; +import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; +import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; import { formatZodError, templateCreateSchema } from "@/lib/validation"; export const dynamic = "force-dynamic"; @@ -25,6 +26,13 @@ export async function POST(request: Request) { const auth = await requireUserApi(); if (!auth.ok) return auth.response; + const limited = rateLimit( + `mutate:${clientIp(request)}`, + LIMITS.mutation.limit, + LIMITS.mutation.windowMs, + ); + if (!limited.ok) return tooManyRequests(limited.retryAfter); + const body = await readJsonBody(request); if (!body.ok) return body.response; diff --git a/src/components/Markdown.test.tsx b/src/components/Markdown.test.tsx new file mode 100644 index 0000000..3b294f0 --- /dev/null +++ b/src/components/Markdown.test.tsx @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Markdown } from "./Markdown"; + +/** + * Markdown kommt aus zwei Quellen, denen nicht zu trauen ist: eigenen + * Notizen (die zwar der Benutzer schreibt, aber ohne Bereinigung ein + * XSS-Loch waeren) und dem README eines fremden Repositories. + * + * Diese Tests halten die Bereinigung fest. Sie rendern serverseitig, was + * fuer die Frage genuegt – es geht um das erzeugte HTML, nicht um + * Verhalten im Browser. + */ + +const html = (text: string, baseUrl?: string) => + renderToStaticMarkup({text}); + +describe("Bereinigung", () => { + it("laesst kein script durch", () => { + const ausgabe = html("\n\nText danach."); + expect(ausgabe).not.toContain(" { + const ausgabe = html(''); + expect(ausgabe).not.toContain("onerror"); + expect(ausgabe).not.toContain("alert"); + }); + + it("laesst kein javascript: als Link durch", () => { + const ausgabe = html("[klick](javascript:alert(1))"); + expect(ausgabe).not.toContain("javascript:"); + }); + + it("laesst kein javascript: ueber ein Bild durch", () => { + // Das Bild wird zu einem Link umgebaut – die Quelle darf dabei nicht + // unbesehen zum href werden. + const ausgabe = html("![x](javascript:alert(1))"); + expect(ausgabe).not.toContain("javascript:"); + }); + + it("laesst kein data: mit HTML durch", () => { + const ausgabe = html("[klick](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)"); + expect(ausgabe).not.toContain("data:text/html"); + }); + + it("haengt an jeden Link noopener und noreferrer", () => { + const ausgabe = html("[Ziel](https://example.com)"); + expect(ausgabe).toContain('target="_blank"'); + expect(ausgabe).toContain("noopener"); + expect(ausgabe).toContain("noreferrer"); + }); + + it("laedt keine Bilder, sondern zeigt eine Marke", () => { + // Die Content-Security-Policy erlaubt img-src nur von der eigenen + // Instanz; ein fremdes README duerfte sonst Zaehlpixel nachladen. + const ausgabe = html("![Abzeichen](https://example.com/badge.svg)"); + expect(ausgabe).not.toContain(" { + const BASIS = "https://git.example.com/o/r/blob/main/"; + + it("loest relative Links gegen die Basis auf", () => { + expect(html("[Doku](docs/x.md)", BASIS)).toContain(`${BASIS}docs/x.md`); + }); + + it("laesst absolute Links und Anker in Ruhe", () => { + expect(html("[Extern](https://example.com/a)", BASIS)).toContain("https://example.com/a"); + expect(html("[Anker](#titel)", BASIS)).toContain('href="#titel"'); + }); + + it("laesst ohne Basis alles unveraendert", () => { + expect(html("[Doku](docs/x.md)")).toContain('href="docs/x.md"'); + }); + + it("erzeugt aus einem Bild im Link keinen Link im Link", () => { + // "[![Abzeichen](bild)](ziel)" ist in READMEs die Regel. Ein a im a ist + // ungueltiges HTML; der Browser zieht es auseinander, was zu einem + // Hydrationsfehler fuehrt – genau daran ist der erste Versuch gescheitert. + const ausgabe = html("[![Abzeichen](img/b.svg)](https://example.com/ziel)", BASIS); + expect(ausgabe).toContain("