diff --git a/packages/commons/core-utils/src/withDefaultProtocol.ts b/packages/commons/core-utils/src/withDefaultProtocol.ts index e48f3eab76..863d2949bd 100644 --- a/packages/commons/core-utils/src/withDefaultProtocol.ts +++ b/packages/commons/core-utils/src/withDefaultProtocol.ts @@ -10,6 +10,10 @@ export function withDefaultProtocol(endpoint: string, defaultProtocol = "https:/ const protocolRegex = /^[a-z]+:\/\//i; if (!protocolRegex.test(endpoint)) { + if (endpoint === "localhost" || endpoint.startsWith("localhost:")) { + return `http://${endpoint}`; + } + return `${defaultProtocol}${endpoint}`; } diff --git a/packages/ui/app/src/hooks/useStandardProxyEnvironment.ts b/packages/ui/app/src/hooks/useStandardProxyEnvironment.ts index 09e5dbe4d8..fc21cd9664 100644 --- a/packages/ui/app/src/hooks/useStandardProxyEnvironment.ts +++ b/packages/ui/app/src/hooks/useStandardProxyEnvironment.ts @@ -1,3 +1,4 @@ +import { withDefaultProtocol } from "@fern-api/ui-core-utils"; import { once } from "lodash-es"; import { useBasePath, useFeatureFlags } from "../atoms"; import { useApiRoute } from "./useApiRoute"; @@ -12,10 +13,10 @@ export const getAppBuildwithfernCom = once((): string => { // see: https://vercel.com/docs/projects/environment-variables/system-environment-variables#framework-environment-variables if (process.env.NEXT_PUBLIC_VERCEL_ENV === "preview" || process.env.NEXT_PUBLIC_VERCEL_ENV === "development") { // this mimics the behavior of hitting app.buildwithfern.com in a preview environment - return `https://${process.env.NEXT_PUBLIC_VERCEL_URL ?? APP_BUILDWITHFERN_COM}`; + return withDefaultProtocol(process.env.NEXT_PUBLIC_VERCEL_URL ?? APP_BUILDWITHFERN_COM); } - return `https://${APP_BUILDWITHFERN_COM}`; + return withDefaultProtocol(APP_BUILDWITHFERN_COM); }); export function useStandardProxyEnvironment(): string { diff --git a/packages/ui/app/src/seo/getBreadcrumbList.ts b/packages/ui/app/src/seo/getBreadcrumbList.ts index 566dae61a5..319d59a21e 100644 --- a/packages/ui/app/src/seo/getBreadcrumbList.ts +++ b/packages/ui/app/src/seo/getBreadcrumbList.ts @@ -1,12 +1,13 @@ import type { DocsV1Read } from "@fern-api/fdr-sdk/client/types"; import type * as FernDocs from "@fern-api/fdr-sdk/docs"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; +import { withDefaultProtocol } from "@fern-api/ui-core-utils"; import { JsonLd } from "@fern-ui/next-seo"; import urljoin from "url-join"; import { getFrontmatter } from "../mdx/frontmatter"; function toUrl(domain: string, slug: FernNavigation.Slug): string { - return urljoin(`https://${domain}`, slug); + return urljoin(withDefaultProtocol(domain), slug); } export function getBreadcrumbList( diff --git a/packages/ui/docs-bundle/src/middleware.ts b/packages/ui/docs-bundle/src/middleware.ts index aacebab031..7c1e324f95 100644 --- a/packages/ui/docs-bundle/src/middleware.ts +++ b/packages/ui/docs-bundle/src/middleware.ts @@ -2,6 +2,7 @@ import { extractBuildId, extractNextDataPathname } from "@/server/extractNextDat import { getPageRoute, getPageRouteMatch, getPageRoutePath } from "@/server/pageRoutes"; import { rewritePosthog } from "@/server/rewritePosthog"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; +import { withDefaultProtocol } from "@fern-api/ui-core-utils"; import type { FernUser } from "@fern-ui/fern-docs-auth"; import { getAuthEdgeConfig } from "@fern-ui/fern-docs-edge-config"; import { COOKIE_FERN_TOKEN } from "@fern-ui/fern-docs-utils"; @@ -97,7 +98,8 @@ export const middleware: NextMiddleware = async (request) => { if (!isLoggedIn && authConfig?.type === "basic_token_verification") { if (!withBasicTokenPublic(authConfig, pathname)) { const destination = new URL(authConfig.redirect); - destination.searchParams.set("state", urlJoin(`https://${xFernHost}`, pathname)); + 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 1219ecc204..0de1eed4c7 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,7 +1,9 @@ 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 } from "@/server/xfernhost/edge"; +import { getXFernHostEdge, getXFernHostHeaderFallbackOrigin } from "@/server/xfernhost/edge"; +import { withDefaultProtocol } from "@fern-api/ui-core-utils"; import { FernUser } from "@fern-ui/fern-docs-auth"; import { getAuthEdgeConfig } from "@fern-ui/fern-docs-edge-config"; import { COOKIE_FERN_TOKEN, HEADER_X_FERN_HOST } from "@fern-ui/fern-docs-utils"; @@ -12,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()); } @@ -26,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 ?? `https://${domain}/`; + const redirectLocation = safeUrl(state) ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)); if (error != null) { return redirectWithLoginError(redirectLocation, error_description ?? error); @@ -52,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); } @@ -71,6 +75,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 0ec2ba6c46..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,6 +1,8 @@ import { verifyFernJWTConfig } from "@/server/auth/FernJWT"; import { withSecureCookie } from "@/server/auth/withSecure"; -import { getXFernHostEdge } from "@/server/xfernhost/edge"; +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"; import { COOKIE_FERN_TOKEN } from "@fern-ui/fern-docs-utils"; import { NextRequest, NextResponse } from "next/server"; @@ -10,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()); } @@ -24,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 ?? `https://${domain}/`; + const redirectLocation = safeUrl(state) ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)); if (edgeConfig?.type !== "basic_token_verification" || token == null) { // eslint-disable-next-line no-console @@ -35,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 3929a08d57..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,4 +1,7 @@ -import { getXFernHostEdge } from "@/server/xfernhost/edge"; +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"; import { COOKIE_ACCESS_TOKEN, COOKIE_FERN_TOKEN, COOKIE_REFRESH_TOKEN } from "@fern-ui/fern-docs-utils"; import { NextRequest, NextResponse } from "next/server"; @@ -7,8 +10,13 @@ export const runtime = "edge"; export default async function GET(req: NextRequest): Promise { const domain = getXFernHostEdge(req); + const authConfig = await getAuthEdgeConfig(domain); + const logoutUrl = authConfig?.type === "basic_token_verification" ? authConfig.logout : undefined; + const state = req.nextUrl.searchParams.get("state"); - const redirectLocation = state ?? `https://${domain}/`; + + 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/changelog.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/changelog.ts index 2c02e3655c..7fd7025dc9 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/changelog.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/changelog.ts @@ -3,11 +3,12 @@ import { getXFernHostNode } from "@/server/xfernhost/node"; import type { DocsV1Read } from "@fern-api/fdr-sdk/client/types"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { NodeCollector } from "@fern-api/fdr-sdk/navigation"; -import { assertNever } from "@fern-api/ui-core-utils"; +import { assertNever, withDefaultProtocol } from "@fern-api/ui-core-utils"; import { COOKIE_FERN_TOKEN } from "@fern-ui/fern-docs-utils"; import { getFrontmatter } from "@fern-ui/ui"; import { Feed, Item } from "feed"; import { NextApiRequest, NextApiResponse } from "next"; +import urlJoin from "url-join"; export const revalidate = 60 * 60 * 24; @@ -43,7 +44,7 @@ export default async function responseApiHandler(req: NextApiRequest, res: NextA return res.status(404).end(); } - const link = `https://${xFernHost}/${node.slug}`; + const link = urlJoin(withDefaultProtocol(xFernHost), node.slug); const feed = new Feed({ id: link, @@ -92,7 +93,7 @@ function toFeedItem( ): Item { const item: Item = { title: entry.title, - link: `https://${xFernHost}/${entry.slug}`, + link: urlJoin(withDefaultProtocol(xFernHost), entry.slug), date: new Date(entry.date), }; 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 75f4bef80f..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 @@ -1,3 +1,5 @@ +import { getXFernHostHeaderFallbackOrigin } from "@/server/xfernhost/edge"; +import { withDefaultProtocol } from "@fern-api/ui-core-utils"; import { COOKIE_EMAIL } from "@fern-ui/fern-docs-utils"; import { NextRequest, NextResponse } from "next/server"; @@ -6,7 +8,8 @@ export const runtime = "edge"; export default async function handler(req: NextRequest): Promise { const email = req.nextUrl.searchParams.get(COOKIE_EMAIL); - const res = NextResponse.redirect(new URL("/", req.url)); + // TODO: validate allowlist of domains to prevent open redirects + const res = NextResponse.redirect(withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req))); if (email) { res.cookies.set({ name: COOKIE_EMAIL, value: 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 ce21028fd3..1fdc7e70ed 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,7 +1,9 @@ import { signFernJWT } from "@/server/auth/FernJWT"; import { OAuth2Client } from "@/server/auth/OAuth2Client"; import { withSecureCookie } from "@/server/auth/withSecure"; -import { getXFernHostEdge } from "@/server/xfernhost/edge"; +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"; import { getAuthEdgeConfig } from "@fern-ui/fern-docs-edge-config"; import { COOKIE_ACCESS_TOKEN, COOKIE_FERN_TOKEN, COOKIE_REFRESH_TOKEN } from "@fern-ui/fern-docs-utils"; @@ -12,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()); } @@ -26,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 ?? `https://${domain}/`; + const redirectLocation = safeUrl(state) ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)); if (error != null) { // eslint-disable-next-line no-console @@ -57,6 +60,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 38ee78a40f..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,5 +1,7 @@ import { withSecureCookie } from "@/server/auth/withSecure"; -import { getXFernHostEdge } from "@/server/xfernhost/edge"; +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"; import { NextRequest, NextResponse } from "next/server"; import { WebflowClient } from "webflow-api"; @@ -9,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()); } @@ -23,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 ?? `https://${domain}/`; + const redirectLocation = safeUrl(state) ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)); if (error != null) { // eslint-disable-next-line no-console @@ -53,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/pages/api/fern-docs/robots.txt.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/robots.txt.ts index 8bc7dde788..9efaabf7f2 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/robots.txt.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/robots.txt.ts @@ -1,4 +1,5 @@ import { getXFernHostEdge } from "@/server/xfernhost/edge"; +import { withDefaultProtocol } from "@fern-api/ui-core-utils"; import { getSeoDisabled } from "@fern-ui/fern-docs-edge-config"; import { NextRequest, NextResponse } from "next/server"; import urlJoin from "url-join"; @@ -8,7 +9,7 @@ export const runtime = "edge"; export default async function GET(req: NextRequest): Promise { const xFernHost = getXFernHostEdge(req); const basePath = req.nextUrl.pathname.split("/robots.txt")[0] || ""; - const sitemap = urlJoin(`https://${xFernHost}`, basePath, "/sitemap.xml"); + const sitemap = urlJoin(withDefaultProtocol(xFernHost), basePath, "/sitemap.xml"); if (await getSeoDisabled(xFernHost)) { return new NextResponse(`User-Agent: *\nDisallow: /\nSitemap: ${sitemap}`, { status: 200 }); diff --git a/packages/ui/docs-bundle/src/server/getUnauthenticatedRedirect.ts b/packages/ui/docs-bundle/src/server/getUnauthenticatedRedirect.ts index f8cf5c8920..ac7070f3cb 100644 --- a/packages/ui/docs-bundle/src/server/getUnauthenticatedRedirect.ts +++ b/packages/ui/docs-bundle/src/server/getUnauthenticatedRedirect.ts @@ -1,4 +1,5 @@ import { FdrAPI } from "@fern-api/fdr-sdk"; +import { withDefaultProtocol } from "@fern-api/ui-core-utils"; import { FernVenusApi, FernVenusApiClient } from "@fern-api/venus-api-sdk"; import { provideRegistryService } from "@fern-ui/ui"; import type { Redirect } from "next/types"; @@ -9,7 +10,7 @@ export async function getUnauthenticatedRedirect(xFernHost: string, path: string const authorizationUrl = getAuthorizationUrl( { organization: await maybeGetWorkosOrganization(xFernHost), - state: urlJoin(`https://${xFernHost}`, path), + state: urlJoin(withDefaultProtocol(xFernHost), path), }, xFernHost, ); 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; + } +} diff --git a/packages/ui/docs-bundle/src/server/xfernhost/edge.ts b/packages/ui/docs-bundle/src/server/xfernhost/edge.ts index 56177fbad5..d730465013 100644 --- a/packages/ui/docs-bundle/src/server/xfernhost/edge.ts +++ b/packages/ui/docs-bundle/src/server/xfernhost/edge.ts @@ -24,3 +24,15 @@ export function getXFernHostEdge(req: NextRequest, useSearchParams = false): str throw new Error("Could not determine xFernHost from request."); } + +// use this for testing auth-based redirects on development and preview environments +export function getXFernHostHeaderFallbackOrigin(req: NextRequest): string { + if ( + process.env.NODE_ENV === "development" || + process.env.VERCEL_ENV === "preview" || + process.env.VERCEL_ENV === "development" + ) { + return req.nextUrl.host; + } + return cleanHost(req.headers.get(HEADER_X_FERN_HOST)) ?? req.nextUrl.host; +} diff --git a/packages/ui/fern-docs-auth/src/types.ts b/packages/ui/fern-docs-auth/src/types.ts index 214a66640e..1b5781eb47 100644 --- a/packages/ui/fern-docs-auth/src/types.ts +++ b/packages/ui/fern-docs-auth/src/types.ts @@ -32,6 +32,7 @@ export const AuthEdgeConfigBasicTokenVerificationSchema = z.object({ secret: z.string(), issuer: z.string(), redirect: z.string(), + logout: z.string().optional(), allowlist: z .array(z.string(), {