From 8b61223153ee72de5c928930cc7a36ec4ae5c5cf Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Thu, 30 May 2024 20:01:20 +1000 Subject: [PATCH] Add API rate limit (#23) --- apps/web/package.json | 1 + apps/web/src/env.js | 5 ++ apps/web/src/server/public-api/auth.ts | 27 ++++++++ packages/sdk/package.json | 5 +- packages/ui/src/spinner.tsx | 4 +- pnpm-lock.yaml | 89 ++++++++++++++------------ 6 files changed, 86 insertions(+), 45 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index c20afdc..5148f00 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@hono/swagger-ui": "^0.2.1", "@hono/zod-openapi": "^0.10.0", "@hookform/resolvers": "^3.3.4", + "@isaacs/ttlcache": "^1.4.1", "@prisma/client": "^5.11.0", "@t3-oss/env-nextjs": "^0.9.2", "@tanstack/react-query": "^5.25.0", diff --git a/apps/web/src/env.js b/apps/web/src/env.js index 7182690..3e19c91 100644 --- a/apps/web/src/env.js +++ b/apps/web/src/env.js @@ -40,6 +40,10 @@ export const env = createEnv({ GOOGLE_CLIENT_SECRET: z.string(), SES_QUEUE_LIMIT: z.string().transform((str) => parseInt(str, 10)), AWS_DEFAULT_REGION: z.string().default("us-east-1"), + API_RATE_LIMIT: z + .string() + .transform((str) => parseInt(str, 10)) + .default(2), }, /** @@ -72,6 +76,7 @@ export const env = createEnv({ GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, SES_QUEUE_LIMIT: process.env.SES_QUEUE_LIMIT, AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION, + API_RATE_LIMIT: process.env.API_RATE_LIMIT, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/apps/web/src/server/public-api/auth.ts b/apps/web/src/server/public-api/auth.ts index 17aa406..8b7b941 100644 --- a/apps/web/src/server/public-api/auth.ts +++ b/apps/web/src/server/public-api/auth.ts @@ -1,20 +1,30 @@ +import TTLCache from "@isaacs/ttlcache"; import { Context } from "hono"; import { hashToken } from "../auth"; import { db } from "../db"; import { UnsendApiError } from "./api-error"; +import { env } from "~/env"; + +const rateLimitCache = new TTLCache({ + ttl: 1000, // 1 second + max: env.API_RATE_LIMIT, +}); /** * Gets the team from the token. Also will check if the token is valid. */ export const getTeamFromToken = async (c: Context) => { const authHeader = c.req.header("Authorization"); + if (!authHeader) { throw new UnsendApiError({ code: "UNAUTHORIZED", message: "No Authorization header provided", }); } + const token = authHeader.split(" ")[1]; + if (!token) { throw new UnsendApiError({ code: "UNAUTHORIZED", @@ -22,6 +32,8 @@ export const getTeamFromToken = async (c: Context) => { }); } + checkRateLimit(token); + const hashedToken = hashToken(token); const team = await db.team.findFirst({ @@ -55,3 +67,18 @@ export const getTeamFromToken = async (c: Context) => { return team; }; + +const checkRateLimit = (token: string) => { + let rateLimit = rateLimitCache.get(token); + + rateLimit = rateLimit ?? 0; + + if (rateLimit >= 2) { + throw new UnsendApiError({ + code: "RATE_LIMITED", + message: `Rate limit exceeded, ${env.API_RATE_LIMIT} requests per second`, + }); + } + + rateLimitCache.set(token, rateLimit + 1); +}; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 7ca61a6..6b02f15 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "unsend", - "version": "0.0.5", + "version": "0.0.6", "description": "", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -8,7 +8,8 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "lint": "eslint . --max-warnings 0", - "build": "rm -rf dist && tsup index.ts --format esm,cjs --dts" + "build": "rm -rf dist && tsup index.ts --format esm,cjs --dts", + "publish-sdk": "pnpm run build && pnpm publish" }, "keywords": [], "author": "", diff --git a/packages/ui/src/spinner.tsx b/packages/ui/src/spinner.tsx index 2b4966a..7981eaa 100644 --- a/packages/ui/src/spinner.tsx +++ b/packages/ui/src/spinner.tsx @@ -3,7 +3,7 @@ import { cn } from ".."; export const Spinner: React.FC< React.SVGProps & { innerSvgClass?: string } -> = (props) => { +> = ({ innerSvgClass, ...props }) => { return ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2dbbfc..5185538 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,6 +112,9 @@ importers: '@hookform/resolvers': specifier: ^3.3.4 version: 3.3.4(react-hook-form@7.51.3) + '@isaacs/ttlcache': + specifier: ^1.4.1 + version: 1.4.1 '@prisma/client': specifier: ^5.11.0 version: 5.11.0(prisma@5.11.0) @@ -2091,6 +2094,11 @@ packages: wrap-ansi: 8.1.0 wrap-ansi-cjs: /wrap-ansi@7.0.0 + /@isaacs/ttlcache@1.4.1: + resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} + engines: {node: '>=12'} + dev: false + /@jridgewell/gen-mapping@0.3.5: resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -6141,7 +6149,7 @@ packages: eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0) eslint-plugin-react: 7.34.0(eslint@8.57.0) eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0) @@ -6199,7 +6207,7 @@ packages: enhanced-resolve: 5.16.0 eslint: 8.57.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.3 is-core-module: 2.13.1 @@ -6333,41 +6341,6 @@ packages: ignore: 5.3.1 dev: true - /eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): - resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - dependencies: - '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.4.2) - array-includes: 3.1.7 - array.prototype.findlastindex: 1.2.4 - array.prototype.flat: 1.3.2 - array.prototype.flatmap: 1.3.2 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 8.57.0 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) - hasown: 2.0.2 - is-core-module: 2.13.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.7 - object.groupby: 1.0.2 - object.values: 1.1.7 - semver: 6.3.1 - tsconfig-paths: 3.15.0 - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - dev: true - /eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0): resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} engines: {node: '>=4'} @@ -6403,6 +6376,40 @@ packages: - supports-color dev: true + /eslint-plugin-import@2.29.1(eslint@8.57.0): + resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + dependencies: + array-includes: 3.1.7 + array.prototype.findlastindex: 1.2.4 + array.prototype.flat: 1.3.2 + array.prototype.flatmap: 1.3.2 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.57.0 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + hasown: 2.0.2 + is-core-module: 2.13.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.7 + object.groupby: 1.0.2 + object.values: 1.1.7 + semver: 6.3.1 + tsconfig-paths: 3.15.0 + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + dev: true + /eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint@8.57.0)(typescript@5.4.2): resolution: {integrity: sha512-QIT7FH7fNmd9n4se7FFKHbsLKGQiw885Ds6Y/sxKgCZ6natwCsXdgPOADnYVxN2QrRweF0FZWbJ6S7Rsn7llug==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8265,8 +8272,8 @@ packages: highlight.js: 10.7.3 dev: false - /lru-cache@10.2.0: - resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} + /lru-cache@10.2.2: + resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==} engines: {node: 14 || >=16.14} /lru-cache@5.1.1: @@ -9565,7 +9572,7 @@ packages: resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} engines: {node: '>=16 || 14 >=14.17'} dependencies: - lru-cache: 10.2.0 + lru-cache: 10.2.2 minipass: 7.0.4 dev: true @@ -9573,7 +9580,7 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} dependencies: - lru-cache: 10.2.0 + lru-cache: 10.2.2 minipass: 7.0.4 /path-to-regexp@0.1.7: