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:
Keith Williams
2026-02-09 15:49:26 -03:00
committed by GitHub
co-authored by Devin AI
parent edf9cd70dd
commit 8b17df4621
3 changed files with 100 additions and 110 deletions
+7 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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;