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