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:
1 parent
ddf3c2c4e6
commit
d9930cba60
12 files changed
+76
-9
No files matched your search
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user