fix: use relative redirects in booking confirmation routes to fix localhost redirect issue (#25794)

* fix: use relative redirects in booking confirmation routes to fix localhost redirect issue

When Cal.com runs behind a reverse proxy, request.url may return the internal
server URL (localhost:3000) instead of the external URL. This causes users to
be redirected to localhost after confirming bookings via email links.

This fix changes the redirect URLs from using url.origin (which could be
localhost) to using relative URLs via new URL(path, request.url). This ensures
the browser resolves the redirect against whatever domain the user actually
requested, fixing the issue for self-hosted deployments behind proxies.

Fixes #20358

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* test: add tests for booking confirmation redirect URL construction

Add unit tests for verify-booking-token and link API routes to ensure:
- Redirect URLs preserve the request origin (not hardcoded localhost)
- Redirect URLs use the correct booking path (/booking/{uid})
- Error messages are properly encoded in query params
- POST handler returns correct 303 status code

These tests verify the fix for #20358 where users were redirected to
localhost:3000 instead of the proper production URL after confirming
bookings via email links.

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: add second argument to GET/POST calls in test files to fix type errors

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Volnei Munhoz
2025-12-12 19:02:54 +05:30
committed by GitHub
co-authored by Volnei Munhoz Devin AI
parent a69fe2ed00
commit 2ad24048b6
4 changed files with 440 additions and 14 deletions
@@ -0,0 +1,208 @@
import type { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("app/api/defaultResponderForAppDir", () => ({
defaultResponderForAppDir:
(handler: (req: NextRequest) => Promise<Response>) =>
(req: NextRequest, _context: { params: Promise<Record<string, string>> }) =>
handler(req),
}));
vi.mock("next/headers", () => ({
headers: vi.fn().mockResolvedValue(new Headers()),
cookies: vi.fn().mockResolvedValue({ getAll: () => [] }),
}));
vi.mock("next/server", () => ({
NextResponse: {
redirect: vi.fn((url: string | URL, init?: { status?: number }) => {
const location = typeof url === "string" ? url : url.toString();
return {
status: init?.status ?? 302,
headers: {
get: (name: string) => (name.toLowerCase() === "location" ? location : null),
},
} as unknown as Response;
}),
},
}));
vi.mock("@calcom/lib/crypto", () => ({
symmetricDecrypt: vi.fn().mockReturnValue(
JSON.stringify({
bookingUid: "test-booking-uid",
userId: 1,
})
),
}));
vi.mock("@calcom/prisma", () => ({
default: {
booking: {
findUniqueOrThrow: vi.fn().mockResolvedValue({
id: 1,
uid: "test-booking-uid",
recurringEventId: null,
}),
},
user: {
findUniqueOrThrow: vi.fn().mockResolvedValue({
id: 1,
locale: "en",
}),
},
},
}));
vi.mock("@calcom/trpc/server/createContext", () => ({
createContext: vi.fn().mockResolvedValue({}),
}));
vi.mock("@calcom/trpc/server/routers/viewer/bookings/_router", () => ({
bookingsRouter: {},
}));
vi.mock("@calcom/trpc/server/trpc", () => ({
createCallerFactory: vi.fn().mockReturnValue(() => ({
confirm: vi.fn().mockResolvedValue({}),
})),
}));
vi.mock("@lib/buildLegacyCtx", () => ({
buildLegacyRequest: vi.fn().mockReturnValue({}),
}));
// Import after mocks are set up
import { GET } from "../route";
const createMockRequest = (url: string): NextRequest => {
const urlObj = new URL(url);
return {
method: "GET",
url,
nextUrl: {
searchParams: urlObj.searchParams,
},
} as unknown as NextRequest;
};
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 () => {
const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token";
const req = createMockRequest(baseUrl);
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
});
it("should preserve custom domain origin in redirect URL", async () => {
const baseUrl = "https://custom-domain.company.com/api/link?action=accept&token=encrypted-token";
const req = createMockRequest(baseUrl);
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://custom-domain.company.com");
expect(location).not.toContain("localhost");
});
it("should preserve self-hosted domain origin in redirect URL", async () => {
const baseUrl = "https://calcom.internal.company.net/api/link?action=reject&token=encrypted-token";
const req = createMockRequest(baseUrl);
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://calcom.internal.company.net");
expect(location).not.toContain("localhost");
});
it("should construct redirect URLs relative to the request URL for various origins", async () => {
const testOrigins = [
"https://app.cal.com",
"https://acme.cal.com",
"https://calcom.company.internal",
"http://192.168.1.100:3000",
];
for (const origin of testOrigins) {
vi.clearAllMocks();
const baseUrl = `${origin}/api/link?action=accept&token=encrypted-token`;
const req = createMockRequest(baseUrl);
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe(origin);
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
}
});
});
describe("GET handler - error handling", () => {
it("should redirect with error message when TRPC throws an error", async () => {
const { TRPCError } = await import("@trpc/server");
// Mock createCallerFactory to throw a TRPCError
const { createCallerFactory } = await import("@calcom/trpc/server/trpc");
vi.mocked(createCallerFactory).mockReturnValue(() => ({
confirm: vi.fn().mockRejectedValue(new TRPCError({ code: "BAD_REQUEST", message: "Custom error" })),
}));
const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token";
const req = createMockRequest(baseUrl);
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
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 () => {
const { TRPCError } = await import("@trpc/server");
// Mock createCallerFactory to throw a TRPCError
const { createCallerFactory } = await import("@calcom/trpc/server/trpc");
vi.mocked(createCallerFactory).mockReturnValue(() => ({
confirm: vi.fn().mockRejectedValue(new TRPCError({ code: "INTERNAL_SERVER_ERROR" })),
}));
const baseUrl = "https://self-hosted.company.org/api/link?action=accept&token=encrypted-token";
const req = createMockRequest(baseUrl);
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://self-hosted.company.org");
expect(location).not.toContain("localhost");
});
});
});
+2 -3
View File
@@ -59,7 +59,6 @@ const createSessionGetter = (userId: number) => async () => {
};
async function handler(request: NextRequest) {
const url = new URL(request.url);
const searchParams = request.nextUrl.searchParams;
const { action, token, reason } = querySchema.parse(Object.fromEntries(searchParams.entries()));
@@ -120,10 +119,10 @@ async function handler(request: NextRequest) {
} catch (e) {
let message = "Error confirming booking";
if (e instanceof TRPCError) message = (e as TRPCError).message;
return NextResponse.redirect(`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent(message)}`);
return NextResponse.redirect(new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, request.url));
}
return NextResponse.redirect(`${url.origin}/booking/${bookingUid}`);
return NextResponse.redirect(new URL(`/booking/${bookingUid}`, request.url));
}
export const GET = defaultResponderForAppDir(handler);
@@ -0,0 +1,222 @@
import type { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("app/api/defaultResponderForAppDir", () => ({
defaultResponderForAppDir:
(handler: (req: NextRequest) => Promise<Response>) =>
(req: NextRequest, _context: { params: Promise<Record<string, string>> }) =>
handler(req),
}));
vi.mock("app/api/parseRequestData", () => ({
parseRequestData: vi.fn().mockResolvedValue({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn().mockResolvedValue(new Headers()),
cookies: vi.fn().mockResolvedValue({ getAll: () => [] }),
}));
vi.mock("next/server", () => ({
NextResponse: {
redirect: vi.fn((url: string | URL, init?: { status?: number }) => {
const location = typeof url === "string" ? url : url.toString();
return {
status: init?.status ?? 302,
headers: {
get: (name: string) => (name.toLowerCase() === "location" ? location : null),
},
} as unknown as Response;
}),
},
}));
vi.mock("@calcom/prisma", () => ({
default: {
booking: {
findUnique: vi.fn(),
update: vi.fn(),
},
user: {
findUniqueOrThrow: vi.fn(),
},
},
}));
vi.mock("@calcom/trpc/server/createContext", () => ({
createContext: vi.fn().mockResolvedValue({}),
}));
vi.mock("@calcom/trpc/server/routers/viewer/bookings/_router", () => ({
bookingsRouter: {},
}));
vi.mock("@calcom/trpc/server/trpc", () => ({
createCallerFactory: vi.fn().mockReturnValue(() => ({
confirm: vi.fn().mockResolvedValue({}),
})),
}));
vi.mock("@lib/buildLegacyCtx", () => ({
buildLegacyRequest: vi.fn().mockReturnValue({}),
}));
// Import after mocks are set up
import { GET, POST } from "../route";
const createMockRequest = (url: string, method: "GET" | "POST" = "GET"): NextRequest => {
const urlObj = new URL(url);
return {
method,
url,
nextUrl: {
searchParams: urlObj.searchParams,
},
} as unknown as NextRequest;
};
describe("verify-booking-token route", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET handler", () => {
it("should redirect to booking page with error when action is reject (requires POST)", async () => {
const baseUrl =
"https://app.example.com/api/verify-booking-token?action=reject&token=test-token&bookingUid=abc123&userId=42";
const req = createMockRequest(baseUrl, "GET");
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.pathname).toBe("/booking/abc123");
expect(redirectUrl.searchParams.get("error")).toBe("Rejection requires POST method");
});
it("should redirect with error when query params are invalid (missing action)", async () => {
const baseUrl =
"https://app.example.com/api/verify-booking-token?token=test-token&bookingUid=abc123&userId=42";
const req = createMockRequest(baseUrl, "GET");
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.pathname).toBe("/booking/abc123");
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
});
it("should redirect with error when query params are invalid (missing token)", async () => {
const baseUrl =
"https://app.example.com/api/verify-booking-token?action=accept&bookingUid=abc123&userId=42";
const req = createMockRequest(baseUrl, "GET");
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
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 () => {
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");
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://custom-domain.example.org");
expect(location).not.toContain("localhost");
});
it("should handle empty bookingUid in error case", async () => {
const baseUrl = "https://app.example.com/api/verify-booking-token?token=test-token&userId=42";
const req = createMockRequest(baseUrl, "GET");
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe("/booking/");
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
});
});
describe("POST handler", () => {
it("should redirect with status 303 when query params are invalid", async () => {
const baseUrl =
"https://app.example.com/api/verify-booking-token?token=test-token&bookingUid=abc123&userId=42";
const req = createMockRequest(baseUrl, "POST");
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(303);
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
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 () => {
const baseUrl =
"https://self-hosted.company.com/api/verify-booking-token?bookingUid=uid123&token=t&userId=1";
const req = createMockRequest(baseUrl, "POST");
const res = await POST(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://self-hosted.company.com");
expect(location).not.toContain("localhost");
});
});
describe("redirect URL construction", () => {
it("should construct redirect URLs relative to the request URL, not hardcoded origins", async () => {
const testOrigins = [
"https://app.cal.com",
"https://acme.cal.com",
"https://calcom.company.internal",
"http://192.168.1.100:3000",
];
for (const origin of testOrigins) {
vi.clearAllMocks();
const baseUrl = `${origin}/api/verify-booking-token?action=reject&token=t&bookingUid=test-uid&userId=1`;
const req = createMockRequest(baseUrl, "GET");
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe(origin);
expect(redirectUrl.pathname).toBe("/booking/test-uid");
}
});
});
});
+8 -11
View File
@@ -29,7 +29,6 @@ const querySchema = z.object({
});
async function getHandler(request: NextRequest) {
const url = new URL(request.url);
const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries());
try {
@@ -38,21 +37,20 @@ async function getHandler(request: NextRequest) {
if (action === DirectAction.REJECT) {
// Rejections should use POST method
return NextResponse.redirect(
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`, request.url)
);
}
return await handleBookingAction(action, token, bookingUid, userId, request, undefined);
} catch (error) {
} catch {
const bookingUid = queryParams.bookingUid || "";
return NextResponse.redirect(
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url)
);
}
}
async function postHandler(request: NextRequest) {
const url = new URL(request.url);
const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries());
try {
@@ -61,10 +59,10 @@ async function postHandler(request: NextRequest) {
const { reason } = z.object({ reason: z.string().optional() }).parse(body || {});
return await handleBookingAction(action, token, bookingUid, userId, request, reason);
} catch (error) {
} catch {
const bookingUid = queryParams.bookingUid || "";
return NextResponse.redirect(
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`,
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
{ status: 303 }
);
}
@@ -78,14 +76,13 @@ async function handleBookingAction(
request: NextRequest,
reason?: string
) {
const url = new URL(request.url);
const booking = await prisma.booking.findUnique({
where: { oneTimePassword: token },
});
if (!booking) {
return NextResponse.redirect(
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`,
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
{ status: 303 }
);
}
@@ -143,7 +140,7 @@ async function handleBookingAction(
let message = "Error confirming booking";
if (e instanceof TRPCError) message = (e as TRPCError).message;
return NextResponse.redirect(
`${url.origin}/booking/${booking.uid}?error=${encodeURIComponent(message)}`,
new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, request.url),
{ status: 303 }
);
}
@@ -153,7 +150,7 @@ async function handleBookingAction(
data: { oneTimePassword: null },
});
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}`, { status: 303 });
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, request.url), { status: 303 });
}
export const GET = defaultResponderForAppDir(getHandler);