🔒️ Harden rate limiting (#1598)

This commit is contained in:
Luke Vella 2025-03-02 15:39:36 +00:00 committed by GitHub
parent da10baaa98
commit d71a2fb6b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 38 additions and 35 deletions

View file

@ -28,11 +28,16 @@ const handler = (req: NextRequest) => {
} }
: undefined; : undefined;
const ip =
process.env.NODE_ENV === "development" ? "127.0.0.1" : ipAddress(req);
const identifier =
session?.user?.id ?? req.headers.get("x-vercel-ja4-digest") ?? ip;
return { return {
user, user,
locale, locale,
ip: identifier,
process.env.NODE_ENV === "development" ? "127.0.0.1" : ipAddress(req),
} satisfies TRPCContext; } satisfies TRPCContext;
}, },
onError({ error }) { onError({ error }) {

View file

@ -11,5 +11,5 @@ type User = {
export type TRPCContext = { export type TRPCContext = {
user?: User; user?: User;
locale?: string; locale?: string;
ip?: string; identifier?: string;
}; };

View file

@ -29,7 +29,7 @@ export const auth = router({
return { isRegistered: count > 0 }; return { isRegistered: count > 0 };
}), }),
requestRegistration: publicProcedure requestRegistration: publicProcedure
.use(createRateLimitMiddleware(5, "1 m")) .use(createRateLimitMiddleware("request_registration", 5, "1 m"))
.input( .input(
z.object({ z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),

View file

@ -130,7 +130,7 @@ export const polls = router({
// START LEGACY ROUTES // START LEGACY ROUTES
create: possiblyPublicProcedure create: possiblyPublicProcedure
.use(createRateLimitMiddleware(20, "1 h")) .use(createRateLimitMiddleware("create_poll", 10, "1 h"))
.use(requireUserMiddleware) .use(requireUserMiddleware)
.input( .input(
z.object({ z.object({
@ -233,7 +233,6 @@ export const polls = router({
return { id: poll.id }; return { id: poll.id };
}), }),
update: possiblyPublicProcedure update: possiblyPublicProcedure
.use(createRateLimitMiddleware(60, "1 h"))
.input( .input(
z.object({ z.object({
urlId: z.string(), urlId: z.string(),
@ -306,7 +305,6 @@ export const polls = router({
}); });
}), }),
delete: possiblyPublicProcedure delete: possiblyPublicProcedure
.use(createRateLimitMiddleware(30, "1 h"))
.input( .input(
z.object({ z.object({
urlId: z.string(), urlId: z.string(),

View file

@ -72,7 +72,7 @@ export const comments = router({
}); });
}), }),
add: publicProcedure add: publicProcedure
.use(createRateLimitMiddleware(5, "1 m")) .use(createRateLimitMiddleware("add_comment", 5, "1 m"))
.use(requireUserMiddleware) .use(requireUserMiddleware)
.input( .input(
z.object({ z.object({

View file

@ -105,7 +105,6 @@ export const participants = router({
return participants; return participants;
}), }),
delete: publicProcedure delete: publicProcedure
.use(createRateLimitMiddleware(20, "1 m"))
.input( .input(
z.object({ z.object({
participantId: z.string(), participantId: z.string(),
@ -123,7 +122,7 @@ export const participants = router({
}); });
}), }),
add: publicProcedure add: publicProcedure
.use(createRateLimitMiddleware(20, "1 m")) .use(createRateLimitMiddleware("add_participant", 5, "1 m"))
.use(requireUserMiddleware) .use(requireUserMiddleware)
.input( .input(
z.object({ z.object({
@ -218,7 +217,6 @@ export const participants = router({
return participant; return participant;
}), }),
rename: publicProcedure rename: publicProcedure
.use(createRateLimitMiddleware(20, "1 m"))
.input(z.object({ participantId: z.string(), newName: z.string() })) .input(z.object({ participantId: z.string(), newName: z.string() }))
.mutation(async ({ input: { participantId, newName } }) => { .mutation(async ({ input: { participantId, newName } }) => {
await prisma.participant.update({ await prisma.participant.update({
@ -232,7 +230,6 @@ export const participants = router({
}); });
}), }),
update: publicProcedure update: publicProcedure
.use(createRateLimitMiddleware(20, "1 m"))
.input( .input(
z.object({ z.object({
pollId: z.string(), pollId: z.string(),

View file

@ -38,22 +38,20 @@ export const user = router({
}, },
}); });
}), }),
delete: privateProcedure delete: privateProcedure.mutation(async ({ ctx }) => {
.use(createRateLimitMiddleware(5, "1 h")) if (ctx.user.isGuest) {
.mutation(async ({ ctx }) => { throw new TRPCError({
if (ctx.user.isGuest) { code: "BAD_REQUEST",
throw new TRPCError({ message: "Guest users cannot be deleted",
code: "BAD_REQUEST",
message: "Guest users cannot be deleted",
});
}
await prisma.user.delete({
where: {
id: ctx.user.id,
},
}); });
}), }
await prisma.user.delete({
where: {
id: ctx.user.id,
},
});
}),
subscription: publicProcedure.query( subscription: publicProcedure.query(
async ({ ctx }): Promise<{ legacy?: boolean; active: boolean }> => { async ({ ctx }): Promise<{ legacy?: boolean; active: boolean }> => {
if (!ctx.user || ctx.user.isGuest) { if (!ctx.user || ctx.user.isGuest) {
@ -67,7 +65,6 @@ export const user = router({
}, },
), ),
changeName: privateProcedure changeName: privateProcedure
.use(createRateLimitMiddleware(20, "1 h"))
.input( .input(
z.object({ z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
@ -84,7 +81,6 @@ export const user = router({
}); });
}), }),
updatePreferences: privateProcedure updatePreferences: privateProcedure
.use(createRateLimitMiddleware(30, "1 h"))
.input( .input(
z.object({ z.object({
locale: z.string().optional(), locale: z.string().optional(),
@ -111,7 +107,7 @@ export const user = router({
return { success: true }; return { success: true };
}), }),
requestEmailChange: privateProcedure requestEmailChange: privateProcedure
.use(createRateLimitMiddleware(10, "1 h")) .use(createRateLimitMiddleware("request_email_change", 10, "1 h"))
.input(z.object({ email: z.string().email() })) .input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const currentUser = await prisma.user.findUnique({ const currentUser = await prisma.user.findUnique({
@ -163,7 +159,7 @@ export const user = router({
return { success: true as const }; return { success: true as const };
}), }),
getAvatarUploadUrl: privateProcedure getAvatarUploadUrl: privateProcedure
.use(createRateLimitMiddleware(20, "1 h")) .use(createRateLimitMiddleware("get_avatar_upload_url", 10, "1 h"))
.input( .input(
z.object({ z.object({
fileType: z.enum(["image/jpeg", "image/png"]), fileType: z.enum(["image/jpeg", "image/png"]),
@ -209,7 +205,6 @@ export const user = router({
}), }),
updateAvatar: privateProcedure updateAvatar: privateProcedure
.input(z.object({ imageKey: z.string().max(255) })) .input(z.object({ imageKey: z.string().max(255) }))
.use(createRateLimitMiddleware(10, "1 h"))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userId = ctx.user.id; const userId = ctx.user.id;
const oldImageKey = ctx.user.image; const oldImageKey = ctx.user.image;

View file

@ -90,6 +90,7 @@ export const proProcedure = privateProcedure.use(async ({ ctx, next }) => {
}); });
export const createRateLimitMiddleware = ( export const createRateLimitMiddleware = (
name: string,
requests: number, requests: number,
duration: "1 m" | "1 h", duration: "1 m" | "1 h",
) => { ) => {
@ -98,20 +99,27 @@ export const createRateLimitMiddleware = (
return next(); return next();
} }
if (!ctx.ip) { if (!ctx.identifier) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to get client IP", message: "Failed to get identifier",
}); });
} }
const ratelimit = new Ratelimit({ const ratelimit = new Ratelimit({
redis: kv, redis: kv,
limiter: Ratelimit.slidingWindow(requests, duration), limiter: Ratelimit.slidingWindow(requests, duration),
}); });
const res = await ratelimit.limit(ctx.ip); const res = await ratelimit.limit(`${name}:${ctx.identifier}`);
if (!res.success) { if (!res.success) {
console.warn("Rate limit exceeded", {
identifier: ctx.identifier,
endpoint: name,
limit: requests,
duration,
});
throw new TRPCError({ throw new TRPCError({
code: "TOO_MANY_REQUESTS", code: "TOO_MANY_REQUESTS",
message: "Too many requests", message: "Too many requests",