Skip to content

Commit ab7971e

Browse files
authored
fix(rate-limit): add rate limiting and update otp logic (#697)
* fix: add ip to verify token 1. we need the ip to distinguish which user is verifying with certainty because the same token right now is tied to a user, which makes it possible to ddos an actual user 2. we get the ip usign cloudflare's header because our fe connects to be via cf and otherwise, via the socket's remote addr * chore: remove old impl * feat: add rate limiting module adapted from active-sg's implementation but removed some stuff that we don't need atm like limiting by user id. * chore: add rate limiter middleware 1. attached this to all procedures, this works via checking the meta key, so we can gradually add it in next time * chore: add rate limit to login and verify endpoints * chore: fix lint errors * chore: add option to skip rate limiting we need to skip rate limiting in test env so that this doesn't interfere with our tests. adding this as an option (which defaults to false) so that it will be disabled by default. * chore: db migration for rate limiter * chore: update generated files * chore: run lint fix * chore: remove unused code
1 parent 61d8037 commit ab7971e

File tree

12 files changed

+171
-37
lines changed

12 files changed

+171
-37
lines changed

apps/studio/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
"prisma": "5.10.2",
125125
"prisma-json-types-generator": "^3.0.4",
126126
"prisma-kysely": "^1.8.0",
127+
"rate-limiter-flexible": "^5.0.3",
127128
"react": "^18.3.1",
128129
"react-dom": "^18.3.1",
129130
"react-error-boundary": "^4.0.12",

apps/studio/prisma/generated/generatedTypes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ export interface Permission {
4848
createdAt: Generated<Timestamp>
4949
updatedAt: Generated<Timestamp>
5050
}
51+
export interface RateLimiterFlexible {
52+
key: string
53+
points: number
54+
expire: Timestamp | null
55+
}
5156
export interface Resource {
5257
id: GeneratedAlways<string>
5358
title: string
@@ -113,6 +118,7 @@ export interface DB {
113118
Footer: Footer
114119
Navbar: Navbar
115120
Permission: Permission
121+
RateLimiterFlexible: RateLimiterFlexible
116122
Resource: Resource
117123
Site: Site
118124
SiteMember: SiteMember

apps/studio/prisma/generated/selectableTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type Blob = Selectable<T.Blob>
77
export type Footer = Selectable<T.Footer>
88
export type Navbar = Selectable<T.Navbar>
99
export type Permission = Selectable<T.Permission>
10+
export type RateLimiterFlexible = Selectable<T.RateLimiterFlexible>
1011
export type Resource = Selectable<T.Resource>
1112
export type Site = Selectable<T.Site>
1213
export type SiteMember = Selectable<T.SiteMember>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- CreateTable
2+
CREATE TABLE "RateLimiterFlexible" (
3+
"key" TEXT NOT NULL,
4+
"points" INTEGER NOT NULL,
5+
"expire" TIMESTAMP(3),
6+
7+
CONSTRAINT "RateLimiterFlexible_pkey" PRIMARY KEY ("key")
8+
);

apps/studio/prisma/schema.prisma

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,9 @@ enum RoleType {
203203
Editor
204204
Publisher
205205
}
206+
207+
model RateLimiterFlexible {
208+
key String @id
209+
points Int
210+
expire DateTime?
211+
}

apps/studio/src/server/modules/auth/auth.service.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,6 @@ export const verifyToken = async (
3030
throw new VerificationError("Token is invalid or has expired")
3131
}
3232

33-
if (verificationToken.attempts > 5) {
34-
throw new VerificationError("Too many attempts")
35-
}
36-
3733
await prisma.verificationToken.delete({
3834
where: {
3935
identifier: email,

apps/studio/src/server/modules/auth/email/email.router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const emailSessionRouter = router({
1818
// Generate OTP.
1919
login: publicProcedure
2020
.input(emailSignInSchema)
21+
.meta({ rateLimitOptions: {} })
2122
.mutation(async ({ ctx, input: { email } }) => {
2223
if (env.NODE_ENV === "production") {
2324
// check if whitelisted email on Growthbook
@@ -77,6 +78,7 @@ export const emailSessionRouter = router({
7778
}),
7879
verifyOtp: publicProcedure
7980
.input(emailVerifyOtpSchema)
81+
.meta({ rateLimitOptions: {} })
8082
.mutation(async ({ ctx, input: { email, token } }) => {
8183
try {
8284
await verifyToken(ctx.prisma, {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { PrismaClient } from "@prisma/client"
2+
import { type NextApiRequest } from "next"
3+
import { TRPCError } from "@trpc/server"
4+
import {
5+
RateLimiterMemory,
6+
RateLimiterPrisma,
7+
RateLimiterRes,
8+
} from "rate-limiter-flexible"
9+
10+
import { type RateLimitMetaOptions } from "./types"
11+
import { getRateLimitFingerprint } from "./utils"
12+
13+
// Default 5 queries per second fallback
14+
export const rateLimiterMemory = new RateLimiterMemory({
15+
points: 5,
16+
duration: 1,
17+
})
18+
19+
export async function checkRateLimit({
20+
rateLimitOptions,
21+
req,
22+
prisma,
23+
}: {
24+
rateLimitOptions: RateLimitMetaOptions
25+
req: NextApiRequest
26+
prisma: PrismaClient
27+
}) {
28+
const max = rateLimitOptions.max ?? 5
29+
const windowMs = rateLimitOptions.windowMs ?? 1000
30+
31+
const store = new RateLimiterPrisma({
32+
storeClient: prisma,
33+
points: max,
34+
duration: windowMs / 1000, // in seconds
35+
insuranceLimiter: rateLimiterMemory,
36+
})
37+
38+
const fingerprint = getRateLimitFingerprint(req)
39+
40+
try {
41+
await store.consume(fingerprint)
42+
} catch (error) {
43+
if (error instanceof RateLimiterRes) {
44+
const tryAgainPeriodInSeconds = Math.round(error.msBeforeNext / 1000) || 1
45+
46+
throw new TRPCError({
47+
code: "TOO_MANY_REQUESTS",
48+
message: `Too many requests, please try again in ${tryAgainPeriodInSeconds}s`,
49+
})
50+
}
51+
}
52+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface RateLimitMetaOptions {
2+
max?: number
3+
windowMs?: number
4+
_internalUseRateLimiterInTestEnv?: boolean
5+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { type NextApiRequest } from "next"
2+
3+
export const getRateLimitFingerprint = (req: NextApiRequest) => {
4+
const requestedPath =
5+
req.url && req.headers.host
6+
? new URL(req.url, `http://${req.headers.host}`).pathname
7+
: ""
8+
9+
const forwarded =
10+
req.headers["cf-connecting-ip"] ??
11+
req.socket.remoteAddress ??
12+
req.headers["x-forwarded-for"]
13+
14+
if (!forwarded) {
15+
return "127.0.0.1"
16+
}
17+
18+
const ip =
19+
(typeof forwarded === "string" ? forwarded : forwarded[0])?.split(
20+
/, /,
21+
)[0] ?? "127.0.0.1"
22+
23+
return `${ip}|${requestedPath}`
24+
}

0 commit comments

Comments
 (0)