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>
This commit is contained in:
Anik Dhabal Babu
2026-02-23 14:19:54 -03:00
committed by GitHub
co-authored by unknown Devin AI
parent 0a84ce5c73
commit 8238d4ffcd
4 changed files with 52 additions and 43 deletions
+18 -17
View File
@@ -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<typeof confirmHandler>;
@@ -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");
});
});
+9 -8
View File
@@ -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);
@@ -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<typeof confirmHandler>;
vi.mock("app/api/defaultResponderForAppDir", () => ({
@@ -80,6 +81,9 @@ function setMockRequestBody(body: Record<string, unknown>) {
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");
}
});
@@ -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);