diff --git a/apps/web/abTest/middlewareFactory.ts b/apps/web/abTest/middlewareFactory.ts deleted file mode 100644 index dc06d17ad5..0000000000 --- a/apps/web/abTest/middlewareFactory.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { getBucket } from "abTest/utils"; -import type { NextMiddleware, NextRequest } from "next/server"; -import { NextResponse, URLPattern } from "next/server"; - -import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME } from "@calcom/lib/constants"; - -const ROUTES: [URLPattern, boolean][] = [ - ["/apps/:slug/setup", process.env.APP_ROUTER_APPS_SLUG_SETUP_ENABLED === "1"] as const, - ["/team", process.env.APP_ROUTER_TEAM_ENABLED === "1"] as const, -].map(([pathname, enabled]) => [ - new URLPattern({ - pathname, - }), - enabled, -]); - -export const abTestMiddlewareFactory = - (next: (req: NextRequest) => Promise>): NextMiddleware => - async (req: NextRequest) => { - const response = await next(req); - - const { pathname } = req.nextUrl; - - const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME); - - const route = ROUTES.find(([regExp]) => regExp.test(req.url)) ?? null; - const enabled = route !== null ? route[1] || override : false; - - if (pathname.includes("future") || !enabled) { - return response; - } - - const bucketValue = override ? "future" : req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value; - - if (!bucketValue || !["future", "legacy"].includes(bucketValue)) { - // cookie does not exist or it has incorrect value - const bucket = getBucket(); - - response.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, bucket, { - expires: Date.now() + 1000 * 60 * 30, - httpOnly: true, - }); // 30 min in ms - - if (bucket === "legacy") { - return response; - } - - const url = req.nextUrl.clone(); - url.pathname = `future${pathname}/`; - - return NextResponse.rewrite(url, response); - } - - if (bucketValue === "legacy") { - return response; - } - - const url = req.nextUrl.clone(); - url.pathname = `future${pathname}/`; - - return NextResponse.rewrite(url, response); - }; diff --git a/apps/web/abTest/utils.ts b/apps/web/abTest/utils.ts deleted file mode 100644 index ed40c9fca9..0000000000 --- a/apps/web/abTest/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { AB_TEST_BUCKET_PROBABILITY } from "@calcom/lib/constants"; - -const cryptoRandom = () => { - return crypto.getRandomValues(new Uint8Array(1))[0] / 0xff; -}; - -export const getBucket = () => { - return cryptoRandom() * 100 < AB_TEST_BUCKET_PROBABILITY ? "future" : "legacy"; -}; diff --git a/apps/web/middleware.test.ts b/apps/web/middleware.test.ts new file mode 100644 index 0000000000..9f91d69946 --- /dev/null +++ b/apps/web/middleware.test.ts @@ -0,0 +1,55 @@ +import { NextRequest } from "next/server"; +import { describe, it, expect } from "vitest"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; + +import { checkPostMethod, POST_METHODS_ALLOWED_APP_ROUTES } from "./middleware"; + +describe("Middleware - POST requests restriction", () => { + const createRequest = (path: string, method: string) => { + return new NextRequest( + new Request(`${WEBAPP_URL}${path}`, { + method, + }) + ); + }; + + it("should allow POST requests to /api routes", async () => { + const req1 = createRequest("/api/auth/signup", "POST"); + const res1 = checkPostMethod(req1); + expect(res1).toBeNull(); + + const req2 = createRequest("/api/trpc/book/event", "POST"); + const res2 = checkPostMethod(req2); + expect(res2).toBeNull(); + }); + + it("should allow POST requests to allowed app routes", async () => { + POST_METHODS_ALLOWED_APP_ROUTES.forEach(async (route) => { + const req = createRequest(route, "POST"); + const res = checkPostMethod(req); + expect(res).toBeNull(); + }); + }); + + it("should block POST requests to not-allowed app routes", async () => { + const req = createRequest("/team/xyz", "POST"); + const res = checkPostMethod(req); + expect(res).not.toBeNull(); + expect(res?.status).toBe(405); + expect(res?.statusText).toBe("Method Not Allowed"); + expect(res?.headers.get("Allow")).toBe("GET"); + }); + + it("should allow GET requests to app routes", async () => { + const req = createRequest("/team/xyz", "GET"); + const res = checkPostMethod(req); + expect(res).toBeNull(); + }); + + it("should allow GET requests to /api routes", async () => { + const req = createRequest("/api/auth/signup", "GET"); + const res = checkPostMethod(req); + expect(res).toBeNull(); + }); +}); diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index beb83ebd97..c0aa834c7a 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -6,9 +6,7 @@ import { NextResponse } from "next/server"; import { getLocale } from "@calcom/features/auth/lib/getLocale"; import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry"; -import { csp } from "@lib/csp"; - -import { abTestMiddlewareFactory } from "./abTest/middlewareFactory"; +import { csp } from "./lib/csp"; const safeGet = async (key: string): Promise => { try { @@ -18,7 +16,33 @@ const safeGet = async (key: string): Promise => { } }; +export const POST_METHODS_ALLOWED_API_ROUTES = ["/api/auth/signup", "/api/trpc"]; +// Some app routes are allowed because "revalidatePath()" is used to revalidate the cache for them +export const POST_METHODS_ALLOWED_APP_ROUTES = ["/settings/my-account/general"]; + +export function checkPostMethod(req: NextRequest) { + const pathname = req.nextUrl.pathname; + if ( + ![...POST_METHODS_ALLOWED_API_ROUTES, ...POST_METHODS_ALLOWED_APP_ROUTES].some((route) => + pathname.startsWith(route) + ) && + req.method === "POST" + ) { + return new NextResponse(null, { + status: 405, + statusText: "Method Not Allowed", + headers: { + Allow: "GET", + }, + }); + } + return null; +} + const middleware = async (req: NextRequest): Promise> => { + const postCheckResult = checkPostMethod(req); + if (postCheckResult) return postCheckResult; + const url = req.nextUrl; const requestHeaders = new Headers(req.headers); requestHeaders.set("x-url", req.url); @@ -174,7 +198,7 @@ export const config = { }; export default collectEvents({ - middleware: abTestMiddlewareFactory(middleware), + middleware, ...nextCollectBasicSettings, cookieName: "__clnds", extend: extendEventData,