Skip to content

Commit 8b17df4

Browse files
refactor: Stop using Unkey for IP-based rate limiting (calcom#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 calcom#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>
1 parent edf9cd7 commit 8b17df4

3 files changed

Lines changed: 103 additions & 113 deletions

File tree

apps/web/app/api/auth/forgot-password/route.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
2-
import { parseRequestData } from "app/api/parseRequestData";
3-
import type { NextRequest } from "next/server";
4-
import { NextResponse } from "next/server";
5-
61
import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest";
72
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
83
import { emailSchema } from "@calcom/lib/emailSchema";
9-
import prisma from "@calcom/prisma";
4+
import getIP from "@calcom/lib/getIP";
105
import { piiHasher } from "@calcom/lib/server/PiiHasher";
6+
import prisma from "@calcom/prisma";
7+
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
8+
import { parseRequestData } from "app/api/parseRequestData";
9+
import type { NextRequest } from "next/server";
10+
import { NextResponse } from "next/server";
1111

1212
async function handler(req: NextRequest) {
1313
const body = await parseRequestData(req);
@@ -17,16 +17,12 @@ async function handler(req: NextRequest) {
1717
return NextResponse.json({ message: "email is required" }, { status: 400 });
1818
}
1919

20-
// fallback to email if ip is not present
21-
let ip = (req.headers.get("x-real-ip") as string) ?? email.data;
22-
20+
let ip = getIP(req) ?? email.data;
2321
const forwardedFor = req.headers.get("x-forwarded-for") as string;
2422
if (!ip && forwardedFor) {
2523
ip = forwardedFor?.split(",").at(0) ?? email.data;
2624
}
2725

28-
// 10 requests per minute
29-
3026
await checkRateLimitAndThrowError({
3127
rateLimitingType: "core",
3228
identifier: `forgotPassword:${piiHasher.hash(ip)}`,

apps/web/proxy.test.ts

Lines changed: 46 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
// Import mocked functions
2+
3+
import { WEBAPP_URL } from "@calcom/lib/constants";
24
import { get as edgeConfigGet } from "@vercel/edge-config";
35
import { NextRequest, NextResponse } from "next/server";
46
import type { Mock } from "vitest";
5-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
6-
7-
import { WEBAPP_URL } from "@calcom/lib/constants";
8-
9-
import { checkPostMethod } from "./proxy";
7+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
108
// We'll test the wrapped proxy as it would be used in production
11-
import proxy from "./proxy";
9+
import proxy, { checkPostMethod, POST_METHODS_ALLOWED_API_ROUTES } from "./proxy";
1210
import { config } from "./proxy";
1311

1412
// Mock dependencies at module level
@@ -446,7 +444,6 @@ describe("Middleware Integration Tests", () => {
446444
});
447445

448446
describe("Multiple Features", () => {
449-
450447
it("should handle embed route with routing forms rewrite", async () => {
451448
const req = createTestRequest({
452449
url: `${WEBAPP_URL}/apps/routing_forms/form/embed?ui.color-scheme=light`,
@@ -484,66 +481,53 @@ describe("Middleware Integration Tests", () => {
484481
});
485482
});
486483

487-
describe("Middleware Matcher - Comprehensive Coverage", () => {
488-
const matcher = config.matcher[0];
489-
const pattern = matcher.replace(/^\/|\/$/g, "");
490-
const regex = new RegExp(`^/${pattern}`);
491-
492-
const cases = [
493-
// pages & apis
494-
{ path: "/", expected: true, reason: "Root page" },
495-
{ path: "/home", expected: true, reason: "Regular page" },
496-
{ path: "/team/abc", expected: true, reason: "Nested page" },
497-
{ path: "/api/auth/login", expected: true, reason: "API route" },
498-
{ path: "/api/bookings", expected: true, reason: "Top-level API" },
499-
{ path: "/dashboard/settings", expected: true, reason: "Deep nested page" },
500-
{ path: "/user/john/profile", expected: true, reason: "Multiple nested path" },
501-
{ path: "/apps/routing_forms/form", expected: true, reason: "App page under /apps" },
502-
{ path: "/embed?ui.color-scheme=dark", expected: true, reason: "Embed query param" },
503-
504-
// should be ignored (internal / static / public)
505-
{ path: "/_next/static/chunks/app.js", expected: false, reason: "Internal static asset" },
506-
{ path: "/_next/image?url=%2Flogo.png&w=256&q=75", expected: false, reason: "Internal image handler" },
507-
{ path: "/_next/data/build-id/page.json", expected: false, reason: "Next.js data route" },
508-
{ path: "/favicon.ico", expected: false, reason: "Favicon asset" },
509-
{ path: "/robots.txt", expected: false, reason: "Robots file" },
510-
{ path: "/sitemap.xml", expected: false, reason: "Sitemap file" },
511-
{ path: "/public/images/logo.png", expected: false, reason: "Public folder asset" },
512-
{ path: "/public/fonts/inter.woff2", expected: false, reason: "Public folder font" },
513-
{ path: "/static/js/main.js", expected: false, reason: "Static folder JavaScript" },
514-
{ path: "/static/css/app.css", expected: false, reason: "Static folder stylesheet" },
515-
516-
// edge cases
517-
{ path: "/manifest.json", expected: true, reason: "Manifest is a public page, not ignored" },
518-
{ path: "/_nextsomething", expected: true, reason: "Looks like _next but not reserved" },
519-
{ path: "/nextconfig", expected: true, reason: "Normal route with 'next' in name" },
520-
{ path: "/_NEXT/image", expected: true, reason: "Case-sensitive test (should match)" },
521-
{ path: "/favicon-abc.ico", expected: true, reason: "Favicon variant should still match" },
522-
{ path: "/robots-custom.txt", expected: true, reason: "Custom robots file should match" },
523-
{ path: "/sitemap-other.xml", expected: true, reason: "Custom sitemap file should match" },
524-
{ path: "/api_", expected: true, reason: "Partial match with api underscore" },
525-
{ path: "//double-slash", expected: true, reason: "Double slash URL" },
526-
{ path: "/_next", expected: false, reason: "Bare _next path" },
527-
];
528-
529-
it("should match only the intended routes", () => {
530-
for (const { path, expected, reason } of cases) {
531-
const result = regex.test(path);
532-
expect(result, `${path}${reason}`).toBe(expected);
533-
}
484+
describe("Middleware Matcher Configuration", () => {
485+
const matcher: string[] = config.matcher;
486+
487+
it("should include all core middleware routes", () => {
488+
expect(matcher).toContain("/auth/login");
489+
expect(matcher).toContain("/login");
490+
expect(matcher).toContain("/apps/installed");
491+
expect(matcher).toContain("/auth/logout");
492+
expect(matcher).toContain("/:path*/embed");
534493
});
535494

536-
it("should not accidentally match internal Next.js routes", () => {
537-
const internalPaths = ["/_next/static", "/_next/image", "/_next/data"];
538-
for (const path of internalPaths) {
539-
expect(regex.test(path)).toBe(false);
495+
it("should have no duplicate entries", () => {
496+
const uniqueEntries = new Set(matcher);
497+
expect(uniqueEntries.size).toBe(matcher.length);
498+
});
499+
500+
it("should cover every POST_METHODS_ALLOWED_API_ROUTES route via exact match or wildcard", () => {
501+
const wildcardPrefixes = matcher
502+
.filter((entry) => entry.endsWith(":path*"))
503+
.map((entry) => entry.replace(":path*", ""));
504+
505+
for (const route of POST_METHODS_ALLOWED_API_ROUTES) {
506+
const matcherEntry = route.endsWith("/") ? `${route}:path*` : route;
507+
const coveredByWildcard = wildcardPrefixes.some((prefix) => route.startsWith(prefix));
508+
const coveredByExact = matcher.includes(matcherEntry);
509+
expect(
510+
coveredByWildcard || coveredByExact,
511+
`POST route "${route}" is not covered by any matcher entry`
512+
).toBe(true);
540513
}
541514
});
542515

543-
it("should match all user-facing routes and APIs", () => {
544-
const publicPaths = ["/", "/api/user", "/settings", "/dashboard"];
545-
for (const path of publicPaths) {
546-
expect(regex.test(path)).toBe(true);
516+
it("should not contain API matcher entries without corresponding POST_METHODS_ALLOWED_API_ROUTES routes", () => {
517+
const NON_API_ROUTES = ["/auth/login", "/login", "/apps/installed", "/auth/logout", "/:path*/embed"];
518+
const apiMatcherEntries = matcher.filter((entry) => !NON_API_ROUTES.includes(entry));
519+
520+
for (const entry of apiMatcherEntries) {
521+
if (entry.endsWith(":path*")) {
522+
const prefix = entry.replace(":path*", "");
523+
const hasCoveredRoutes = POST_METHODS_ALLOWED_API_ROUTES.some((route) => route.startsWith(prefix));
524+
expect(hasCoveredRoutes, `Matcher wildcard "${entry}" doesn't cover any POST route`).toBe(true);
525+
} else {
526+
expect(
527+
POST_METHODS_ALLOWED_API_ROUTES,
528+
`Matcher has "${entry}" but it's missing from POST_METHODS_ALLOWED_API_ROUTES`
529+
).toContain(entry);
530+
}
547531
}
548532
});
549533
});

apps/web/proxy.ts

Lines changed: 50 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
1+
import process from "node:process";
2+
import { getCspHeader, getCspNonce } from "@lib/csp";
13
import { get } from "@vercel/edge-config";
24
import type { NextRequest } from "next/server";
35
import { NextResponse } from "next/server";
46

5-
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
6-
import getIP from "@calcom/lib/getIP";
7-
import { HttpError } from "@calcom/lib/http-error";
8-
import { piiHasher } from "@calcom/lib/server/PiiHasher";
9-
10-
import { getCspHeader, getCspNonce } from "@lib/csp";
11-
12-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
137
const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
148
try {
159
return get<T>(key);
16-
} catch {
10+
} catch (error) {
1711
// Don't crash if EDGE_CONFIG env var is missing
1812
}
1913
};
@@ -137,31 +131,13 @@ const shouldEnforceCsp = (url: URL) => {
137131
};
138132

139133
const proxy = async (req: NextRequest): Promise<NextResponse<unknown>> => {
140-
const requestorIp = getIP(req);
141-
try {
142-
await checkRateLimitAndThrowError({
143-
rateLimitingType: "common",
144-
identifier: piiHasher.hash(`${req.nextUrl.pathname}-${requestorIp}`),
145-
});
146-
} catch (error) {
147-
if (error instanceof HttpError) {
148-
return new NextResponse(error.message, { status: error.statusCode });
149-
}
150-
throw error;
151-
}
152-
153-
// const postCheckResult = checkPostMethod(req);
154-
// if (postCheckResult) return postCheckResult;
134+
const postCheckResult = checkPostMethod(req);
135+
if (postCheckResult) return postCheckResult;
155136

156137
const url = req.nextUrl;
157138
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
158139
const requestHeaders = new Headers(reqWithEnrichedHeaders.headers);
159140

160-
const routingFormRewriteResponse = routingForms.handleRewrite(url);
161-
if (routingFormRewriteResponse) {
162-
return responseWithHeaders({ url, res: routingFormRewriteResponse, req: reqWithEnrichedHeaders });
163-
}
164-
165141
if (url.pathname.startsWith("/api/auth/signup")) {
166142
const isSignupDisabled = await safeGet<boolean>("isSignupDisabled");
167143
// 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>> => {
196172
return responseWithHeaders({ url, res, req: reqWithEnrichedHeaders });
197173
};
198174

199-
const routingForms = {
200-
handleRewrite: (url: URL) => {
201-
// Don't 404 old routing_forms links
202-
if (url.pathname.startsWith("/apps/routing_forms")) {
203-
url.pathname = url.pathname.replace(/^\/apps\/routing_forms($|\/)/, "/apps/routing-forms/");
204-
return NextResponse.rewrite(url);
205-
}
206-
},
207-
};
208-
209175
const embeds = {
210176
addResponseHeaders: ({ url, res }: { url: URL; res: NextResponse }) => {
211177
if (!url.pathname.endsWith("/embed")) {
@@ -265,7 +231,51 @@ function enrichRequestWithHeaders({ req }: { req: NextRequest }) {
265231
}
266232

267233
export const config = {
268-
matcher: ["/((?!_next(?:/|$)|static(?:/|$)|public(?:/|$)|favicon\\.ico$|robots\\.txt$|sitemap\\.xml$).*)"],
234+
matcher: [
235+
"/auth/login",
236+
"/login",
237+
"/apps/installed",
238+
"/auth/logout",
239+
"/:path*/embed",
240+
"/api/auth/forgot-password",
241+
"/api/auth/oauth/me",
242+
"/api/auth/oauth/refreshToken",
243+
"/api/auth/oauth/token",
244+
"/api/auth/reset-password",
245+
"/api/auth/saml/callback",
246+
"/api/auth/saml/token",
247+
"/api/auth/setup",
248+
"/api/auth/signup",
249+
"/api/auth/two-factor/totp/disable",
250+
"/api/auth/two-factor/totp/enable",
251+
"/api/auth/two-factor/totp/setup",
252+
"/api/auth/session",
253+
"/api/availability/calendar",
254+
"/api/cancel",
255+
"/api/cron/:path*",
256+
"/api/get-inbound-dynamic-variables",
257+
"/api/integrations/:path*",
258+
"/api/recorded-daily-video",
259+
"/api/router",
260+
"/api/routing-forms/queued-response",
261+
"/api/scim/v2.0/:path*",
262+
"/api/support/conversation",
263+
"/api/sync/helpscout",
264+
"/api/twilio/webhook",
265+
"/api/username",
266+
"/api/verify-booking-token",
267+
"/api/video/guest-session",
268+
"/api/webhook/app-credential",
269+
"/api/webhooks/calendar-subscription/:path*",
270+
"/api/webhooks/retell-ai",
271+
"/api/workflows/sms/user-response",
272+
"/api/trpc/:path*",
273+
"/api/auth/callback/:path*",
274+
"/api/book/event",
275+
"/api/book/instant-event",
276+
"/api/book/recurring-event",
277+
"/availability",
278+
],
269279
};
270280

271281
export default proxy;

0 commit comments

Comments
 (0)