refactor: Stop using Unkey for IP-based rate limiting (#27674)
* refactor: migrate IP-based rate limiting from Unkey to Cloudflare
Remove Unkey rate limiting for IP-based endpoints that will now be
handled by Cloudflare Enterprise Advanced Rate Limiting:
- Regular booking creation (createBooking:{hashedIP})
- Recurring booking creation (createRecurringBooking:{hashedIP})
- Instant meeting creation (instant.event-{hashedIP})
- Booking cancellation for unauthenticated users (api:cancel-ip:{hashedIP})
- Forgot password (forgotPassword:{hashedIP})
- Reset password (api:reset-password:{hashedIP})
- Signup (api:signup:{hashedIP})
- API v1 requests ({userId} with auto-lock)
- Global proxy rate limiting (common namespace)
Rate limiting that remains in Unkey (user/entity-based):
- Login (hashedEmail)
- Booking cancellation for authenticated users (api:cancel-user:{userId})
- 2FA setup/enable/disable (api:totp-*:{userId})
- SMS sending (team/org/user based)
- Email verification (various patterns)
- Team member operations (userId based)
- Routing forms (formId:responseHash)
- AI phone calls (userId based)
Also includes Cloudflare configuration proposal document with
recommended rules using JA4 fingerprinting for enhanced protection.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: remove cloudflare proposal doc from PR
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: remove cloudflare comments and keep common rate limiting type
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: revert global middleware from PR #25080 and restore core rate limits
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: restore instantMeeting rate limiting
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix: restore email fallback in forgot-password rate limiting
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* Remove the static file check
* refactor: add POST_METHODS_ALLOWED_API_ROUTES to proxy matcher
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* revert: restore API v1 rate limiting to original state
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* revert: restore reset-password, cancel, book/event, book/recurring-event to original state
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: use POST_METHODS_ALLOWED_API_ROUTES spread in matcher instead of hardcoded routes
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* revert: restore signup route to original state
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test: update proxy matcher tests for POST_METHODS_ALLOWED_API_ROUTES spread
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* Rename var
* refactor: revert matcher to static strings, add sync-check test for POST_METHODS_ALLOWED_API_ROUTES
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* removed dead routing forms rewrite code
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin AI
parent
edf9cd70dd
commit
8b17df4621
@@ -1,14 +1,14 @@
|
||||
import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import { parseRequestData } from "app/api/parseRequestData";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
const body = await parseRequestData(req);
|
||||
const email = emailSchema.transform((val) => val.toLowerCase()).safeParse(body?.email);
|
||||
@@ -17,16 +17,12 @@ async function handler(req: NextRequest) {
|
||||
return NextResponse.json({ message: "email is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// fallback to email if ip is not present
|
||||
let ip = (req.headers.get("x-real-ip") as string) ?? email.data;
|
||||
|
||||
let ip = getIP(req) ?? email.data;
|
||||
const forwardedFor = req.headers.get("x-forwarded-for") as string;
|
||||
if (!ip && forwardedFor) {
|
||||
ip = forwardedFor?.split(",").at(0) ?? email.data;
|
||||
}
|
||||
|
||||
// 10 requests per minute
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `forgotPassword:${piiHasher.hash(ip)}`,
|
||||
|
||||
+43
-59
@@ -1,14 +1,12 @@
|
||||
// Import mocked functions
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { get as edgeConfigGet } from "@vercel/edge-config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type { Mock } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { checkPostMethod } from "./proxy";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
// We'll test the wrapped proxy as it would be used in production
|
||||
import proxy from "./proxy";
|
||||
import proxy, { checkPostMethod, POST_METHODS_ALLOWED_API_ROUTES } from "./proxy";
|
||||
import { config } from "./proxy";
|
||||
|
||||
// Mock dependencies at module level
|
||||
@@ -446,7 +444,6 @@ describe("Middleware Integration Tests", () => {
|
||||
});
|
||||
|
||||
describe("Multiple Features", () => {
|
||||
|
||||
it("should handle embed route with routing forms rewrite", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/apps/routing_forms/form/embed?ui.color-scheme=light`,
|
||||
@@ -484,66 +481,53 @@ describe("Middleware Integration Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Middleware Matcher - Comprehensive Coverage", () => {
|
||||
const matcher = config.matcher[0];
|
||||
const pattern = matcher.replace(/^\/|\/$/g, "");
|
||||
const regex = new RegExp(`^/${pattern}`);
|
||||
describe("Middleware Matcher Configuration", () => {
|
||||
const matcher: string[] = config.matcher;
|
||||
|
||||
const cases = [
|
||||
// pages & apis
|
||||
{ path: "/", expected: true, reason: "Root page" },
|
||||
{ path: "/home", expected: true, reason: "Regular page" },
|
||||
{ path: "/team/abc", expected: true, reason: "Nested page" },
|
||||
{ path: "/api/auth/login", expected: true, reason: "API route" },
|
||||
{ path: "/api/bookings", expected: true, reason: "Top-level API" },
|
||||
{ path: "/dashboard/settings", expected: true, reason: "Deep nested page" },
|
||||
{ path: "/user/john/profile", expected: true, reason: "Multiple nested path" },
|
||||
{ path: "/apps/routing_forms/form", expected: true, reason: "App page under /apps" },
|
||||
{ path: "/embed?ui.color-scheme=dark", expected: true, reason: "Embed query param" },
|
||||
it("should include all core middleware routes", () => {
|
||||
expect(matcher).toContain("/auth/login");
|
||||
expect(matcher).toContain("/login");
|
||||
expect(matcher).toContain("/apps/installed");
|
||||
expect(matcher).toContain("/auth/logout");
|
||||
expect(matcher).toContain("/:path*/embed");
|
||||
});
|
||||
|
||||
// should be ignored (internal / static / public)
|
||||
{ path: "/_next/static/chunks/app.js", expected: false, reason: "Internal static asset" },
|
||||
{ path: "/_next/image?url=%2Flogo.png&w=256&q=75", expected: false, reason: "Internal image handler" },
|
||||
{ path: "/_next/data/build-id/page.json", expected: false, reason: "Next.js data route" },
|
||||
{ path: "/favicon.ico", expected: false, reason: "Favicon asset" },
|
||||
{ path: "/robots.txt", expected: false, reason: "Robots file" },
|
||||
{ path: "/sitemap.xml", expected: false, reason: "Sitemap file" },
|
||||
{ path: "/public/images/logo.png", expected: false, reason: "Public folder asset" },
|
||||
{ path: "/public/fonts/inter.woff2", expected: false, reason: "Public folder font" },
|
||||
{ path: "/static/js/main.js", expected: false, reason: "Static folder JavaScript" },
|
||||
{ path: "/static/css/app.css", expected: false, reason: "Static folder stylesheet" },
|
||||
it("should have no duplicate entries", () => {
|
||||
const uniqueEntries = new Set(matcher);
|
||||
expect(uniqueEntries.size).toBe(matcher.length);
|
||||
});
|
||||
|
||||
// edge cases
|
||||
{ path: "/manifest.json", expected: true, reason: "Manifest is a public page, not ignored" },
|
||||
{ path: "/_nextsomething", expected: true, reason: "Looks like _next but not reserved" },
|
||||
{ path: "/nextconfig", expected: true, reason: "Normal route with 'next' in name" },
|
||||
{ path: "/_NEXT/image", expected: true, reason: "Case-sensitive test (should match)" },
|
||||
{ path: "/favicon-abc.ico", expected: true, reason: "Favicon variant should still match" },
|
||||
{ path: "/robots-custom.txt", expected: true, reason: "Custom robots file should match" },
|
||||
{ path: "/sitemap-other.xml", expected: true, reason: "Custom sitemap file should match" },
|
||||
{ path: "/api_", expected: true, reason: "Partial match with api underscore" },
|
||||
{ path: "//double-slash", expected: true, reason: "Double slash URL" },
|
||||
{ path: "/_next", expected: false, reason: "Bare _next path" },
|
||||
];
|
||||
it("should cover every POST_METHODS_ALLOWED_API_ROUTES route via exact match or wildcard", () => {
|
||||
const wildcardPrefixes = matcher
|
||||
.filter((entry) => entry.endsWith(":path*"))
|
||||
.map((entry) => entry.replace(":path*", ""));
|
||||
|
||||
it("should match only the intended routes", () => {
|
||||
for (const { path, expected, reason } of cases) {
|
||||
const result = regex.test(path);
|
||||
expect(result, `${path} → ${reason}`).toBe(expected);
|
||||
for (const route of POST_METHODS_ALLOWED_API_ROUTES) {
|
||||
const matcherEntry = route.endsWith("/") ? `${route}:path*` : route;
|
||||
const coveredByWildcard = wildcardPrefixes.some((prefix) => route.startsWith(prefix));
|
||||
const coveredByExact = matcher.includes(matcherEntry);
|
||||
expect(
|
||||
coveredByWildcard || coveredByExact,
|
||||
`POST route "${route}" is not covered by any matcher entry`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should not accidentally match internal Next.js routes", () => {
|
||||
const internalPaths = ["/_next/static", "/_next/image", "/_next/data"];
|
||||
for (const path of internalPaths) {
|
||||
expect(regex.test(path)).toBe(false);
|
||||
}
|
||||
});
|
||||
it("should not contain API matcher entries without corresponding POST_METHODS_ALLOWED_API_ROUTES routes", () => {
|
||||
const NON_API_ROUTES = ["/auth/login", "/login", "/apps/installed", "/auth/logout", "/:path*/embed"];
|
||||
const apiMatcherEntries = matcher.filter((entry) => !NON_API_ROUTES.includes(entry));
|
||||
|
||||
it("should match all user-facing routes and APIs", () => {
|
||||
const publicPaths = ["/", "/api/user", "/settings", "/dashboard"];
|
||||
for (const path of publicPaths) {
|
||||
expect(regex.test(path)).toBe(true);
|
||||
for (const entry of apiMatcherEntries) {
|
||||
if (entry.endsWith(":path*")) {
|
||||
const prefix = entry.replace(":path*", "");
|
||||
const hasCoveredRoutes = POST_METHODS_ALLOWED_API_ROUTES.some((route) => route.startsWith(prefix));
|
||||
expect(hasCoveredRoutes, `Matcher wildcard "${entry}" doesn't cover any POST route`).toBe(true);
|
||||
} else {
|
||||
expect(
|
||||
POST_METHODS_ALLOWED_API_ROUTES,
|
||||
`Matcher has "${entry}" but it's missing from POST_METHODS_ALLOWED_API_ROUTES`
|
||||
).toContain(entry);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+50
-40
@@ -1,19 +1,13 @@
|
||||
import process from "node:process";
|
||||
import { getCspHeader, getCspNonce } from "@lib/csp";
|
||||
import { get } from "@vercel/edge-config";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
|
||||
import { getCspHeader, getCspNonce } from "@lib/csp";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
|
||||
try {
|
||||
return get<T>(key);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Don't crash if EDGE_CONFIG env var is missing
|
||||
}
|
||||
};
|
||||
@@ -137,31 +131,13 @@ const shouldEnforceCsp = (url: URL) => {
|
||||
};
|
||||
|
||||
const proxy = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||
const requestorIp = getIP(req);
|
||||
try {
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "common",
|
||||
identifier: piiHasher.hash(`${req.nextUrl.pathname}-${requestorIp}`),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
return new NextResponse(error.message, { status: error.statusCode });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// const postCheckResult = checkPostMethod(req);
|
||||
// if (postCheckResult) return postCheckResult;
|
||||
const postCheckResult = checkPostMethod(req);
|
||||
if (postCheckResult) return postCheckResult;
|
||||
|
||||
const url = req.nextUrl;
|
||||
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
|
||||
const requestHeaders = new Headers(reqWithEnrichedHeaders.headers);
|
||||
|
||||
const routingFormRewriteResponse = routingForms.handleRewrite(url);
|
||||
if (routingFormRewriteResponse) {
|
||||
return responseWithHeaders({ url, res: routingFormRewriteResponse, req: reqWithEnrichedHeaders });
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/auth/signup")) {
|
||||
const isSignupDisabled = await safeGet<boolean>("isSignupDisabled");
|
||||
// If is in maintenance mode, point the url pathname to the maintenance page
|
||||
@@ -196,16 +172,6 @@ const proxy = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||
return responseWithHeaders({ url, res, req: reqWithEnrichedHeaders });
|
||||
};
|
||||
|
||||
const routingForms = {
|
||||
handleRewrite: (url: URL) => {
|
||||
// Don't 404 old routing_forms links
|
||||
if (url.pathname.startsWith("/apps/routing_forms")) {
|
||||
url.pathname = url.pathname.replace(/^\/apps\/routing_forms($|\/)/, "/apps/routing-forms/");
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const embeds = {
|
||||
addResponseHeaders: ({ url, res }: { url: URL; res: NextResponse }) => {
|
||||
if (!url.pathname.endsWith("/embed")) {
|
||||
@@ -265,7 +231,51 @@ function enrichRequestWithHeaders({ req }: { req: NextRequest }) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next(?:/|$)|static(?:/|$)|public(?:/|$)|favicon\\.ico$|robots\\.txt$|sitemap\\.xml$).*)"],
|
||||
matcher: [
|
||||
"/auth/login",
|
||||
"/login",
|
||||
"/apps/installed",
|
||||
"/auth/logout",
|
||||
"/:path*/embed",
|
||||
"/api/auth/forgot-password",
|
||||
"/api/auth/oauth/me",
|
||||
"/api/auth/oauth/refreshToken",
|
||||
"/api/auth/oauth/token",
|
||||
"/api/auth/reset-password",
|
||||
"/api/auth/saml/callback",
|
||||
"/api/auth/saml/token",
|
||||
"/api/auth/setup",
|
||||
"/api/auth/signup",
|
||||
"/api/auth/two-factor/totp/disable",
|
||||
"/api/auth/two-factor/totp/enable",
|
||||
"/api/auth/two-factor/totp/setup",
|
||||
"/api/auth/session",
|
||||
"/api/availability/calendar",
|
||||
"/api/cancel",
|
||||
"/api/cron/:path*",
|
||||
"/api/get-inbound-dynamic-variables",
|
||||
"/api/integrations/:path*",
|
||||
"/api/recorded-daily-video",
|
||||
"/api/router",
|
||||
"/api/routing-forms/queued-response",
|
||||
"/api/scim/v2.0/:path*",
|
||||
"/api/support/conversation",
|
||||
"/api/sync/helpscout",
|
||||
"/api/twilio/webhook",
|
||||
"/api/username",
|
||||
"/api/verify-booking-token",
|
||||
"/api/video/guest-session",
|
||||
"/api/webhook/app-credential",
|
||||
"/api/webhooks/calendar-subscription/:path*",
|
||||
"/api/webhooks/retell-ai",
|
||||
"/api/workflows/sms/user-response",
|
||||
"/api/trpc/:path*",
|
||||
"/api/auth/callback/:path*",
|
||||
"/api/book/event",
|
||||
"/api/book/instant-event",
|
||||
"/api/book/recurring-event",
|
||||
"/availability",
|
||||
],
|
||||
};
|
||||
|
||||
export default proxy;
|
||||
|
||||
Reference in New Issue
Block a user