diff --git a/apps/web/app/api/auth/forgot-password/route.ts b/apps/web/app/api/auth/forgot-password/route.ts index 87c7abcad1..3f45de0fad 100644 --- a/apps/web/app/api/auth/forgot-password/route.ts +++ b/apps/web/app/api/auth/forgot-password/route.ts @@ -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)}`, diff --git a/apps/web/proxy.test.ts b/apps/web/proxy.test.ts index e13deb6bb3..6a7d65e071 100644 --- a/apps/web/proxy.test.ts +++ b/apps/web/proxy.test.ts @@ -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); + } } }); }); diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 96e2a465bf..ff42ec19aa 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -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 (key: string): Promise => { try { return get(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> => { - 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("isSignupDisabled"); // If is in maintenance mode, point the url pathname to the maintenance page @@ -196,16 +172,6 @@ const proxy = async (req: NextRequest): Promise> => { 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;