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:
1 parent
ec9573f33a
commit
f43541a4cc
11 files changed
+162
-8
No files matched your search
@@ -11,6 +11,38 @@ function getSupabaseHostname(): string {
|
||||
return "*.supabase.co";
|
||||
}
|
||||
|
||||
function buildCsp(): string {
|
||||
const supabase = `https://${getSupabaseHostname()}`;
|
||||
const directives: Record<string, string[]> = {
|
||||
"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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -54,11 +54,12 @@ function createTestCaller(userId: string, mockPrisma: ReturnType<typeof createMo
|
||||
// petsRouter.createCaller accepts the same context shape as createTRPCContext returns.
|
||||
// Cast the mock to the expected PrismaClient type — mock has the same interface for
|
||||
// tested methods.
|
||||
type CallerCtx = { userId: string | null; prisma: import("@prisma/client").PrismaClient; locale: string };
|
||||
type CallerCtx = { 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 petsRouter.createCaller(ctx);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { generateInviteCode, INVITE_CODES_PER_USER } from "@/lib/invite-codes";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getClientIp } from "@/lib/get-client-ip";
|
||||
|
||||
function resolveBase(request: NextRequest): string {
|
||||
const proto = request.headers.get("x-forwarded-proto") ?? "https";
|
||||
@@ -17,6 +19,15 @@ export async function GET(request: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
const base = resolveBase(request);
|
||||
|
||||
const { success } = await checkRateLimit({
|
||||
key: `auth.consume-invite:${getClientIp(request)}`,
|
||||
limit: 20,
|
||||
windowSeconds: 60,
|
||||
});
|
||||
if (!success) {
|
||||
return NextResponse.redirect(new URL("/", base));
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.redirect(new URL("/log-in", base));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getClientIp } from "@/lib/get-client-ip";
|
||||
|
||||
function resolveBase(request: NextRequest): string {
|
||||
const proto = request.headers.get("x-forwarded-proto") ?? "https";
|
||||
@@ -11,9 +13,19 @@ function resolveBase(request: NextRequest): string {
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const code = request.nextUrl.searchParams.get("code")?.trim().toUpperCase();
|
||||
const base = resolveBase(request);
|
||||
|
||||
const { success } = await checkRateLimit({
|
||||
key: `auth.set-invite:${getClientIp(request)}`,
|
||||
limit: 20,
|
||||
windowSeconds: 60,
|
||||
});
|
||||
if (!success) {
|
||||
return NextResponse.redirect(new URL("/join?error=rate-limited", base));
|
||||
}
|
||||
|
||||
const code = request.nextUrl.searchParams.get("code")?.trim().toUpperCase();
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.redirect(new URL("/join?error=missing", base));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import "server-only";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function getClientIp(request: NextRequest): string {
|
||||
return (
|
||||
request.headers.get("cf-connecting-ip") ??
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import "server-only";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
interface RateLimitOptions {
|
||||
/** Unique key identifying the caller + the action being limited, e.g. "posts.create:user_123". */
|
||||
key: string;
|
||||
/** Max requests allowed within the window. */
|
||||
limit: number;
|
||||
windowSeconds: number;
|
||||
}
|
||||
|
||||
interface RateLimitResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed-window rate limiter backed by Redis INCR/EXPIRE.
|
||||
* Fails open (allows the request) if Redis is unreachable — a rate-limit
|
||||
* outage must not take the whole app down with it.
|
||||
*/
|
||||
export async function checkRateLimit({ key, limit, windowSeconds }: RateLimitOptions): Promise<RateLimitResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
+36
-2
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user