diff --git a/apps/aws-app/cdk/app.ts b/apps/aws-app/cdk/app.ts index c9b1905..5a572c4 100644 --- a/apps/aws-app/cdk/app.ts +++ b/apps/aws-app/cdk/app.ts @@ -1,4 +1,5 @@ import * as cdk from 'aws-cdk-lib'; +import './env.js'; import {MainStack} from './main-stack.js'; import {WafStack} from './waf-stack.js'; diff --git a/apps/aws-app/cdk/env.ts b/apps/aws-app/cdk/env.ts new file mode 100644 index 0000000..3b27bb6 --- /dev/null +++ b/apps/aws-app/cdk/env.ts @@ -0,0 +1,20 @@ +import {z} from 'zod'; + +declare global { + namespace NodeJS { + interface ProcessEnv extends z.infer {} + } +} + +const envVariables = z.object({ + AWS_ACCESS_KEY_ID: z.string(), + AWS_HANDLER_VERIFY_HEADER: z.string(), + AWS_REGION: z.string(), + AWS_SECRET_ACCESS_KEY: z.string(), + GOOGLE_SEARCH_API_KEY: z.string(), + GOOGLE_SEARCH_SEARCH_ENGINE_ID: z.string(), + UPSTASH_REDIS_REST_TOKEN: z.string(), + UPSTASH_REDIS_REST_URL: z.string(), +}); + +envVariables.parse(process.env); diff --git a/apps/aws-app/cdk/main-stack.ts b/apps/aws-app/cdk/main-stack.ts index 177fdeb..0cce03a 100644 --- a/apps/aws-app/cdk/main-stack.ts +++ b/apps/aws-app/cdk/main-stack.ts @@ -2,7 +2,6 @@ import path from 'path'; import * as cdk from 'aws-cdk-lib'; import type {Construct} from 'constructs'; -const verifyHeader = process.env.AWS_HANDLER_VERIFY_HEADER; const distDirname = path.join(import.meta.dirname, `../dist/`); export interface MainStackProps extends cdk.StackProps { @@ -26,9 +25,14 @@ export class MainStack extends cdk.Stack { runtime: cdk.aws_lambda.Runtime.NODEJS_20_X, bundling: {format: cdk.aws_lambda_nodejs.OutputFormat.ESM}, timeout: cdk.Duration.minutes(1), - environment: verifyHeader - ? {AWS_HANDLER_VERIFY_HEADER: verifyHeader} - : undefined, + environment: { + AWS_HANDLER_VERIFY_HEADER: process.env.AWS_HANDLER_VERIFY_HEADER, + GOOGLE_SEARCH_API_KEY: process.env.GOOGLE_SEARCH_API_KEY, + GOOGLE_SEARCH_SEARCH_ENGINE_ID: + process.env.GOOGLE_SEARCH_SEARCH_ENGINE_ID, + UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, + UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, + }, }, ); @@ -47,9 +51,9 @@ export class MainStack extends cdk.Stack { const distribution = new cdk.aws_cloudfront.Distribution(this, `cdn`, { defaultBehavior: { origin: new cdk.aws_cloudfront_origins.FunctionUrlOrigin(functionUrl, { - customHeaders: verifyHeader - ? {'X-Origin-Verify': verifyHeader} - : undefined, + customHeaders: { + 'X-Origin-Verify': process.env.AWS_HANDLER_VERIFY_HEADER, + }, }), allowedMethods: cdk.aws_cloudfront.AllowedMethods.ALLOW_ALL, cachePolicy: new cdk.aws_cloudfront.CachePolicy(this, `cache-policy`, { diff --git a/apps/aws-app/package.json b/apps/aws-app/package.json index 3fdb0f0..e29e93a 100644 --- a/apps/aws-app/package.json +++ b/apps/aws-app/package.json @@ -18,6 +18,8 @@ "dependencies": { "@mfng/core": "*", "@mfng/shared-app": "*", + "@upstash/ratelimit": "^1.0.1", + "@upstash/redis": "^1.28.4", "ai": "^3.0.11", "clsx": "^1.2.1", "openai": "^4.29.0", diff --git a/apps/aws-app/src/handler/index.tsx b/apps/aws-app/src/handler/index.tsx index 9008af0..1ace479 100644 --- a/apps/aws-app/src/handler/index.tsx +++ b/apps/aws-app/src/handler/index.tsx @@ -20,10 +20,12 @@ import { reactServerManifest, reactSsrManifest, } from './manifests.js'; +import {ratelimitMiddleware} from './ratelimit-middleware.js'; export const app = new Hono(); app.use(authMiddleware); +app.use(ratelimitMiddleware); app.use(loggerMiddleware); app.get(`/*`, async (context) => handleGet(context.req.raw)); app.post(`/*`, async (context) => handlePost(context.req.raw)); diff --git a/apps/aws-app/src/handler/ratelimit-middleware.ts b/apps/aws-app/src/handler/ratelimit-middleware.ts new file mode 100644 index 0000000..b912bf9 --- /dev/null +++ b/apps/aws-app/src/handler/ratelimit-middleware.ts @@ -0,0 +1,38 @@ +import {Ratelimit} from '@upstash/ratelimit'; +import {Redis} from '@upstash/redis'; +import type {MiddlewareHandler} from 'hono'; + +let ratelimit: Ratelimit | undefined; + +try { + ratelimit = new Ratelimit({ + redis: Redis.fromEnv(), + limiter: Ratelimit.slidingWindow(10, `10 m`), + analytics: true, + ephemeralCache: new Map(), + }); +} catch { + console.warn(`Unable to create Redis instance, no rate limits are applied.`); +} + +export const ratelimitMiddleware: MiddlewareHandler = async (context, next) => { + if (ratelimit && context.req.method === `POST`) { + const ip = context.req.header(`cloudfront-viewer-address`); + + if (ip) { + const {success, reset} = await ratelimit.limit(ip); + + if (!success) { + console.debug(`Rate limit reached for ${ip}`); + + return context.text(`Too Many Requests`, 429, { + 'Retry-After': ((reset - Date.now()) / 1000).toFixed(), + }); + } + } else { + console.warn(`cloudfront-viewer-address not provided`); + } + } + + return next(); +}; diff --git a/package-lock.json b/package-lock.json index d6a29e2..772b627 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,8 @@ "dependencies": { "@mfng/core": "*", "@mfng/shared-app": "*", + "@upstash/ratelimit": "^1.0.1", + "@upstash/redis": "^1.28.4", "ai": "^3.0.11", "clsx": "^1.2.1", "openai": "^4.29.0", @@ -4925,6 +4927,33 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@upstash/core-analytics": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.7.tgz", + "integrity": "sha512-lC2j5efqb1haX/fpTGaPUx1rue1WUkOZBVHDzCB7eMIVsRdFFp4xiHtyH/G9omiR1zj39fU5SCTWFiKJH3KOpw==", + "dependencies": { + "@upstash/redis": "^1.28.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@upstash/ratelimit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-1.0.1.tgz", + "integrity": "sha512-G9LZ7idhlkuYknbUngCB3qzd7QnkK1xDkFG5jRtEJZuOUS5UKJ0UTKbhalCtp39eX2wu2Ubv8W7HCeaJQOWM0A==", + "dependencies": { + "@upstash/core-analytics": "^0.0.7" + } + }, + "node_modules/@upstash/redis": { + "version": "1.28.4", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.28.4.tgz", + "integrity": "sha512-UalkSAny/dz1m8giEhD3Y5ru1o+CPHI32wFyS3MyzDzj2TRvEN+lTw+mPwi20ojk0H2gs8TBW3qsrvwuLLy+pA==", + "dependencies": { + "crypto-js": "^4.2.0" + } + }, "node_modules/@vanilla-extract/babel-plugin-debug-ids": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@vanilla-extract/babel-plugin-debug-ids/-/babel-plugin-debug-ids-1.0.2.tgz", @@ -7634,6 +7663,11 @@ "node": "*" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/css-declaration-sorter": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.3.1.tgz",