feat(security): add Redis-backed rate limiting and CSP/security headers

Public go-live hardening: Redis-backed rate limiting on the previously
invite-gated public surface (explore browsing, post/comment creation,
invite-cookie routes) plus CSP, HSTS, and standard security headers on
every response, now that the invite gate no longer filters traffic.
This commit is contained in:
admin committed 2026-08-10 07:57:56 +02:00
1 parent ec9573f33a
commit f43541a4cc
11 files changed
+162 -8

No files matched your search

+36 -2
View File
@@ -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<ReturnType<typeof headers>>): 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<ReturnType<typeof createTRPCContext>>;
@@ -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();
});
}
+2 -1
View File
@@ -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),
+5 -2
View File
@@ -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);
+2 -1
View File
@@ -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),