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:
1 parent
4414ef12ed
commit
ddf3c2c4e6
5 files changed
+58
-5
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in new issue
Block a user