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

+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" });