diff --git a/next.config.ts b/next.config.ts index a8e3cd7..d7d902b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -11,6 +11,38 @@ function getSupabaseHostname(): string { return "*.supabase.co"; } +function buildCsp(): string { + const supabase = `https://${getSupabaseHostname()}`; + const directives: Record = { + "default-src": ["'self'"], + "script-src": ["'self'", "https://clerk.pawfeed.org"], + // Radix/shadcn primitives set inline style="" attributes for positioning — + // no nonce mechanism covers style attributes, so 'unsafe-inline' is required here. + "style-src": ["'self'", "'unsafe-inline'"], + "img-src": ["'self'", "data:", supabase, "https://image.mux.com", "https://img.clerk.com"], + "font-src": ["'self'", "data:"], + "media-src": ["'self'", "blob:", "https://stream.mux.com"], + "worker-src": ["'self'", "blob:"], + "connect-src": [ + "'self'", + "https://clerk.pawfeed.org", + "https://accounts.pawfeed.org", + supabase, + "https://stream.mux.com", + "https://image.mux.com", + "https://litix.io", + "https://storage.googleapis.com", + ], + "frame-src": ["'self'", "https://clerk.pawfeed.org"], + "frame-ancestors": ["'none'"], + "object-src": ["'none'"], + "base-uri": ["'self'"], + }; + return Object.entries(directives) + .map(([key, values]) => `${key} ${values.join(" ")}`) + .join("; "); +} + const nextConfig: NextConfig = { output: "standalone", allowedDevOrigins: ["192.168.1.92"], @@ -19,6 +51,21 @@ const nextConfig: NextConfig = { { protocol: "https", hostname: getSupabaseHostname() }, ], }, + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "Content-Security-Policy", value: buildCsp() }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }, + ], + }, + ]; + }, }; export default withNextIntl(nextConfig); diff --git a/src/__tests__/media-upload.test.ts b/src/__tests__/media-upload.test.ts index 0f99886..39dfe4b 100644 --- a/src/__tests__/media-upload.test.ts +++ b/src/__tests__/media-upload.test.ts @@ -80,11 +80,13 @@ function createTestCaller( userId: string | null; prisma: import("@prisma/client").PrismaClient; locale: string; + ip: string; }; const ctx: CallerCtx = { userId, prisma: mockPrisma as unknown as import("@prisma/client").PrismaClient, locale: "en", + ip: "test", }; return mediaRouter.createCaller(ctx); } diff --git a/src/__tests__/pet-profile.test.ts b/src/__tests__/pet-profile.test.ts index 1ae066f..ac55b25 100644 --- a/src/__tests__/pet-profile.test.ts +++ b/src/__tests__/pet-profile.test.ts @@ -54,11 +54,12 @@ function createTestCaller(userId: string, mockPrisma: ReturnType { + try { + const redisKey = `ratelimit:${key}`; + const count = await redis.incr(redisKey); + if (count === 1) { + await redis.expire(redisKey, windowSeconds); + } + return { success: count <= limit }; + } catch { + return { success: true }; + } +} diff --git a/src/trpc/init.ts b/src/trpc/init.ts index e00d826..45afa65 100644 --- a/src/trpc/init.ts +++ b/src/trpc/init.ts @@ -1,8 +1,17 @@ import { initTRPC, TRPCError } from "@trpc/server"; import { auth } from "@clerk/nextjs/server"; -import { cookies } from "next/headers"; +import { cookies, headers } from "next/headers"; import { prisma } from "@/lib/prisma"; import { LOCALES, defaultLocale } from "@/i18n/request"; +import { checkRateLimit } from "@/lib/rate-limit"; + +function resolveClientIp(headerList: Awaited>): string { + return ( + headerList.get("cf-connecting-ip") ?? + headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + "unknown" + ); +} /** * tRPC context — created per request in the API route handler. @@ -16,9 +25,11 @@ import { LOCALES, defaultLocale } from "@/i18n/request"; export const createTRPCContext = async () => { const { userId } = await auth(); const cookieStore = await cookies(); + const headerList = await headers(); const raw = cookieStore.get("NEXT_LOCALE")?.value ?? ""; const locale = (LOCALES as readonly string[]).includes(raw) ? raw : defaultLocale; - return { userId, prisma, locale }; + const ip = resolveClientIp(headerList); + return { userId, prisma, locale, ip }; }; type Context = Awaited>; @@ -43,3 +54,26 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { }, }); }); + +/** + * Rate-limit middleware factory — Redis-backed fixed window, keyed by + * caller identity (authenticated userId, or IP for public procedures). + * `scope` namespaces the Redis key per call site (e.g. "posts.create"). + */ +export function rateLimited(scope: string, limit: number, windowSeconds: number) { + return t.middleware(async ({ ctx, next }) => { + const identifier = ctx.userId ?? ctx.ip; + const { success } = await checkRateLimit({ + key: `${scope}:${identifier}`, + limit, + windowSeconds, + }); + if (!success) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Too many requests — please slow down.", + }); + } + return next(); + }); +} diff --git a/src/trpc/routers/comments.ts b/src/trpc/routers/comments.ts index d88fcb1..519fa14 100644 --- a/src/trpc/routers/comments.ts +++ b/src/trpc/routers/comments.ts @@ -1,13 +1,14 @@ import "server-only"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { router, protectedProcedure } from "@/trpc/init"; +import { router, protectedProcedure, rateLimited } from "@/trpc/init"; import { createMentionNotifications } from "@/lib/mention-helpers"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; import { notify } from "@/lib/notify"; export const commentsRouter = router({ create: protectedProcedure + .use(rateLimited("comments.create", 20, 300)) .input( z.object({ postId: z.string().min(1), diff --git a/src/trpc/routers/explore.ts b/src/trpc/routers/explore.ts index ccf294d..9114981 100644 --- a/src/trpc/routers/explore.ts +++ b/src/trpc/routers/explore.ts @@ -1,6 +1,6 @@ import "server-only"; import { z } from "zod"; -import { router, publicProcedure } from "@/trpc/init"; +import { router, publicProcedure, rateLimited } from "@/trpc/init"; import { pickLocalizedName } from "@/lib/localized-name"; const TRENDING_POSTS_WINDOW_MS = 48 * 60 * 60 * 1000; // 48 h @@ -8,6 +8,7 @@ const TRENDING_HASHTAGS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7 days export const exploreRouter = router({ listPets: publicProcedure + .use(rateLimited("explore.listPets", 60, 60)) .input( z.object({ speciesId: z.string().optional(), @@ -50,7 +51,9 @@ export const exploreRouter = router({ * getTrending — trending posts (48 h window, ranked by reaction count) * and trending hashtags (7 day window, ranked by post count). */ - getTrending: publicProcedure.query(async ({ ctx }) => { + getTrending: publicProcedure + .use(rateLimited("explore.getTrending", 30, 60)) + .query(async ({ ctx }) => { const now = Date.now(); const since48h = new Date(now - TRENDING_POSTS_WINDOW_MS); const since7d = new Date(now - TRENDING_HASHTAGS_WINDOW_MS); diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts index 5be56e3..5fe055b 100644 --- a/src/trpc/routers/posts.ts +++ b/src/trpc/routers/posts.ts @@ -1,7 +1,7 @@ import "server-only"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { router, protectedProcedure } from "@/trpc/init"; +import { router, protectedProcedure, rateLimited } from "@/trpc/init"; import { createSignedUploadUrl, ALLOWED_CONTENT_TYPES, @@ -90,6 +90,7 @@ export const postsRouter = router({ * Fan-out: fanOutPost adds this post to every follower's Redis sorted set. */ create: protectedProcedure + .use(rateLimited("posts.create", 10, 600)) .input( z.object({ petId: z.string().min(1),