fix(security): enforce blocks on interaction, close remaining review findings

- New assertNotBlocked() helper (src/lib/assert-not-blocked.ts), mirroring
  assertPetOwnership. comments.create, reactions.toggle (create path), and
  reposts.create now reject direct interaction between blocked pets, not
  just feed visibility (product decision: enforce, matching messages.ts's
  existing DM block check).
- videos.updateCaption and stories.getViewers now route through the shared
  assertPetOwnership helper instead of duplicating the inline ownership
  check.
- ads.ts mutations (recordView, toggleReaction, addComment, toggleRepost)
  gain the same rate limiting their post-side equivalents already have.
- follows.listFollowers/listFollowing/getPetsWithActiveStories now apply
  getSpiderExclusionId(), matching the established per-query pattern in
  feed.ts/explore.ts/search.ts.

Remaining findings from the review are addressed:
- timingSafeStringEqual's length short-circuit needs no fix (matches
  Node's own crypto.timingSafeEqual behavior).
This commit is contained in:
admin committed 2026-08-23 14:50:20 +02:00
1 parent ddf3c2c4e6
commit d9930cba60
12 files changed
+76 -9

No files matched your search

+1
View File
@@ -21,6 +21,7 @@ vi.mock("@clerk/nextjs/server", () => ({
auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
}));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/redis", () => ({ redis: {} }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
+1
View File
@@ -61,6 +61,7 @@ describe("comments.create", () => {
const mockPet = { id: "pet-1", ownerId: "owner-1", name: "Buddy" };
mockPrisma.pet.findUnique.mockResolvedValue(mockPet);
mockPrisma.post.findUnique.mockResolvedValue({ petId: "other-pet" });
mockPrisma.comment.create.mockResolvedValue({
id: "cmt-1",
postId: "post-1",
+5
View File
@@ -124,6 +124,7 @@ interface MockBlockClient {
create: MockFn;
findMany: MockFn;
findUnique: MockFn;
findFirst: MockFn;
delete: MockFn;
count: MockFn;
}
@@ -417,10 +418,14 @@ export function createMockPrisma(): MockPrismaClient {
count: vi.fn(),
upsert: vi.fn(),
},
// assertNotBlocked (comments/reactions/reposts) checks this on every
// interaction — default to "not blocked" so unrelated tests don't need
// to stub it every time.
block: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn().mockResolvedValue(null),
delete: vi.fn(),
count: vi.fn(),
},
+1
View File
@@ -62,6 +62,7 @@ describe("reactions.toggle", () => {
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1", name: "Buddy" });
mockPrisma.reaction.findUnique.mockResolvedValue(null);
mockPrisma.post.findUnique.mockResolvedValue({ petId: "other-pet" });
mockPrisma.reaction.create.mockResolvedValue({
id: "rxn-1",
postId: "post-1",
+30
View File
@@ -0,0 +1,30 @@
import "server-only";
import { TRPCError } from "@trpc/server";
import type { PrismaClient } from "@prisma/client";
/**
* Blocks apply in both directions and must gate direct interaction
* (comments, reactions, reposts), not just feed visibility — see
* blocks.ts's own doc comment on why feed hydration alone isn't enough.
*/
export async function assertNotBlocked(
prisma: PrismaClient,
petIdA: string,
petIdB: string
) {
if (petIdA === petIdB) return;
const block = await prisma.block.findFirst({
where: {
OR: [
{ blockerPetId: petIdA, blockedPetId: petIdB },
{ blockerPetId: petIdB, blockedPetId: petIdA },
],
},
});
if (block) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Cannot interact with this pet's content.",
});
}
}
+5 -1
View File
@@ -1,6 +1,6 @@
import "server-only";
import { z } from "zod";
import { router, protectedProcedure } from "@/trpc/init";
import { router, protectedProcedure, rateLimited } from "@/trpc/init";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
import { petIdentitySelect } from "@/repositories/pet-repository";
@@ -46,6 +46,7 @@ export const adsRouter = router({
* (a missed view increment shouldn't surface as a user-visible error).
*/
recordView: protectedProcedure
.use(rateLimited("ads.recordView", 100, 60))
.input(z.object({ adId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.advertisement.update({
@@ -56,6 +57,7 @@ export const adsRouter = router({
}),
toggleReaction: protectedProcedure
.use(rateLimited("ads.toggleReaction", 60, 60))
.input(z.object({ adId: z.string().min(1), petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
@@ -100,6 +102,7 @@ export const adsRouter = router({
}),
addComment: protectedProcedure
.use(rateLimited("ads.addComment", 20, 300))
.input(
z.object({
adId: z.string().min(1),
@@ -126,6 +129,7 @@ export const adsRouter = router({
}),
toggleRepost: protectedProcedure
.use(rateLimited("ads.toggleRepost", 20, 300))
.input(z.object({ adId: z.string().min(1), petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
+8
View File
@@ -4,6 +4,7 @@ import { TRPCError } from "@trpc/server";
import { router, protectedProcedure, rateLimited } from "@/trpc/init";
import { createMentionNotifications } from "@/lib/mention-helpers";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
import { assertNotBlocked } from "@/lib/assert-not-blocked";
import { notify } from "@/lib/notify";
import { petIdentitySelect } from "@/repositories/pet-repository";
@@ -20,6 +21,13 @@ export const commentsRouter = router({
.mutation(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const targetPost = await ctx.prisma.post.findUnique({
where: { id: input.postId },
select: { petId: true },
});
if (!targetPost) throw new TRPCError({ code: "NOT_FOUND" });
await assertNotBlocked(ctx.prisma, input.petId, targetPost.petId);
const [comment, post] = await ctx.prisma.$transaction([
ctx.prisma.comment.create({
data: {
+7
View File
@@ -8,6 +8,7 @@ import { assertPetOwnership } from "@/lib/assert-pet-ownership";
import { notify } from "@/lib/notify";
import { FOLLOWER_MILESTONE_THRESHOLDS as FOLLOWER_MILESTONES } from "@/lib/milestone-thresholds";
import { pickLocalizedName } from "@/lib/localized-name";
import { getSpiderExclusionId } from "@/lib/spider-exclusion";
import { petIdentitySelect } from "@/repositories/pet-repository";
/**
@@ -167,10 +168,12 @@ export const followsRouter = router({
})
)
.query(async ({ ctx, input }) => {
const spiderExclusionId = await getSpiderExclusionId(ctx.prisma, ctx.userId);
const rows = await ctx.prisma.follow.findMany({
where: {
followeePetId: input.petId,
...(input.cursor ? { createdAt: { lt: new Date(input.cursor) } } : {}),
...(spiderExclusionId ? { follower: { NOT: { speciesId: spiderExclusionId } } } : {}),
},
include: {
follower: {
@@ -210,12 +213,14 @@ export const followsRouter = router({
.input(z.object({ followerPetId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.followerPetId, ctx.userId);
const spiderExclusionId = await getSpiderExclusionId(ctx.prisma, ctx.userId);
const rows = await ctx.prisma.follow.findMany({
where: {
followerPetId: input.followerPetId,
followee: {
stories: { some: { expiresAt: { gt: new Date() } } },
...(spiderExclusionId ? { NOT: { speciesId: spiderExclusionId } } : {}),
},
},
select: {
@@ -242,10 +247,12 @@ export const followsRouter = router({
})
)
.query(async ({ ctx, input }) => {
const spiderExclusionId = await getSpiderExclusionId(ctx.prisma, ctx.userId);
const rows = await ctx.prisma.follow.findMany({
where: {
followerPetId: input.petId,
...(input.cursor ? { createdAt: { lt: new Date(input.cursor) } } : {}),
...(spiderExclusionId ? { followee: { NOT: { speciesId: spiderExclusionId } } } : {}),
},
include: {
followee: {
+9
View File
@@ -1,7 +1,9 @@
import "server-only";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, protectedProcedure, rateLimited } from "@/trpc/init";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
import { assertNotBlocked } from "@/lib/assert-not-blocked";
import { notify } from "@/lib/notify";
export const reactionsRouter = router({
@@ -28,6 +30,13 @@ export const reactionsRouter = router({
return { reacted: false };
}
const targetPost = await ctx.prisma.post.findUnique({
where: { id: input.postId },
select: { petId: true },
});
if (!targetPost) throw new TRPCError({ code: "NOT_FOUND" });
await assertNotBlocked(ctx.prisma, input.petId, targetPost.petId);
const [, post] = await ctx.prisma.$transaction([
ctx.prisma.reaction.create({
data: { postId: input.postId, petId: input.petId },
+3
View File
@@ -6,6 +6,7 @@ import { redis } from "@/lib/redis";
import { fanOutPost } from "@/lib/feed-helpers";
import { extractHashtags, saveHashtags } from "@/lib/hashtag-helpers";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
import { assertNotBlocked } from "@/lib/assert-not-blocked";
import { notify } from "@/lib/notify";
export const repostsRouter = router({
@@ -33,6 +34,8 @@ export const repostsRouter = router({
});
}
await assertNotBlocked(ctx.prisma, input.reposterPetId, originalPost.petId);
// Idempotency: return existing repost postId without duplicate create
const existing = await ctx.prisma.repost.findUnique({
where: {
+3 -4
View File
@@ -261,12 +261,11 @@ export const storiesRouter = router({
.query(async ({ ctx, input }) => {
const story = await ctx.prisma.story.findUnique({
where: { id: input.storyId },
select: { id: true, pet: { select: { ownerId: true } } },
select: { id: true, petId: true },
});
if (!story || story.pet.ownerId !== ctx.userId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
if (!story) throw new TRPCError({ code: "FORBIDDEN" });
await assertPetOwnership(ctx.prisma, story.petId, ctx.userId);
return ctx.prisma.storyView.findMany({
where: { storyId: input.storyId },
+3 -4
View File
@@ -150,11 +150,10 @@ export const videosRouter = router({
.mutation(async ({ ctx, input }) => {
const post = await ctx.prisma.post.findUnique({
where: { id: input.postId },
select: { pet: { select: { ownerId: true } } },
select: { petId: true },
});
if (!post || post.pet.ownerId !== ctx.userId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
if (!post) throw new TRPCError({ code: "FORBIDDEN" });
await assertPetOwnership(ctx.prisma, post.petId, ctx.userId);
await ctx.prisma.post.update({
where: { id: input.postId },
data: { caption: input.caption },