fix(security): close GDPR-export/health-card token guessing and video IDOR

- GDPR export token (admin.exportUserData) and health-card token
  (health.getOrCreateCard) now get an explicit crypto-random token
  instead of falling back to Prisma's cuid() default, which is
  timestamp/counter-derived and not meant for unguessable secrets.
- Add IP-based rate limiting to both public token endpoints
  (/api/gdpr-export/[token], /health-card/[token]) to close the
  brute-force window.
- videos.getByPostId now applies the same hiddenAt/shadowban
  visibility filter posts.getById already uses, closing an IDOR that
  let any authenticated user read Mux data for hidden/shadowbanned
  video posts.

Found via a security-reviewer pass over the tRPC/API layer.
This commit is contained in:
admin committed 2026-08-23 14:25:25 +02:00
1 parent 4414ef12ed
commit ddf3c2c4e6
5 files changed
+58 -5

No files matched your search

+14 -2
View File
@@ -1,7 +1,9 @@
import "server-only";
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { downloadExport } from "@/lib/data-export-storage";
import { checkRateLimit } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/get-client-ip";
/**
* Public, unauthenticated download endpoint for GDPR Art. 15 exports — same
@@ -11,11 +13,21 @@ import { downloadExport } from "@/lib/data-export-storage";
* clicks (Gitea #6 follow-up: the emailed link must read pawfeed.org).
*/
export async function GET(
_request: Request,
request: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const ip = getClientIp(request);
const { success } = await checkRateLimit({
key: `gdpr-export:${ip}`,
limit: 20,
windowSeconds: 60,
});
if (!success) {
return new NextResponse(null, { status: 429 });
}
const exportToken = await prisma.dataExportToken.findUnique({ where: { token } });
if (!exportToken) {
return NextResponse.json({ error: "Link ungültig." }, { status: 404 });
+34
View File
@@ -1,6 +1,8 @@
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import { PawPrint, AlertTriangle } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { checkRateLimit } from "@/lib/rate-limit";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
@@ -37,6 +39,38 @@ export default async function HealthCardPage({ params, searchParams }: PageProps
const { print } = await searchParams;
const autoPrint = print === "1";
const headerList = await headers();
const ip =
headerList.get("cf-connecting-ip") ??
headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
"unknown";
const { success } = await checkRateLimit({
key: `health-card:${ip}`,
limit: 20,
windowSeconds: 60,
});
if (!success) {
return (
<div className="min-h-screen bg-muted/30 flex items-center justify-center px-4">
<div className="bg-card border rounded-xl p-8 max-w-sm text-center space-y-4">
<div className="flex justify-center">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
</div>
<h1 className="text-lg font-semibold">Zu viele Anfragen</h1>
<p className="text-sm text-muted-foreground">
Bitte versuche es in einer Minute erneut.
</p>
<div className="flex items-center justify-center gap-2 pt-2">
<PawPrint className="h-4 w-4 text-orange-500" />
<span className="text-sm font-medium text-muted-foreground">PawFeed</span>
</div>
</div>
</div>
);
}
const card = await prisma.healthCard.findUnique({
where: { token },
include: {
+1
View File
@@ -1156,6 +1156,7 @@ export const adminRouter = router({
const exportToken = await ctx.prisma.dataExportToken.create({
data: {
ownerId: input.ownerId,
token: randomUUID().replace(/-/g, ""),
storageKey: key,
expiresAt: new Date(Date.now() + DATA_EXPORT_TOKEN_TTL_MS),
},
+1 -1
View File
@@ -197,7 +197,7 @@ export const healthRouter = router({
const card = await ctx.prisma.healthCard.upsert({
where: { petId: input.petId },
update: { expiresAt },
create: { petId: input.petId, expiresAt },
create: { petId: input.petId, expiresAt, token: crypto.randomUUID().replace(/-/g, "") },
});
return { token: card.token, expiresAt: card.expiresAt };
}),
+8 -2
View File
@@ -80,8 +80,14 @@ export const videosRouter = router({
getByPostId: protectedProcedure
.input(z.object({ postId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const videoPost = await ctx.prisma.videoPost.findUnique({
where: { postId: input.postId },
const videoPost = await ctx.prisma.videoPost.findFirst({
where: {
postId: input.postId,
post: {
hiddenAt: null,
pet: { OR: [{ ownerId: ctx.userId }, { owner: { shadowban: null } }] },
},
},
include: { post: { select: { petId: true, createdAt: true } } },
});
if (!videoPost) throw new TRPCError({ code: "NOT_FOUND" });