diff --git a/packages/ui/docs-bundle/src/middleware.ts b/packages/ui/docs-bundle/src/middleware.ts index 6951a0bed2..7c1e324f95 100644 --- a/packages/ui/docs-bundle/src/middleware.ts +++ b/packages/ui/docs-bundle/src/middleware.ts @@ -99,6 +99,7 @@ export const middleware: NextMiddleware = async (request) => { if (!withBasicTokenPublic(authConfig, pathname)) { const destination = new URL(authConfig.redirect); destination.searchParams.set("state", urlJoin(withDefaultProtocol(xFernHost), pathname)); + // TODO: validate allowlist of domains to prevent open redirects return NextResponse.redirect(destination); } } diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts index 057926d662..eb0606f299 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts @@ -1,5 +1,6 @@ import { signFernJWT } from "@/server/auth/FernJWT"; import { withSecureCookie } from "@/server/auth/withSecure"; +import { safeUrl } from "@/server/safeUrl"; import { getWorkOS, getWorkOSClientId } from "@/server/workos"; import { getXFernHostEdge, getXFernHostHeaderFallbackOrigin } from "@/server/xfernhost/edge"; import { withDefaultProtocol } from "@fern-api/ui-core-utils"; @@ -13,6 +14,7 @@ export const runtime = "edge"; function redirectWithLoginError(location: string, errorMessage: string): NextResponse { const url = new URL(location); url.searchParams.set("loginError", errorMessage); + // TODO: validate allowlist of domains to prevent open redirects return NextResponse.redirect(url.toString()); } @@ -27,7 +29,7 @@ export default async function GET(req: NextRequest): Promise { const state = req.nextUrl.searchParams.get("state"); const error = req.nextUrl.searchParams.get("error"); const error_description = req.nextUrl.searchParams.get("error_description"); - const redirectLocation = state ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)); + const redirectLocation = safeUrl(state) ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)); if (error != null) { return redirectWithLoginError(redirectLocation, error_description ?? error); @@ -53,6 +55,7 @@ export default async function GET(req: NextRequest): Promise { nextUrl.host = req.headers.get(HEADER_X_FERN_HOST)!; } + // TODO: validate allowlist of domains to prevent open redirects return NextResponse.redirect(nextUrl); } @@ -74,6 +77,7 @@ export default async function GET(req: NextRequest): Promise { const token = await signFernJWT(fernUser, user); + // TODO: validate allowlist of domains to prevent open redirects const res = NextResponse.redirect(redirectLocation); res.cookies.set(COOKIE_FERN_TOKEN, token, withSecureCookie()); return res; diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/jwt/callback.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/jwt/callback.ts index c854db1130..0b536d2a0c 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/jwt/callback.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/jwt/callback.ts @@ -1,5 +1,6 @@ import { verifyFernJWTConfig } from "@/server/auth/FernJWT"; import { withSecureCookie } from "@/server/auth/withSecure"; +import { safeUrl } from "@/server/safeUrl"; import { getXFernHostEdge, getXFernHostHeaderFallbackOrigin } from "@/server/xfernhost/edge"; import { withDefaultProtocol } from "@fern-api/ui-core-utils"; import { getAuthEdgeConfig } from "@fern-ui/fern-docs-edge-config"; @@ -11,6 +12,7 @@ export const runtime = "edge"; function redirectWithLoginError(location: string, errorMessage: string): NextResponse { const url = new URL(location); url.searchParams.set("loginError", errorMessage); + // TODO: validate allowlist of domains to prevent open redirects return NextResponse.redirect(url.toString()); } @@ -25,7 +27,7 @@ export default async function handler(req: NextRequest): Promise { // since we expect the callback to be redirected to, the token will be in the query params const token = req.nextUrl.searchParams.get(COOKIE_FERN_TOKEN); const state = req.nextUrl.searchParams.get("state"); - const redirectLocation = state ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)); + const redirectLocation = safeUrl(state) ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)); if (edgeConfig?.type !== "basic_token_verification" || token == null) { // eslint-disable-next-line no-console @@ -36,6 +38,7 @@ export default async function handler(req: NextRequest): Promise { try { await verifyFernJWTConfig(token, edgeConfig); + // TODO: validate allowlist of domains to prevent open redirects const res = NextResponse.redirect(redirectLocation); res.cookies.set(COOKIE_FERN_TOKEN, token, withSecureCookie()); return res; diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/logout.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/logout.ts index 44123dbcd4..69a5347d48 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/logout.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/logout.ts @@ -1,3 +1,4 @@ +import { safeUrl } from "@/server/safeUrl"; import { getXFernHostEdge, getXFernHostHeaderFallbackOrigin } from "@/server/xfernhost/edge"; import { withDefaultProtocol } from "@fern-api/ui-core-utils"; import { getAuthEdgeConfig } from "@fern-ui/fern-docs-edge-config"; @@ -14,7 +15,8 @@ export default async function GET(req: NextRequest): Promise { const state = req.nextUrl.searchParams.get("state"); - const redirectLocation = logoutUrl ?? state ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)); + const redirectLocation = + safeUrl(logoutUrl) ?? safeUrl(state) ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)); const res = NextResponse.redirect(redirectLocation); res.cookies.delete(COOKIE_FERN_TOKEN); diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/integrations/launchdarkly/identify.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/integrations/launchdarkly/identify.ts index 987185993f..8456112ace 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/integrations/launchdarkly/identify.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/integrations/launchdarkly/identify.ts @@ -8,6 +8,7 @@ export const runtime = "edge"; export default async function handler(req: NextRequest): Promise { const email = req.nextUrl.searchParams.get(COOKIE_EMAIL); + // TODO: validate allowlist of domains to prevent open redirects const res = NextResponse.redirect(withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req))); if (email) { diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/ory/callback.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/ory/callback.ts index 39175d2527..319a4284b7 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/ory/callback.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/ory/callback.ts @@ -1,6 +1,7 @@ import { signFernJWT } from "@/server/auth/FernJWT"; import { OAuth2Client } from "@/server/auth/OAuth2Client"; import { withSecureCookie } from "@/server/auth/withSecure"; +import { safeUrl } from "@/server/safeUrl"; import { getXFernHostEdge, getXFernHostHeaderFallbackOrigin } from "@/server/xfernhost/edge"; import { withDefaultProtocol } from "@fern-api/ui-core-utils"; import { FernUser, OryAccessTokenSchema } from "@fern-ui/fern-docs-auth"; @@ -13,6 +14,7 @@ export const runtime = "edge"; function redirectWithLoginError(location: string, errorMessage: string): NextResponse { const url = new URL(location); url.searchParams.set("loginError", errorMessage); + // TODO: validate allowlist of domains to prevent open redirects return NextResponse.redirect(url.toString()); } @@ -27,7 +29,7 @@ export default async function GET(req: NextRequest): Promise { const state = req.nextUrl.searchParams.get("state"); const error = req.nextUrl.searchParams.get("error"); const error_description = req.nextUrl.searchParams.get("error_description"); - const redirectLocation = state ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)); + const redirectLocation = safeUrl(state) ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)); if (error != null) { // eslint-disable-next-line no-console @@ -60,6 +62,7 @@ export default async function GET(req: NextRequest): Promise { email: token.ext?.email, }; const expires = token.exp == null ? undefined : new Date(token.exp * 1000); + // TODO: validate allowlist of domains to prevent open redirects const res = NextResponse.redirect(redirectLocation); res.cookies.set(COOKIE_FERN_TOKEN, await signFernJWT(fernUser), withSecureCookie({ expires })); res.cookies.set(COOKIE_ACCESS_TOKEN, access_token, withSecureCookie({ expires })); diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/webflow/callback.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/webflow/callback.ts index 32e208e327..ffc1c100f3 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/webflow/callback.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/webflow/callback.ts @@ -1,4 +1,5 @@ import { withSecureCookie } from "@/server/auth/withSecure"; +import { safeUrl } from "@/server/safeUrl"; import { getXFernHostEdge, getXFernHostHeaderFallbackOrigin } from "@/server/xfernhost/edge"; import { withDefaultProtocol } from "@fern-api/ui-core-utils"; import { getAuthEdgeConfig } from "@fern-ui/fern-docs-edge-config"; @@ -10,6 +11,7 @@ export const runtime = "edge"; function redirectWithLoginError(location: string, errorMessage: string): NextResponse { const url = new URL(location); url.searchParams.set("loginError", errorMessage); + // TODO: validate allowlist of domains to prevent open redirects return NextResponse.redirect(url.toString()); } @@ -24,7 +26,7 @@ export default async function GET(req: NextRequest): Promise { const state = req.nextUrl.searchParams.get("state"); const error = req.nextUrl.searchParams.get("error"); const error_description = req.nextUrl.searchParams.get("error_description"); - const redirectLocation = state ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)); + const redirectLocation = safeUrl(state) ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)); if (error != null) { // eslint-disable-next-line no-console @@ -54,6 +56,7 @@ export default async function GET(req: NextRequest): Promise { code, }); + // TODO: validate allowlist of domains to prevent open redirects const res = NextResponse.redirect(redirectLocation); res.cookies.set("access_token", accessToken, withSecureCookie()); return res; diff --git a/packages/ui/docs-bundle/src/server/safeUrl.ts b/packages/ui/docs-bundle/src/server/safeUrl.ts new file mode 100644 index 0000000000..0ee68ad83f --- /dev/null +++ b/packages/ui/docs-bundle/src/server/safeUrl.ts @@ -0,0 +1,18 @@ +import { withDefaultProtocol } from "@fern-api/ui-core-utils"; + +export function safeUrl(url: string | null | undefined): string | undefined { + if (url == null) { + return undefined; + } + + url = withDefaultProtocol(url); + + try { + new URL(url); + return url; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return undefined; + } +}