Files
webstudio/apps/builder/middleware.js
T
Ivan Starkov 167c594243 fix: Wrong path in rate limiting (#2570)
## Description

1. What is this PR about (link the issue and add a short description)

## Steps for reproduction

1. click button
2. expect xyz

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
5de6)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env.example`
and the `builder/env-check.js` if mandatory
2023-11-05 23:42:24 +02:00

470 lines
12 KiB
JavaScript

/**
* To test middleware locally:
* ```shell
* # At project root:
* pnpx vercel link
* pnpx vercel dev
* ```
*
* OR
*
* ```shell
* # Link vercel project
* pnpm middleware:link
* # Initialize .env.preview
* pnpm middleware:env
* # Start middleware server
* pnpm middleware:dev
* ```
*
* OR
*
* ```shell
* pnpx vercel link
* pnpx vercel env pull --environment=preview ./.env.preview
* DOTENV_CONFIG_PATH=.env.preview pnpm tsx watch -r dotenv/config ./middleware.js
* ```
*/
import { Ratelimit } from "@upstash/ratelimit";
import { ipAddress, next as passthroughRaw } from "@vercel/edge";
import { createClient } from "@vercel/kv";
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { env as envHono, getRuntimeKey } from "hono/adapter";
import { handle } from "hono/vercel";
import memoize from "memoize-one";
import { createCookieSessionStorage } from "@remix-run/cloudflare";
/**
* ----------------------------------------------------------------
* ---------------------======<<<Types>>>======--------------------
* ----------------------------------------------------------------
*/
/**
* @typedef {object} Environment
* @property {string} KV_RATE_LIMIT_REST_API_TOKEN
* @property {string} KV_RATE_LIMIT_REST_API_URL
* @property {string} TRPC_SERVER_API_TOKEN
* @property {string} AUTH_SECRET
*
* @typedef {object} Variables
* @property {boolean} skipRateLimit
*
* @typedef {object} ContextEnv
* @property {Variables} Variables
*/
/**
* ------------------------------------------------------------------
* ---------------------======<<<Utilities>>>======------------------
* ------------------------------------------------------------------
*/
/**
* @param {import('hono').Context<ContextEnv>} req
* @returns {Promise<Response>}
*/
let passthrough = async (req) => passthroughRaw();
/** @type {typeof envHono<Environment, import('hono').Context<ContextEnv>>} */
const env = (ctx) => envHono(ctx);
const getRateLimitMemoized = memoize(
/**
* @param {string} kvApiToken
* @param {string} kvUrl
*/
(kvApiToken, kvUrl) => {
const rateLimiterCache = new Map();
const kv = createClient({
token: kvApiToken,
url: kvUrl,
});
const authorized = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(20, "5 s"),
ephemeralCache: rateLimiterCache,
prefix: "authorized",
});
const unAuthorized = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(10, "5 s"),
ephemeralCache: rateLimiterCache,
prefix: "unauthorized",
});
// No ephemeralCache for the AI endpoint
const ai = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(20, "1 m"),
prefix: "ai",
// Usually during AI request we need few requests available, small timeout here could break things
timeout: 60000,
});
// For performance testing purposes
const perfTest = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(12000, "600 s"),
prefix: "test",
});
const perfTestEphemeral = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(12000, "600 s"),
ephemeralCache: rateLimiterCache,
prefix: "ephemeral",
});
return {
authorized,
unAuthorized,
ai,
perfTest,
perfTestEphemeral,
};
}
);
/** @param {import('hono').Context<ContextEnv>} ctx */
const getRateLimits = (ctx) => {
const { KV_RATE_LIMIT_REST_API_TOKEN, KV_RATE_LIMIT_REST_API_URL } = env(ctx);
if (KV_RATE_LIMIT_REST_API_TOKEN === undefined) {
throw new HTTPException(500, {
message: "KV_RATE_LIMIT_REST_API_TOKEN env variable is undefined",
});
}
if (KV_RATE_LIMIT_REST_API_URL === undefined) {
throw new HTTPException(500, {
message: "KV_RATE_LIMIT_REST_API_URL env variable is undefined",
});
}
return getRateLimitMemoized(
KV_RATE_LIMIT_REST_API_TOKEN,
KV_RATE_LIMIT_REST_API_URL
);
};
/**
* @todo: must be shared with 'apps/builder/app/services/cookie.server.ts' after ESM switch
*/
const getSessionStorageMemozied = memoize(
/**
* @param {string} authSecret
*/
(authSecret) =>
createCookieSessionStorage({
cookie: {
maxAge: 60 * 60 * 24 * 30,
name: "_session",
sameSite: "lax",
path: "/",
httpOnly: true,
secrets: authSecret ? [authSecret] : undefined,
secure: process.env.NODE_ENV === "production",
},
})
);
/** @param {import('hono').Context<ContextEnv>} ctx */
const getSessionStorage = (ctx) => {
const { AUTH_SECRET } = env(ctx);
if (AUTH_SECRET === undefined) {
throw new HTTPException(500, {
message: "AUTH_SECRET env variable is undefined",
});
}
return getSessionStorageMemozied(AUTH_SECRET);
};
/**
* @param {import('hono').Context<ContextEnv>} ctx
*/
const getUserId = async (ctx) => {
const sessionStorage = getSessionStorage(ctx);
const { data } = await sessionStorage.getSession(ctx.req.header("Cookie"));
return data.user?.id;
};
/**
* @param {import('hono').Context<ContextEnv>} ctx
* @param {keyof ReturnType<typeof getRateLimits>} ratelimitName
* @param {string} key
*/
const checkRateLimit = async (ctx, ratelimitName, key) => {
const rateLimits = getRateLimits(ctx);
const ratelimit = rateLimits[ratelimitName];
const { success, pending, limit, reset, remaining } =
await ratelimit.limit(key);
if (getRuntimeKey() !== "node") {
try {
ctx.executionCtx.waitUntil(pending);
} catch {
/**/
}
}
if (success === false) {
// eslint-disable-next-line no-console
console.warn(
`ratelimit triggered: [${ratelimitName}] limit=${limit}, reset=${reset}, remaining=${remaining} key=${key}`
);
throw new HTTPException(429, {
res: ctx.json(
{
error: {
message: `ratelimit triggered: [${ratelimitName}] limit=${limit}, reset=${reset}, remaining=${remaining}, key=${key}`,
code: 429,
meta: {
limit,
reset,
remaining,
ratelimitName,
},
},
},
429
),
});
}
};
/**
* ------------------------------------------------------------
* ---------------------======<<<App>>>======------------------
* ------------------------------------------------------------
*/
/** @type {Hono<ContextEnv>} */
const app = new Hono();
// Debug route, to show current values, MUST BE ABOVE middlewares
app.get("/rate-limit/debug", async (ctx) => {
const rateLimits = getRateLimits(ctx);
const aiKey = `${
(await getUserId(ctx)) ?? ipAddress(ctx.req) ?? "127.0.0.1"
}`;
const userId = await getUserId(ctx);
const ip = ipAddress(ctx.req) ?? "127.0.0.1";
return ctx.json({
keys: {
aiKey,
userId,
ip,
},
unAuthorized: await rateLimits.unAuthorized.limit(ip),
authorized: await rateLimits.authorized.limit(userId),
ai: await rateLimits.ai.limit(aiKey),
});
});
// Debug route, to test the rate-limiting performance, MUST BE ABOVE middlewares
app.get("/rate-limit/test", async (ctx) => {
const rateLimits = getRateLimits(ctx);
const aiKey = `${
(await getUserId(ctx)) ?? ipAddress(ctx.req) ?? "127.0.0.1"
}`;
return ctx.json({
perfTest: await rateLimits.perfTest.limit(aiKey),
});
});
// Debug route, to test the rate-limiting performance with ephemeral cache, MUST BE ABOVE middlewares
app.get("/rate-limit/ephemeral-test", async (ctx) => {
const rateLimits = getRateLimits(ctx);
const aiKey = `${
(await getUserId(ctx)) ?? ipAddress(ctx.req) ?? "127.0.0.1"
}`;
return ctx.json({
perfTest: await rateLimits.perfTestEphemeral.limit(aiKey),
});
});
/**
* Check whether the request is for a static file; if so, bypass the rate-limiting mechanism.
*/
app.use("*", async (ctx, next) => {
const skipRateLimit = ctx.get("skipRateLimit");
if (skipRateLimit) {
return next();
}
const url = new URL(ctx.req.url);
ctx.set(
"skipRateLimit",
url.pathname.startsWith("/asset/") ||
url.pathname.startsWith("/build/") ||
url.pathname.endsWith(".ico")
);
return next();
});
/**
* Check whether the request is a service call; if so, bypass the rate-limiting mechanism.
*/
app.use("*", async (ctx, next) => {
const skipRateLimit = ctx.get("skipRateLimit");
if (skipRateLimit) {
return next();
}
const { TRPC_SERVER_API_TOKEN } = env(ctx);
if (TRPC_SERVER_API_TOKEN === undefined) {
throw new HTTPException(500, {
message: "TRPC_SERVER_API_TOKEN env variable is undefined",
});
}
const isServiceCall =
TRPC_SERVER_API_TOKEN !== undefined &&
ctx.req.header("Authorization") === TRPC_SERVER_API_TOKEN;
ctx.set("skipRateLimit", isServiceCall);
return next();
});
/**
* Rate limiting AI endpoint, max priority
*/
app.use("/rest/ai/*", async (ctx, next) => {
const skipRateLimit = ctx.get("skipRateLimit");
if (skipRateLimit) {
return next();
}
const aiKey = `${
(await getUserId(ctx)) ?? ipAddress(ctx.req) ?? "127.0.0.1"
}`;
await checkRateLimit(ctx, "ai", aiKey);
ctx.set("skipRateLimit", true);
return next();
});
/**
* Rate limiting authorized/unauthorized users
*/
app.use("*", async (ctx, next) => {
const skipRateLimit = ctx.get("skipRateLimit");
if (skipRateLimit) {
return next();
}
const userId = await getUserId(ctx);
if (userId === undefined) {
await checkRateLimit(
ctx,
"unAuthorized",
ipAddress(ctx.req) ?? "127.0.0.1"
);
ctx.set("skipRateLimit", true);
return next();
}
await checkRateLimit(ctx, "authorized", userId);
ctx.set("skipRateLimit", true);
return next();
});
app.notFound(async (ctx) => {
return passthrough(ctx);
});
app.onError(async (err, ctx) => {
if (err instanceof HTTPException) {
return err.getResponse();
}
// eslint-disable-next-line no-console
console.error(err.stack ?? err);
return ctx.json({ cause: err.cause, message: err.message });
});
export default handle(app);
/**
* --------------------------------------------------------------------
* ---------------------======<<<Development>>>======------------------
* --------------------------------------------------------------------
*/
if (process.env.NODE_ENV !== "production") {
if (getRuntimeKey() === "node") {
// To avoid vercel dev errors on build
const modulePath = "" + "@hono/node-server";
/** @type {Promise<import("@hono/node-server")>} */
const importedModule = import(modulePath);
importedModule.then(({ serve }) => {
serve({ ...app, port: 3002 }, (info) => {
// eslint-disable-next-line no-console
console.info(`Listening on http://localhost:${info.port}`);
});
});
passthrough = async (ctx) => {
try {
const devUrl = new URL(ctx.req.url);
devUrl.port = "3000";
const raw = ctx.req.raw;
const req = new Request(devUrl, {
headers: raw.headers,
method: raw.method,
body: raw.body,
redirect: "manual",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
duplex: "half",
});
const res = await fetch(req);
const responseHeaders = new Headers(res.headers);
responseHeaders.delete("content-encoding");
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: responseHeaders,
});
} catch (error) {
throw new HTTPException(500, {
message:
error instanceof Error
? `Middlewared dev fetch error ${error.message}`
: "Middlewared dev fetch error",
});
}
};
}
}