import { clientSideApiEndpointsLimiter, forgotPasswordLimiter, loginLimiter, shareUrlLimiter, signupLimiter, syncUserIdentificationLimiter, verifyEmailLimiter, } from "@/app/middleware/bucket"; import { isAuthProtectedRoute, isClientSideApiRoute, isForgotPasswordRoute, isLoginRoute, isManagementApiRoute, isShareUrlRoute, isSignupRoute, isSyncWithUserIdentificationEndpoint, isVerifyEmailRoute, } from "@/app/middleware/endpoint-validator"; import { E2E_TESTING, IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants"; import { isValidCallbackUrl } from "@/lib/utils/url"; import { logApiError } from "@/modules/api/v2/lib/utils"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { ipAddress } from "@vercel/functions"; import { getToken } from "next-auth/jwt"; import { NextRequest, NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { logger } from "@formbricks/logger"; const enforceHttps = (request: NextRequest): Response | null => { const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http"; if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") { const apiError: ApiErrorResponseV2 = { type: "forbidden", details: [ { field: "", issue: "Only HTTPS connections are allowed on the management endpoints.", }, ], }; logApiError(request, apiError); return NextResponse.json(apiError, { status: 403 }); } return null; }; const handleAuth = async (request: NextRequest): Promise => { const token = await getToken({ req: request as any }); if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) { const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`; return NextResponse.redirect(loginUrl); } const callbackUrl = request.nextUrl.searchParams.get("callbackUrl"); if (callbackUrl && !isValidCallbackUrl(callbackUrl, WEBAPP_URL)) { return NextResponse.json({ error: "Invalid callback URL" }, { status: 400 }); } if (token && callbackUrl) { return NextResponse.redirect(callbackUrl); } return null; }; const applyRateLimiting = (request: NextRequest, ip: string) => { if (isLoginRoute(request.nextUrl.pathname)) { loginLimiter(`login-${ip}`); } else if (isSignupRoute(request.nextUrl.pathname)) { signupLimiter(`signup-${ip}`); } else if (isVerifyEmailRoute(request.nextUrl.pathname)) { verifyEmailLimiter(`verify-email-${ip}`); } else if (isForgotPasswordRoute(request.nextUrl.pathname)) { forgotPasswordLimiter(`forgot-password-${ip}`); } else if (isClientSideApiRoute(request.nextUrl.pathname)) { clientSideApiEndpointsLimiter(`client-side-api-${ip}`); const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname); if (envIdAndUserId) { const { environmentId, userId } = envIdAndUserId; syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`); } } else if (isShareUrlRoute(request.nextUrl.pathname)) { shareUrlLimiter(`share-${ip}`); } }; const handleSurveyDomain = (request: NextRequest): Response | null => { try { if (!SURVEY_URL) return null; const host = request.headers.get("host") || ""; const surveyDomain = SURVEY_URL ? new URL(SURVEY_URL).host : ""; if (host !== surveyDomain) return null; return new NextResponse(null, { status: 404 }); } catch (error) { logger.error(error, "Error handling survey domain"); return new NextResponse(null, { status: 404 }); } }; const isSurveyRoute = (request: NextRequest) => { return request.nextUrl.pathname.startsWith("/c/") || request.nextUrl.pathname.startsWith("/s/"); }; export const middleware = async (originalRequest: NextRequest) => { if (isSurveyRoute(originalRequest)) { return NextResponse.next(); } // Handle survey domain routing. const surveyResponse = handleSurveyDomain(originalRequest); if (surveyResponse) return surveyResponse; // Create a new Request object to override headers and add a unique request ID header const request = new NextRequest(originalRequest, { headers: new Headers(originalRequest.headers), }); request.headers.set("x-request-id", uuidv4()); request.headers.set("x-start-time", Date.now().toString()); // Create a new NextResponse object to forward the new request with headers const nextResponseWithCustomHeader = NextResponse.next({ request: { headers: request.headers, }, }); // Enforce HTTPS for management endpoints if (isManagementApiRoute(request.nextUrl.pathname)) { const httpsResponse = enforceHttps(request); if (httpsResponse) return httpsResponse; } // Handle authentication const authResponse = await handleAuth(request); if (authResponse) return authResponse; if (!IS_PRODUCTION || RATE_LIMITING_DISABLED) { return nextResponseWithCustomHeader; } let ip = request.headers.get("cf-connecting-ip") || request.headers.get("x-forwarded-for")?.split(",")[0].trim() || ipAddress(request); if (ip) { try { applyRateLimiting(request, ip); return nextResponseWithCustomHeader; } catch (e) { const apiError: ApiErrorResponseV2 = { type: "too_many_requests", details: [{ field: "", issue: "Too many requests. Please try again later." }], }; logApiError(request, apiError); return NextResponse.json(apiError, { status: 429 }); } } return nextResponseWithCustomHeader; }; export const config = { matcher: [ "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|js|css|images|fonts|icons|public|api/v1/og).*)", // Exclude the Open Graph image generation route from middleware ], };