From 8211c49375a56e4ddf90f58457ae50c1490d04f3 Mon Sep 17 00:00:00 2001 From: Niko Sams Date: Thu, 30 Jan 2025 09:09:37 +0100 Subject: [PATCH] Refactor single middleware function into multiple functions that get chained together (#671) --- site/next.config.mjs | 2 +- site/src/middleware.ts | 68 +++++-------------- site/src/middleware/adminRedirect.ts | 12 ++++ site/src/middleware/chain.ts | 17 +++++ .../contentSecurityPolicyHeaders.ts | 34 ++++++++++ site/src/middleware/damRewrite.ts | 12 ++++ site/src/middleware/domainRewrite.ts | 29 ++++++++ site/src/middleware/preview.ts | 16 +++++ site/src/middleware/redirectToMainHost.ts | 30 ++++++++ site/src/middleware/sitePreview.ts | 18 +++++ site/src/util/configureResponse.ts | 28 -------- 11 files changed, 187 insertions(+), 79 deletions(-) create mode 100644 site/src/middleware/adminRedirect.ts create mode 100644 site/src/middleware/chain.ts create mode 100644 site/src/middleware/contentSecurityPolicyHeaders.ts create mode 100644 site/src/middleware/damRewrite.ts create mode 100644 site/src/middleware/domainRewrite.ts create mode 100644 site/src/middleware/preview.ts create mode 100644 site/src/middleware/redirectToMainHost.ts create mode 100644 site/src/middleware/sitePreview.ts delete mode 100644 site/src/util/configureResponse.ts diff --git a/site/next.config.mjs b/site/next.config.mjs index 6f97dd4dc..636462e29 100644 --- a/site/next.config.mjs +++ b/site/next.config.mjs @@ -25,7 +25,7 @@ const nextConfig = { experimental: { optimizePackageImports: ["@comet/cms-site"], }, - // https://nextjs.org/docs/advanced-features/security-headers (Content-Security-Policy and CORS are set in middleware.ts) + // https://nextjs.org/docs/advanced-features/security-headers (Content-Security-Policy and CORS are set in middleware/cspHeaders.ts) headers: async () => [ { source: "/:path*", diff --git a/site/src/middleware.ts b/site/src/middleware.ts index 24a8fe32e..73dc1aa18 100644 --- a/site/src/middleware.ts +++ b/site/src/middleware.ts @@ -1,53 +1,21 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - -import { configureResponse } from "./util/configureResponse"; -import { getHostByHeaders, getSiteConfigForHost, getSiteConfigs } from "./util/siteConfig"; - -export async function middleware(request: NextRequest) { - const headers = request.headers; - const host = getHostByHeaders(headers); - const { pathname } = new URL(request.url); - - // Preview - if (request.nextUrl.pathname.startsWith("/block-preview/") || request.nextUrl.pathname === "/site-preview") { - return NextResponse.next({ request: { headers } }); - } - - const siteConfig = await getSiteConfigForHost(host); - if (!siteConfig) { - // Redirect to Main Host - const redirectSiteConfig = getSiteConfigs().find( - (siteConfig) => - siteConfig.domains.additional?.includes(host) || (siteConfig.domains.pattern && host.match(new RegExp(siteConfig.domains.pattern))), - ); - if (redirectSiteConfig) { - return NextResponse.redirect(redirectSiteConfig.url); - } - - throw new Error(`Cannot get siteConfig for host ${host}`); - } - - if (pathname.startsWith("/dam/")) { - return NextResponse.rewrite(new URL(`${process.env.API_URL_INTERNAL}${request.nextUrl.pathname}`)); - } - - if (request.nextUrl.pathname === "/admin" && process.env.ADMIN_URL) { - return NextResponse.redirect(new URL(process.env.ADMIN_URL)); - } - - return configureResponse( - NextResponse.rewrite( - new URL( - `/${siteConfig.scope.domain}${request.nextUrl.pathname}${ - request.nextUrl.searchParams.toString().length > 0 ? `?${request.nextUrl.searchParams.toString()}` : "" - }`, - request.url, - ), - { request: { headers } }, - ), - ); -} +import { withAdminRedirectMiddleware } from "./middleware/adminRedirect"; +import { chain } from "./middleware/chain"; +import { withContentSecurityPolicyHeadersMiddleware } from "./middleware/contentSecurityPolicyHeaders"; +import { withDamRewriteMiddleware } from "./middleware/damRewrite"; +import { withDomainRewriteMiddleware } from "./middleware/domainRewrite"; +import { withPreviewMiddleware } from "./middleware/preview"; +import { withRedirectToMainHostMiddleware } from "./middleware/redirectToMainHost"; +import { withSitePreviewMiddleware } from "./middleware/sitePreview"; + +export default chain([ + withSitePreviewMiddleware, + withRedirectToMainHostMiddleware, + withAdminRedirectMiddleware, + withDamRewriteMiddleware, + withContentSecurityPolicyHeadersMiddleware, // order matters: after redirects (that don't need csp headers), before everything else that needs csp headers + withPreviewMiddleware, + withDomainRewriteMiddleware, // must be last (rewrites all urls) +]); export const config = { matcher: [ diff --git a/site/src/middleware/adminRedirect.ts b/site/src/middleware/adminRedirect.ts new file mode 100644 index 000000000..6527a91e0 --- /dev/null +++ b/site/src/middleware/adminRedirect.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { CustomMiddleware } from "./chain"; + +export function withAdminRedirectMiddleware(middleware: CustomMiddleware) { + return async (request: NextRequest) => { + if (request.nextUrl.pathname === "/admin" && process.env.ADMIN_URL) { + return NextResponse.redirect(new URL(process.env.ADMIN_URL)); + } + return middleware(request); + }; +} diff --git a/site/src/middleware/chain.ts b/site/src/middleware/chain.ts new file mode 100644 index 000000000..63af9c021 --- /dev/null +++ b/site/src/middleware/chain.ts @@ -0,0 +1,17 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +export type CustomMiddleware = (request: NextRequest) => NextResponse | Response | Promise; + +type MiddlewareFactory = (middleware: CustomMiddleware) => CustomMiddleware; +// Utility function to chain multiple middlewares together +export function chain(functions: MiddlewareFactory[], index = 0): CustomMiddleware { + const current = functions[index]; + + if (current) { + const next = chain(functions, index + 1); + return current(next); + } + + return () => NextResponse.next(); +} diff --git a/site/src/middleware/contentSecurityPolicyHeaders.ts b/site/src/middleware/contentSecurityPolicyHeaders.ts new file mode 100644 index 000000000..4fd25dd30 --- /dev/null +++ b/site/src/middleware/contentSecurityPolicyHeaders.ts @@ -0,0 +1,34 @@ +import { NextRequest } from "next/server"; + +import { CustomMiddleware } from "./chain"; + +export function withContentSecurityPolicyHeadersMiddleware(middleware: CustomMiddleware) { + return async (request: NextRequest) => { + const response = await middleware(request); + response.headers.set( + "Content-Security-Policy", + ` + default-src 'self'; + form-action 'self'; + object-src 'none'; + img-src 'self' https: data:${process.env.NODE_ENV === "development" ? " http:" : ""}; + media-src 'self' https: data:${process.env.NODE_ENV === "development" ? " http:" : ""}; + style-src 'self' 'unsafe-inline'; + font-src 'self' https: data:; + script-src 'self' 'unsafe-inline' https:${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}; + connect-src 'self' https:${process.env.NODE_ENV === "development" ? " http:" : ""}; + frame-ancestors ${process.env.ADMIN_URL ?? "none"}; + upgrade-insecure-requests; + block-all-mixed-content; + frame-src 'self' https://*.youtube.com https://*.youtube-nocookie.com; + ` + .replace(/\s{2,}/g, " ") + .trim(), + ); + if (process.env.ADMIN_URL) { + response.headers.set("Access-Control-Allow-Origin", process.env.ADMIN_URL); + } + + return response; + }; +} diff --git a/site/src/middleware/damRewrite.ts b/site/src/middleware/damRewrite.ts new file mode 100644 index 000000000..3c895dac7 --- /dev/null +++ b/site/src/middleware/damRewrite.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { CustomMiddleware } from "./chain"; + +export function withDamRewriteMiddleware(middleware: CustomMiddleware) { + return async (request: NextRequest) => { + if (request.nextUrl.pathname.startsWith("/dam/")) { + return NextResponse.rewrite(new URL(`${process.env.API_URL_INTERNAL}${request.nextUrl.pathname}`)); + } + return middleware(request); + }; +} diff --git a/site/src/middleware/domainRewrite.ts b/site/src/middleware/domainRewrite.ts new file mode 100644 index 000000000..9b0ce4ae6 --- /dev/null +++ b/site/src/middleware/domainRewrite.ts @@ -0,0 +1,29 @@ +import { getHostByHeaders, getSiteConfigForHost } from "@src/util/siteConfig"; +import { NextRequest, NextResponse } from "next/server"; + +import { CustomMiddleware } from "./chain"; + +/** + * Rewrite request to include the matching domain (from http host) in the path, so the route can have [domain] as parameter. + * + * Doing this matching in the middleware makes it possible to keep static rendering, as the page doesn't need headers() to get the domain. + */ +export function withDomainRewriteMiddleware(middleware: CustomMiddleware) { + return async (request: NextRequest) => { + const headers = request.headers; + const host = getHostByHeaders(headers); + const siteConfig = await getSiteConfigForHost(host); + if (!siteConfig) { + throw new Error(`Cannot get siteConfig for host ${host}`); + } + return NextResponse.rewrite( + new URL( + `/${siteConfig.scope.domain}${request.nextUrl.pathname}${ + request.nextUrl.searchParams.toString().length > 0 ? `?${request.nextUrl.searchParams.toString()}` : "" + }`, + request.url, + ), + { request: { headers } }, + ); + }; +} diff --git a/site/src/middleware/preview.ts b/site/src/middleware/preview.ts new file mode 100644 index 000000000..46c6570bc --- /dev/null +++ b/site/src/middleware/preview.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { CustomMiddleware } from "./chain"; + +/** + * /block-preview/* and /site-preview must not use the domain rewrite, because we don't have a active domain. This middleware exists the chain in that case. + */ +export function withPreviewMiddleware(middleware: CustomMiddleware) { + return async (request: NextRequest) => { + if (request.nextUrl.pathname.startsWith("/block-preview/") || request.nextUrl.pathname === "/site-preview") { + // don't apply any other middlewares + return NextResponse.next(); + } + return middleware(request); + }; +} diff --git a/site/src/middleware/redirectToMainHost.ts b/site/src/middleware/redirectToMainHost.ts new file mode 100644 index 000000000..7feb090a5 --- /dev/null +++ b/site/src/middleware/redirectToMainHost.ts @@ -0,0 +1,30 @@ +import { getHostByHeaders, getSiteConfigForHost, getSiteConfigs } from "@src/util/siteConfig"; +import { NextRequest, NextResponse } from "next/server"; + +import { CustomMiddleware } from "./chain"; + +/** + * When http host isn't siteConfig.domains.main (instead .pattern or .additional match), redirect to main host. + */ +export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { + return async (request: NextRequest) => { + const headers = request.headers; + const host = getHostByHeaders(headers); + const siteConfig = await getSiteConfigForHost(host); + + if (!siteConfig) { + // Redirect to Main Host + const redirectSiteConfig = getSiteConfigs().find( + (siteConfig) => + siteConfig.domains.additional?.includes(host) || + (siteConfig.domains.pattern && host.match(new RegExp(siteConfig.domains.pattern))), + ); + if (redirectSiteConfig) { + return NextResponse.redirect(redirectSiteConfig.url); + } + + throw new Error(`Cannot get siteConfig for host ${host}`); + } + return middleware(request); + }; +} diff --git a/site/src/middleware/sitePreview.ts b/site/src/middleware/sitePreview.ts new file mode 100644 index 000000000..78604e991 --- /dev/null +++ b/site/src/middleware/sitePreview.ts @@ -0,0 +1,18 @@ +import { getHostByHeaders, getSiteConfigForHost } from "@src/util/siteConfig"; +import { NextRequest, NextResponse } from "next/server"; + +import { CustomMiddleware } from "./chain"; + +/** + * Verify when on site preview domain, a siteConfig exists (by scope set as cookie) + */ +export function withSitePreviewMiddleware(middleware: CustomMiddleware) { + return async (request: NextRequest) => { + const host = getHostByHeaders(request.headers); + const siteConfig = await getSiteConfigForHost(host); + if (!siteConfig && host === process.env.PREVIEW_DOMAIN) { + return NextResponse.json({ error: "Preview has to be called from within Comet Web preview" }, { status: 404 }); + } + return middleware(request); + }; +} diff --git a/site/src/util/configureResponse.ts b/site/src/util/configureResponse.ts deleted file mode 100644 index faeed7ebf..000000000 --- a/site/src/util/configureResponse.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextResponse } from "next/server"; - -export function configureResponse(response: NextResponse) { - response.headers.set( - "Content-Security-Policy", - ` - default-src 'self'; - form-action 'self'; - object-src 'none'; - img-src 'self' https: data:${process.env.NODE_ENV === "development" ? " http:" : ""}; - media-src 'self' https: data:${process.env.NODE_ENV === "development" ? " http:" : ""}; - style-src 'self' 'unsafe-inline'; - font-src 'self' https: data:; - script-src 'self' 'unsafe-inline' https:${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}; - connect-src 'self' https:${process.env.NODE_ENV === "development" ? " http:" : ""}; - frame-ancestors ${process.env.ADMIN_URL ?? "none"}; - upgrade-insecure-requests; - block-all-mixed-content; - frame-src 'self' https://*.youtube.com https://*.youtube-nocookie.com; - ` - .replace(/\s{2,}/g, " ") - .trim(), - ); - if (process.env.ADMIN_URL) { - response.headers.set("Access-Control-Allow-Origin", process.env.ADMIN_URL); - } - return response; -}