Skip to content

Commit 8238d4f

Browse files
fix: use WEBAPP_URL for booking confirmation redirects to fix localhost behind proxy (calcom#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 calcom#20358 Co-Authored-By: unknown <> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 0a84ce5 commit 8238d4f

4 files changed

Lines changed: 52 additions & 43 deletions

File tree

apps/web/app/api/link/__tests__/route.test.ts

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { NextRequest } from "next/server";
2-
import { describe, it, expect, vi, beforeEach } from "vitest";
31
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
2+
import type { NextRequest } from "next/server";
43
import type { Mock } from "vitest";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
55

66
const mockConfirmHandler = confirmHandler as unknown as Mock<typeof confirmHandler>;
77

@@ -82,9 +82,9 @@ vi.mock("@calcom/features/booking-audit/lib/makeActor", () => ({
8282
makeUserActor: vi.fn().mockReturnValue({ type: "user", id: "test-uuid" }),
8383
}));
8484

85+
import prisma from "@calcom/prisma";
8586
// Import after mocks are set up
8687
import { GET } from "../route";
87-
import prisma from "@calcom/prisma";
8888

8989
const createMockRequest = (url: string): NextRequest => {
9090
const urlObj = new URL(url);
@@ -97,13 +97,16 @@ const createMockRequest = (url: string): NextRequest => {
9797
} as unknown as NextRequest;
9898
};
9999

100+
// Vitest sets NEXT_PUBLIC_WEBAPP_URL to http://app.cal.local:3000 (see vitest.config.mts)
101+
const EXPECTED_REDIRECT_ORIGIN = "http://app.cal.local:3000";
102+
100103
describe("link route", () => {
101104
beforeEach(() => {
102105
vi.clearAllMocks();
103106
});
104107

105108
describe("GET handler - redirect URL construction", () => {
106-
it("should redirect to booking page with the same origin as the request", async () => {
109+
it("should redirect to booking page using WEBAPP_URL (fixes localhost redirect when behind proxy)", async () => {
107110
const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token";
108111
const req = createMockRequest(baseUrl);
109112

@@ -113,11 +116,11 @@ describe("link route", () => {
113116
expect(location).toBeTruthy();
114117
const redirectUrl = new URL(location!);
115118

116-
expect(redirectUrl.origin).toBe("https://app.example.com");
119+
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
117120
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
118121
});
119122

120-
it("should preserve custom domain origin in redirect URL", async () => {
123+
it("should use WEBAPP_URL for redirects, not request.url (avoids localhost when proxy sends localhost)", async () => {
121124
const baseUrl = "https://custom-domain.company.com/api/link?action=accept&token=encrypted-token";
122125
const req = createMockRequest(baseUrl);
123126

@@ -127,11 +130,11 @@ describe("link route", () => {
127130
expect(location).toBeTruthy();
128131
const redirectUrl = new URL(location!);
129132

130-
expect(redirectUrl.origin).toBe("https://custom-domain.company.com");
133+
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
131134
expect(location).not.toContain("localhost");
132135
});
133136

134-
it("should preserve self-hosted domain origin in redirect URL", async () => {
137+
it("should use WEBAPP_URL for self-hosted deployments", async () => {
135138
const baseUrl = "https://calcom.internal.company.net/api/link?action=reject&token=encrypted-token";
136139
const req = createMockRequest(baseUrl);
137140

@@ -141,11 +144,11 @@ describe("link route", () => {
141144
expect(location).toBeTruthy();
142145
const redirectUrl = new URL(location!);
143146

144-
expect(redirectUrl.origin).toBe("https://calcom.internal.company.net");
145-
expect(location).not.toContain("localhost");
147+
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
148+
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
146149
});
147150

148-
it("should construct redirect URLs relative to the request URL for various origins", async () => {
151+
it("should construct redirect URLs using WEBAPP_URL regardless of request origin", async () => {
149152
const testOrigins = [
150153
"https://app.cal.com",
151154
"https://acme.cal.com",
@@ -164,7 +167,7 @@ describe("link route", () => {
164167
expect(location).toBeTruthy();
165168
const redirectUrl = new URL(location!);
166169

167-
expect(redirectUrl.origin).toBe(origin);
170+
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
168171
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
169172
}
170173
});
@@ -174,7 +177,6 @@ describe("link route", () => {
174177
it("should redirect with error message when confirmHandler throws a TRPCError", async () => {
175178
const { TRPCError } = await import("@trpc/server");
176179

177-
// Mock confirmHandler to throw a TRPCError
178180
mockConfirmHandler.mockRejectedValueOnce(
179181
new TRPCError({ code: "BAD_REQUEST", message: "Custom error" })
180182
);
@@ -188,15 +190,14 @@ describe("link route", () => {
188190
expect(location).toBeTruthy();
189191
const redirectUrl = new URL(location!);
190192

191-
expect(redirectUrl.origin).toBe("https://app.example.com");
193+
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
192194
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
193195
expect(redirectUrl.searchParams.get("error")).toBe("Custom error");
194196
});
195197

196-
it("should preserve origin in error redirect URL", async () => {
198+
it("should use WEBAPP_URL for error redirects (not localhost when behind proxy)", async () => {
197199
const { TRPCError } = await import("@trpc/server");
198200

199-
// Mock confirmHandler to throw a TRPCError
200201
mockConfirmHandler.mockRejectedValueOnce(new TRPCError({ code: "INTERNAL_SERVER_ERROR" }));
201202

202203
const baseUrl = "https://self-hosted.company.org/api/link?action=accept&token=encrypted-token";
@@ -208,7 +209,7 @@ describe("link route", () => {
208209
expect(location).toBeTruthy();
209210
const redirectUrl = new URL(location!);
210211

211-
expect(redirectUrl.origin).toBe("https://self-hosted.company.org");
212+
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
212213
expect(location).not.toContain("localhost");
213214
});
214215
});

apps/web/app/api/link/route.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
2-
import type { NextRequest } from "next/server";
3-
import { NextResponse } from "next/server";
4-
import { z } from "zod";
5-
1+
import process from "node:process";
2+
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
3+
import { WEBAPP_URL } from "@calcom/lib/constants";
64
import { symmetricDecrypt } from "@calcom/lib/crypto";
75
import { distributedTracing } from "@calcom/lib/tracing/factory";
86
import prisma from "@calcom/prisma";
97
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
108
import { TRPCError } from "@trpc/server";
11-
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
9+
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
10+
import type { NextRequest } from "next/server";
11+
import { NextResponse } from "next/server";
12+
import { z } from "zod";
1213

1314
enum DirectAction {
1415
ACCEPT = "accept",
@@ -99,11 +100,11 @@ async function handler(request: NextRequest) {
99100
let message = "Error confirming booking";
100101
if (e instanceof TRPCError) message = (e as TRPCError).message;
101102
return NextResponse.redirect(
102-
new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, request.url)
103+
new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, WEBAPP_URL)
103104
);
104105
}
105106

106-
return NextResponse.redirect(new URL(`/booking/${bookingUid}`, request.url));
107+
return NextResponse.redirect(new URL(`/booking/${bookingUid}`, WEBAPP_URL));
107108
}
108109

109110
export const GET = defaultResponderForAppDir(handler);

apps/web/app/api/verify-booking-token/__tests__/route.test.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type { NextRequest } from "next/server";
2-
import { describe, it, expect, vi, beforeEach } from "vitest";
31
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
2+
import type { NextRequest } from "next/server";
43
import type { Mock } from "vitest";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
56
const mockConfirmHandler = confirmHandler as unknown as Mock<typeof confirmHandler>;
67

78
vi.mock("app/api/defaultResponderForAppDir", () => ({
@@ -80,6 +81,9 @@ function setMockRequestBody(body: Record<string, unknown>) {
8081
mockRequestBody = body;
8182
}
8283

84+
// Vitest sets NEXT_PUBLIC_WEBAPP_URL to http://app.cal.local:3000 (see vitest.config.mts)
85+
const EXPECTED_REDIRECT_ORIGIN = "http://app.cal.local:3000";
86+
8387
function expectErrorRedirect(res: Response, path: string, error: string) {
8488
const location = res.headers.get("location");
8589
expect(location).toBeTruthy();
@@ -88,8 +92,10 @@ function expectErrorRedirect(res: Response, path: string, error: string) {
8892
expect(redirectUrl.searchParams.get("error")).toBe(error);
8993
}
9094

95+
import process from "node:process";
9196
// Import after mocks are set up
9297
import { GET, POST } from "../route";
98+
9399
const DB = {
94100
bookings: {} as Record<
95101
string,
@@ -204,7 +210,7 @@ describe("verify-booking-token route", () => {
204210
expect(location).toBeTruthy();
205211
const redirectUrl = new URL(location!);
206212

207-
expect(redirectUrl.origin).toBe("https://app.example.com");
213+
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
208214
expect(redirectUrl.pathname).toBe("/booking/abc123");
209215
expect(redirectUrl.searchParams.get("error")).toBeNull();
210216
expect(mockConfirmHandler).toHaveBeenCalledWith(
@@ -229,7 +235,7 @@ describe("verify-booking-token route", () => {
229235
expect(location).toBeTruthy();
230236
const redirectUrl = new URL(location!);
231237

232-
expect(redirectUrl.origin).toBe("https://app.example.com");
238+
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
233239
expect(redirectUrl.pathname).toBe("/booking/abc123");
234240
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
235241
});
@@ -245,12 +251,12 @@ describe("verify-booking-token route", () => {
245251
expect(location).toBeTruthy();
246252
const redirectUrl = new URL(location!);
247253

248-
expect(redirectUrl.origin).toBe("https://app.example.com");
254+
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
249255
expect(redirectUrl.pathname).toBe("/booking/abc123");
250256
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
251257
});
252258

253-
it("should preserve the request origin in redirect URL (not hardcode localhost)", async () => {
259+
it("should use WEBAPP_URL for redirects (fixes localhost when behind proxy)", async () => {
254260
const baseUrl =
255261
"https://custom-domain.example.org/api/verify-booking-token?action=reject&token=t&bookingUid=booking-uid&userId=1";
256262
const req = createMockRequest(baseUrl, "GET");
@@ -261,7 +267,7 @@ describe("verify-booking-token route", () => {
261267
expect(location).toBeTruthy();
262268
const redirectUrl = new URL(location!);
263269

264-
expect(redirectUrl.origin).toBe("https://custom-domain.example.org");
270+
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
265271
expect(location).not.toContain("localhost");
266272
});
267273

@@ -294,12 +300,12 @@ describe("verify-booking-token route", () => {
294300
expect(location).toBeTruthy();
295301
const redirectUrl = new URL(location!);
296302

297-
expect(redirectUrl.origin).toBe("https://app.example.com");
303+
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
298304
expect(redirectUrl.pathname).toBe("/booking/abc123");
299305
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
300306
});
301307

302-
it("should preserve the request origin in POST redirect URL", async () => {
308+
it("should use WEBAPP_URL for POST redirects (fixes localhost when behind proxy)", async () => {
303309
const baseUrl =
304310
"https://self-hosted.company.com/api/verify-booking-token?bookingUid=uid123&token=t&userId=1&action=reject";
305311
const req = createMockRequest(baseUrl, "POST");
@@ -310,13 +316,13 @@ describe("verify-booking-token route", () => {
310316
expect(location).toBeTruthy();
311317
const redirectUrl = new URL(location!);
312318

313-
expect(redirectUrl.origin).toBe("https://self-hosted.company.com");
319+
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
314320
expect(location).not.toContain("localhost");
315321
});
316322
});
317323

318324
describe("redirect URL construction", () => {
319-
it("should construct redirect URLs relative to the request URL, not hardcoded origins", async () => {
325+
it("should construct redirect URLs using WEBAPP_URL regardless of request origin", async () => {
320326
const testOrigins = [
321327
"https://app.cal.com",
322328
"https://acme.cal.com",
@@ -335,7 +341,7 @@ describe("verify-booking-token route", () => {
335341
expect(location).toBeTruthy();
336342
const redirectUrl = new URL(location!);
337343

338-
expect(redirectUrl.origin).toBe(origin);
344+
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
339345
expect(redirectUrl.pathname).toBe("/booking/test-uid");
340346
}
341347
});

apps/web/app/api/verify-booking-token/route.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
2+
import { WEBAPP_URL } from "@calcom/lib/constants";
23
import { distributedTracing } from "@calcom/lib/tracing/factory";
34
import prisma from "@calcom/prisma";
45
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
@@ -31,7 +32,7 @@ async function getHandler(request: NextRequest) {
3132
} catch {
3233
const bookingUid = queryParams.bookingUid || "";
3334
return NextResponse.redirect(
34-
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url)
35+
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL)
3536
);
3637
}
3738
}
@@ -48,7 +49,7 @@ async function postHandler(request: NextRequest) {
4849
} catch {
4950
const bookingUid = queryParams.bookingUid || "";
5051
return NextResponse.redirect(
51-
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
52+
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL),
5253
{ status: 303 }
5354
);
5455
}
@@ -59,7 +60,7 @@ async function handleBookingAction(
5960
token: string,
6061
bookingUid: string,
6162
userId: string,
62-
request: NextRequest,
63+
_request: NextRequest,
6364
reason?: string
6465
) {
6566
const booking = await prisma.booking.findUnique({
@@ -68,7 +69,7 @@ async function handleBookingAction(
6869

6970
if (!booking) {
7071
return NextResponse.redirect(
71-
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
72+
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL),
7273
{ status: 303 }
7374
);
7475
}
@@ -113,7 +114,7 @@ async function handleBookingAction(
113114
let message = "Error confirming booking";
114115
if (e instanceof TRPCError) message = (e as TRPCError).message;
115116
return NextResponse.redirect(
116-
new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, request.url),
117+
new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, WEBAPP_URL),
117118
{ status: 303 }
118119
);
119120
}
@@ -123,7 +124,7 @@ async function handleBookingAction(
123124
data: { oneTimePassword: null },
124125
});
125126

126-
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, request.url), { status: 303 });
127+
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, WEBAPP_URL), { status: 303 });
127128
}
128129

129130
export const GET = defaultResponderForAppDir(getHandler);

0 commit comments

Comments
 (0)