From 8238d4ffcd47e3c3a9e8b394bf5c83cadcca9157 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:49:54 +0530 Subject: [PATCH] fix: use WEBAPP_URL for booking confirmation redirects to fix localhost behind proxy (#28144) Replace request.url with WEBAPP_URL from @calcom/lib/constants as the base URL for NextResponse.redirect() in booking confirmation API routes. Behind a reverse proxy, request.url resolves to http://localhost:3000 instead of the public domain. Fixes #20358 Co-Authored-By: unknown <> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/app/api/link/__tests__/route.test.ts | 35 ++++++++++--------- apps/web/app/api/link/route.ts | 17 ++++----- .../__tests__/route.test.ts | 30 +++++++++------- .../web/app/api/verify-booking-token/route.ts | 13 +++---- 4 files changed, 52 insertions(+), 43 deletions(-) diff --git a/apps/web/app/api/link/__tests__/route.test.ts b/apps/web/app/api/link/__tests__/route.test.ts index 542a70c632..8a64327607 100644 --- a/apps/web/app/api/link/__tests__/route.test.ts +++ b/apps/web/app/api/link/__tests__/route.test.ts @@ -1,7 +1,7 @@ -import type { NextRequest } from "next/server"; -import { describe, it, expect, vi, beforeEach } from "vitest"; import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler"; +import type { NextRequest } from "next/server"; import type { Mock } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mockConfirmHandler = confirmHandler as unknown as Mock; @@ -82,9 +82,9 @@ vi.mock("@calcom/features/booking-audit/lib/makeActor", () => ({ makeUserActor: vi.fn().mockReturnValue({ type: "user", id: "test-uuid" }), })); +import prisma from "@calcom/prisma"; // Import after mocks are set up import { GET } from "../route"; -import prisma from "@calcom/prisma"; const createMockRequest = (url: string): NextRequest => { const urlObj = new URL(url); @@ -97,13 +97,16 @@ const createMockRequest = (url: string): NextRequest => { } as unknown as NextRequest; }; +// Vitest sets NEXT_PUBLIC_WEBAPP_URL to http://app.cal.local:3000 (see vitest.config.mts) +const EXPECTED_REDIRECT_ORIGIN = "http://app.cal.local:3000"; + describe("link route", () => { beforeEach(() => { vi.clearAllMocks(); }); describe("GET handler - redirect URL construction", () => { - it("should redirect to booking page with the same origin as the request", async () => { + it("should redirect to booking page using WEBAPP_URL (fixes localhost redirect when behind proxy)", async () => { const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token"; const req = createMockRequest(baseUrl); @@ -113,11 +116,11 @@ describe("link route", () => { expect(location).toBeTruthy(); const redirectUrl = new URL(location!); - expect(redirectUrl.origin).toBe("https://app.example.com"); + expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN); expect(redirectUrl.pathname).toBe("/booking/test-booking-uid"); }); - it("should preserve custom domain origin in redirect URL", async () => { + it("should use WEBAPP_URL for redirects, not request.url (avoids localhost when proxy sends localhost)", async () => { const baseUrl = "https://custom-domain.company.com/api/link?action=accept&token=encrypted-token"; const req = createMockRequest(baseUrl); @@ -127,11 +130,11 @@ describe("link route", () => { expect(location).toBeTruthy(); const redirectUrl = new URL(location!); - expect(redirectUrl.origin).toBe("https://custom-domain.company.com"); + expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN); expect(location).not.toContain("localhost"); }); - it("should preserve self-hosted domain origin in redirect URL", async () => { + it("should use WEBAPP_URL for self-hosted deployments", async () => { const baseUrl = "https://calcom.internal.company.net/api/link?action=reject&token=encrypted-token"; const req = createMockRequest(baseUrl); @@ -141,11 +144,11 @@ describe("link route", () => { expect(location).toBeTruthy(); const redirectUrl = new URL(location!); - expect(redirectUrl.origin).toBe("https://calcom.internal.company.net"); - expect(location).not.toContain("localhost"); + expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN); + expect(redirectUrl.pathname).toBe("/booking/test-booking-uid"); }); - it("should construct redirect URLs relative to the request URL for various origins", async () => { + it("should construct redirect URLs using WEBAPP_URL regardless of request origin", async () => { const testOrigins = [ "https://app.cal.com", "https://acme.cal.com", @@ -164,7 +167,7 @@ describe("link route", () => { expect(location).toBeTruthy(); const redirectUrl = new URL(location!); - expect(redirectUrl.origin).toBe(origin); + expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN); expect(redirectUrl.pathname).toBe("/booking/test-booking-uid"); } }); @@ -174,7 +177,6 @@ describe("link route", () => { it("should redirect with error message when confirmHandler throws a TRPCError", async () => { const { TRPCError } = await import("@trpc/server"); - // Mock confirmHandler to throw a TRPCError mockConfirmHandler.mockRejectedValueOnce( new TRPCError({ code: "BAD_REQUEST", message: "Custom error" }) ); @@ -188,15 +190,14 @@ describe("link route", () => { expect(location).toBeTruthy(); const redirectUrl = new URL(location!); - expect(redirectUrl.origin).toBe("https://app.example.com"); + expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN); expect(redirectUrl.pathname).toBe("/booking/test-booking-uid"); expect(redirectUrl.searchParams.get("error")).toBe("Custom error"); }); - it("should preserve origin in error redirect URL", async () => { + it("should use WEBAPP_URL for error redirects (not localhost when behind proxy)", async () => { const { TRPCError } = await import("@trpc/server"); - // Mock confirmHandler to throw a TRPCError mockConfirmHandler.mockRejectedValueOnce(new TRPCError({ code: "INTERNAL_SERVER_ERROR" })); const baseUrl = "https://self-hosted.company.org/api/link?action=accept&token=encrypted-token"; @@ -208,7 +209,7 @@ describe("link route", () => { expect(location).toBeTruthy(); const redirectUrl = new URL(location!); - expect(redirectUrl.origin).toBe("https://self-hosted.company.org"); + expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN); expect(location).not.toContain("localhost"); }); }); diff --git a/apps/web/app/api/link/route.ts b/apps/web/app/api/link/route.ts index 1826a697e2..34c400fa62 100644 --- a/apps/web/app/api/link/route.ts +++ b/apps/web/app/api/link/route.ts @@ -1,14 +1,15 @@ -import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { z } from "zod"; - +import process from "node:process"; +import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import { distributedTracing } from "@calcom/lib/tracing/factory"; import prisma from "@calcom/prisma"; import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler"; import { TRPCError } from "@trpc/server"; -import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; enum DirectAction { ACCEPT = "accept", @@ -99,11 +100,11 @@ async function handler(request: NextRequest) { let message = "Error confirming booking"; if (e instanceof TRPCError) message = (e as TRPCError).message; return NextResponse.redirect( - new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, request.url) + new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, WEBAPP_URL) ); } - return NextResponse.redirect(new URL(`/booking/${bookingUid}`, request.url)); + return NextResponse.redirect(new URL(`/booking/${bookingUid}`, WEBAPP_URL)); } export const GET = defaultResponderForAppDir(handler); diff --git a/apps/web/app/api/verify-booking-token/__tests__/route.test.ts b/apps/web/app/api/verify-booking-token/__tests__/route.test.ts index f142e2f658..db8c2f01db 100644 --- a/apps/web/app/api/verify-booking-token/__tests__/route.test.ts +++ b/apps/web/app/api/verify-booking-token/__tests__/route.test.ts @@ -1,7 +1,8 @@ -import type { NextRequest } from "next/server"; -import { describe, it, expect, vi, beforeEach } from "vitest"; import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler"; +import type { NextRequest } from "next/server"; import type { Mock } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + const mockConfirmHandler = confirmHandler as unknown as Mock; vi.mock("app/api/defaultResponderForAppDir", () => ({ @@ -80,6 +81,9 @@ function setMockRequestBody(body: Record) { mockRequestBody = body; } +// Vitest sets NEXT_PUBLIC_WEBAPP_URL to http://app.cal.local:3000 (see vitest.config.mts) +const EXPECTED_REDIRECT_ORIGIN = "http://app.cal.local:3000"; + function expectErrorRedirect(res: Response, path: string, error: string) { const location = res.headers.get("location"); expect(location).toBeTruthy(); @@ -88,8 +92,10 @@ function expectErrorRedirect(res: Response, path: string, error: string) { expect(redirectUrl.searchParams.get("error")).toBe(error); } +import process from "node:process"; // Import after mocks are set up import { GET, POST } from "../route"; + const DB = { bookings: {} as Record< string, @@ -204,7 +210,7 @@ describe("verify-booking-token route", () => { expect(location).toBeTruthy(); const redirectUrl = new URL(location!); - expect(redirectUrl.origin).toBe("https://app.example.com"); + expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN); expect(redirectUrl.pathname).toBe("/booking/abc123"); expect(redirectUrl.searchParams.get("error")).toBeNull(); expect(mockConfirmHandler).toHaveBeenCalledWith( @@ -229,7 +235,7 @@ describe("verify-booking-token route", () => { expect(location).toBeTruthy(); const redirectUrl = new URL(location!); - expect(redirectUrl.origin).toBe("https://app.example.com"); + expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN); expect(redirectUrl.pathname).toBe("/booking/abc123"); expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking"); }); @@ -245,12 +251,12 @@ describe("verify-booking-token route", () => { expect(location).toBeTruthy(); const redirectUrl = new URL(location!); - expect(redirectUrl.origin).toBe("https://app.example.com"); + expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN); expect(redirectUrl.pathname).toBe("/booking/abc123"); expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking"); }); - it("should preserve the request origin in redirect URL (not hardcode localhost)", async () => { + it("should use WEBAPP_URL for redirects (fixes localhost when behind proxy)", async () => { const baseUrl = "https://custom-domain.example.org/api/verify-booking-token?action=reject&token=t&bookingUid=booking-uid&userId=1"; const req = createMockRequest(baseUrl, "GET"); @@ -261,7 +267,7 @@ describe("verify-booking-token route", () => { expect(location).toBeTruthy(); const redirectUrl = new URL(location!); - expect(redirectUrl.origin).toBe("https://custom-domain.example.org"); + expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN); expect(location).not.toContain("localhost"); }); @@ -294,12 +300,12 @@ describe("verify-booking-token route", () => { expect(location).toBeTruthy(); const redirectUrl = new URL(location!); - expect(redirectUrl.origin).toBe("https://app.example.com"); + expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN); expect(redirectUrl.pathname).toBe("/booking/abc123"); expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking"); }); - it("should preserve the request origin in POST redirect URL", async () => { + it("should use WEBAPP_URL for POST redirects (fixes localhost when behind proxy)", async () => { const baseUrl = "https://self-hosted.company.com/api/verify-booking-token?bookingUid=uid123&token=t&userId=1&action=reject"; const req = createMockRequest(baseUrl, "POST"); @@ -310,13 +316,13 @@ describe("verify-booking-token route", () => { expect(location).toBeTruthy(); const redirectUrl = new URL(location!); - expect(redirectUrl.origin).toBe("https://self-hosted.company.com"); + expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN); expect(location).not.toContain("localhost"); }); }); describe("redirect URL construction", () => { - it("should construct redirect URLs relative to the request URL, not hardcoded origins", async () => { + it("should construct redirect URLs using WEBAPP_URL regardless of request origin", async () => { const testOrigins = [ "https://app.cal.com", "https://acme.cal.com", @@ -335,7 +341,7 @@ describe("verify-booking-token route", () => { expect(location).toBeTruthy(); const redirectUrl = new URL(location!); - expect(redirectUrl.origin).toBe(origin); + expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN); expect(redirectUrl.pathname).toBe("/booking/test-uid"); } }); diff --git a/apps/web/app/api/verify-booking-token/route.ts b/apps/web/app/api/verify-booking-token/route.ts index 3ba4f28dfd..d8a77175ca 100644 --- a/apps/web/app/api/verify-booking-token/route.ts +++ b/apps/web/app/api/verify-booking-token/route.ts @@ -1,4 +1,5 @@ import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import { distributedTracing } from "@calcom/lib/tracing/factory"; import prisma from "@calcom/prisma"; import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler"; @@ -31,7 +32,7 @@ async function getHandler(request: NextRequest) { } catch { const bookingUid = queryParams.bookingUid || ""; return NextResponse.redirect( - new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url) + new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL) ); } } @@ -48,7 +49,7 @@ async function postHandler(request: NextRequest) { } catch { const bookingUid = queryParams.bookingUid || ""; return NextResponse.redirect( - new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url), + new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL), { status: 303 } ); } @@ -59,7 +60,7 @@ async function handleBookingAction( token: string, bookingUid: string, userId: string, - request: NextRequest, + _request: NextRequest, reason?: string ) { const booking = await prisma.booking.findUnique({ @@ -68,7 +69,7 @@ async function handleBookingAction( if (!booking) { return NextResponse.redirect( - new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url), + new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL), { status: 303 } ); } @@ -113,7 +114,7 @@ async function handleBookingAction( let message = "Error confirming booking"; if (e instanceof TRPCError) message = (e as TRPCError).message; return NextResponse.redirect( - new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, request.url), + new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, WEBAPP_URL), { status: 303 } ); } @@ -123,7 +124,7 @@ async function handleBookingAction( data: { oneTimePassword: null }, }); - return NextResponse.redirect(new URL(`/booking/${booking.uid}`, request.url), { status: 303 }); + return NextResponse.redirect(new URL(`/booking/${booking.uid}`, WEBAPP_URL), { status: 303 }); } export const GET = defaultResponderForAppDir(getHandler);