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("