Files
calendar/apps/web/proxy.ts
T
Keith WilliamsGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
8b17df4621 refactor: Stop using Unkey for IP-based rate limiting (#27674)
* refactor: migrate IP-based rate limiting from Unkey to Cloudflare

Remove Unkey rate limiting for IP-based endpoints that will now be
handled by Cloudflare Enterprise Advanced Rate Limiting:

- Regular booking creation (createBooking:{hashedIP})
- Recurring booking creation (createRecurringBooking:{hashedIP})
- Instant meeting creation (instant.event-{hashedIP})
- Booking cancellation for unauthenticated users (api:cancel-ip:{hashedIP})
- Forgot password (forgotPassword:{hashedIP})
- Reset password (api:reset-password:{hashedIP})
- Signup (api:signup:{hashedIP})
- API v1 requests ({userId} with auto-lock)
- Global proxy rate limiting (common namespace)

Rate limiting that remains in Unkey (user/entity-based):
- Login (hashedEmail)
- Booking cancellation for authenticated users (api:cancel-user:{userId})
- 2FA setup/enable/disable (api:totp-*:{userId})
- SMS sending (team/org/user based)
- Email verification (various patterns)
- Team member operations (userId based)
- Routing forms (formId:responseHash)
- AI phone calls (userId based)

Also includes Cloudflare configuration proposal document with
recommended rules using JA4 fingerprinting for enhanced protection.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: remove cloudflare proposal doc from PR

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: remove cloudflare comments and keep common rate limiting type

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor: revert global middleware from PR #25080 and restore core rate limits

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor: restore instantMeeting rate limiting

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: restore email fallback in forgot-password rate limiting

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Remove the static file check

* refactor: add POST_METHODS_ALLOWED_API_ROUTES to proxy matcher

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* revert: restore API v1 rate limiting to original state

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* revert: restore reset-password, cancel, book/event, book/recurring-event to original state

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor: use POST_METHODS_ALLOWED_API_ROUTES spread in matcher instead of hardcoded routes

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* revert: restore signup route to original state

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: update proxy matcher tests for POST_METHODS_ALLOWED_API_ROUTES spread

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Rename var

* refactor: revert matcher to static strings, add sync-check test for POST_METHODS_ALLOWED_API_ROUTES

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* removed dead routing forms rewrite code

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-09 15:49:26 -03:00

282 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import process from "node:process";
import { getCspHeader, getCspNonce } from "@lib/csp";
import { get } from "@vercel/edge-config";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
try {
return get<T>(key);
} catch (error) {
// Don't crash if EDGE_CONFIG env var is missing
}
};
export const POST_METHODS_ALLOWED_API_ROUTES = [
"/api/auth/forgot-password",
"/api/auth/oauth/me",
"/api/auth/oauth/refreshToken",
"/api/auth/oauth/token",
"/api/auth/reset-password",
"/api/auth/saml/callback",
"/api/auth/saml/token",
"/api/auth/setup",
"/api/auth/signup",
"/api/auth/two-factor/totp/disable",
"/api/auth/two-factor/totp/enable",
"/api/auth/two-factor/totp/setup",
"/api/auth/session",
"/api/availability/calendar",
"/api/cancel",
"/api/cron/bookingReminder",
"/api/cron/calendar-cache-cleanup",
"/api/cron/changeTimeZone",
"/api/cron/checkSmsPrices",
"/api/cron/downgradeUsers",
"/api/cron/monthlyDigestEmail",
"/api/cron/syncAppMeta",
"/api/cron/webhookTriggers",
"/api/cron/workflows/scheduleEmailReminders",
"/api/cron/workflows/scheduleSMSReminders",
"/api/cron/workflows/scheduleWhatsappReminders",
"/api/get-inbound-dynamic-variables",
"/api/integrations/", // for /api/integrations/[...args] and webhooks
"/api/recorded-daily-video",
"/api/router",
"/api/routing-forms/queued-response",
"/api/scim/v2.0/", // /api/scim/v2.0/[...directory]
"/api/support/conversation",
"/api/sync/helpscout",
"/api/twilio/webhook",
"/api/username",
"/api/verify-booking-token",
"/api/video/guest-session",
"/api/webhook/app-credential",
"/api/webhooks/calendar-subscription/", // /api/webhooks/calendar-subscription/[provider]
"/api/webhooks/retell-ai",
"/api/workflows/sms/user-response",
"/api/trpc/", // for tRPC
"/api/auth/callback/", // for NextAuth
"/api/book/event",
"/api/book/instant-event",
"/api/book/recurring-event",
"/availability",
];
export function checkPostMethod(req: NextRequest) {
const pathname = req.nextUrl.pathname;
if (!POST_METHODS_ALLOWED_API_ROUTES.some((route) => pathname.startsWith(route)) && req.method === "POST") {
return new NextResponse(null, {
status: 405,
statusText: "Method Not Allowed",
headers: {
Allow: "GET",
},
});
}
return null;
}
// Vercel/Edge rejects nonASCII header values (see: https://github.com/vercel/next.js/issues/85631)
const isAscii = (s: string) => {
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) return false;
return true;
};
const stripNonAscii = (s: string) => {
let out = "";
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) <= 0x7f) out += s[i];
return out;
};
const sanitizeRequestHeaders = (headers: Iterable<[string, string]>): Headers => {
const out = new Headers();
for (const [name, raw] of Array.from(headers)) {
if (!isAscii(name)) continue;
let value = raw;
if (!isAscii(value)) {
// Heuristic: if the string contains common mojibake markers (Ã: 0xC3, Â: 0xC2),
// prefer a simple strip (avoids introducing spurious ASCII letters like 'A').
let hasMojibakeMarker = false;
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if (code === 0xc3 || code === 0xc2) {
hasMojibakeMarker = true;
break;
}
}
if (hasMojibakeMarker) {
value = stripNonAscii(value);
} else {
try {
value = stripNonAscii(value.normalize("NFKD"));
} catch {
value = stripNonAscii(value);
}
}
}
if (value) out.set(name, value);
}
return out;
};
const isPagePathRequest = (url: URL) => {
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
const isFile = /\..*$/;
const { pathname } = url;
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
};
const shouldEnforceCsp = (url: URL) => {
return url.pathname.startsWith("/auth/login") || url.pathname.startsWith("/login");
};
const proxy = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const postCheckResult = checkPostMethod(req);
if (postCheckResult) return postCheckResult;
const url = req.nextUrl;
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
const requestHeaders = new Headers(reqWithEnrichedHeaders.headers);
if (url.pathname.startsWith("/api/auth/signup")) {
const isSignupDisabled = await safeGet<boolean>("isSignupDisabled");
// If is in maintenance mode, point the url pathname to the maintenance page
if (isSignupDisabled) {
// TODO: Consider using responseWithHeaders here
return NextResponse.json({ error: "Signup is disabled" }, { status: 503 });
}
}
if (url.pathname.startsWith("/apps/installed")) {
const returnTo = reqWithEnrichedHeaders.cookies.get("return-to");
if (returnTo?.value) {
const response = NextResponse.redirect(new URL(returnTo.value, reqWithEnrichedHeaders.url), {
headers: requestHeaders,
});
response.cookies.delete("return-to");
return response;
}
}
const res = NextResponse.next({
request: {
headers: sanitizeRequestHeaders(requestHeaders),
},
});
if (url.pathname.startsWith("/auth/logout")) {
res.cookies.delete("next-auth.session-token");
}
return responseWithHeaders({ url, res, req: reqWithEnrichedHeaders });
};
const embeds = {
addResponseHeaders: ({ url, res }: { url: URL; res: NextResponse }) => {
if (!url.pathname.endsWith("/embed")) {
return res;
}
const isCOEPEnabled = url.searchParams.get("flag.coep") === "true";
if (isCOEPEnabled) {
res.headers.set("Cross-Origin-Embedder-Policy", "require-corp");
}
const embedColorScheme = url.searchParams.get("ui.color-scheme");
if (embedColorScheme) {
res.headers.set("x-embedColorScheme", embedColorScheme);
}
res.headers.set("x-isEmbed", "true");
return res;
},
};
const contentSecurityPolicy = {
addResponseHeaders: ({ res, req }: { res: NextResponse; req: NextRequest }) => {
const nonce = req.headers.get("x-csp-nonce");
if (!nonce) {
res.headers.set("x-csp-status", "not-opted-in");
return res;
}
const cspHeader = getCspHeader({ shouldEnforceCsp: shouldEnforceCsp(req.nextUrl), nonce });
if (cspHeader) {
res.headers.set(cspHeader.name, cspHeader.value);
}
return res;
},
addRequestHeaders: ({ req }: { req: NextRequest }) => {
if (!process.env.CSP_POLICY) {
return req;
}
const isCspApplicable = isPagePathRequest(req.nextUrl);
if (!isCspApplicable) {
return req;
}
const nonce = getCspNonce();
req.headers.set("x-csp-nonce", nonce);
return req;
},
};
function responseWithHeaders({ url, res, req }: { url: URL; res: NextResponse; req: NextRequest }) {
const resWithCSP = contentSecurityPolicy.addResponseHeaders({ res, req });
const resWithEmbeds = embeds.addResponseHeaders({ url, res: resWithCSP });
return resWithEmbeds;
}
function enrichRequestWithHeaders({ req }: { req: NextRequest }) {
const reqWithCSP = contentSecurityPolicy.addRequestHeaders({ req });
return reqWithCSP;
}
export const config = {
matcher: [
"/auth/login",
"/login",
"/apps/installed",
"/auth/logout",
"/:path*/embed",
"/api/auth/forgot-password",
"/api/auth/oauth/me",
"/api/auth/oauth/refreshToken",
"/api/auth/oauth/token",
"/api/auth/reset-password",
"/api/auth/saml/callback",
"/api/auth/saml/token",
"/api/auth/setup",
"/api/auth/signup",
"/api/auth/two-factor/totp/disable",
"/api/auth/two-factor/totp/enable",
"/api/auth/two-factor/totp/setup",
"/api/auth/session",
"/api/availability/calendar",
"/api/cancel",
"/api/cron/:path*",
"/api/get-inbound-dynamic-variables",
"/api/integrations/:path*",
"/api/recorded-daily-video",
"/api/router",
"/api/routing-forms/queued-response",
"/api/scim/v2.0/:path*",
"/api/support/conversation",
"/api/sync/helpscout",
"/api/twilio/webhook",
"/api/username",
"/api/verify-booking-token",
"/api/video/guest-session",
"/api/webhook/app-credential",
"/api/webhooks/calendar-subscription/:path*",
"/api/webhooks/retell-ai",
"/api/workflows/sms/user-response",
"/api/trpc/:path*",
"/api/auth/callback/:path*",
"/api/book/event",
"/api/book/instant-event",
"/api/book/recurring-event",
"/availability",
],
};
export default proxy;