From 0699f34d5c4481c027c4d29d73944f79f97008df Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Wed, 7 Feb 2024 16:09:39 +0000 Subject: [PATCH] feat(vercel): middleware verification (#9987) * feat(vercel): verification for edge middleware * add changeset * Apply suggestions from code review --------- Co-authored-by: Nate Moore --- .changeset/slimy-zebras-march.md | 7 ++++ .../vercel/src/serverless/adapter.ts | 19 ++++++++--- .../vercel/src/serverless/entrypoint.ts | 32 +++++++++++++++---- .../vercel/src/serverless/middleware.ts | 17 +++++++--- 4 files changed, 60 insertions(+), 15 deletions(-) create mode 100644 .changeset/slimy-zebras-march.md diff --git a/.changeset/slimy-zebras-march.md b/.changeset/slimy-zebras-march.md new file mode 100644 index 0000000000..a3f0bcc65d --- /dev/null +++ b/.changeset/slimy-zebras-march.md @@ -0,0 +1,7 @@ +--- +"@astrojs/vercel": minor +--- + +Implements verification for edge middleware. This is a security measure to ensure that your serverless functions are only ever called by your edge middleware and not a third party. + +When `edgeMiddleware` is enabled, the serverless function will now respond with `403 Forbidden` for requests that are not verified to have come from the generated edge middleware. No user action is necessary. diff --git a/packages/integrations/vercel/src/serverless/adapter.ts b/packages/integrations/vercel/src/serverless/adapter.ts index 36924c508e..0281b62d4c 100644 --- a/packages/integrations/vercel/src/serverless/adapter.ts +++ b/packages/integrations/vercel/src/serverless/adapter.ts @@ -42,6 +42,7 @@ export const ASTRO_PATH_PARAM = 'x_astro_path'; * with the locals serialized into this header. */ export const ASTRO_LOCALS_HEADER = 'x-astro-locals'; +export const ASTRO_MIDDLEWARE_SECRET_HEADER = 'x-astro-middleware-secret'; export const VERCEL_EDGE_MIDDLEWARE_FILE = 'vercel-edge-middleware'; // Vercel routes the folder names to a path on the deployed website. @@ -67,14 +68,17 @@ const SUPPORTED_NODE_VERSIONS: Record< function getAdapter({ edgeMiddleware, functionPerRoute, + middlewareSecret, }: { edgeMiddleware: boolean; functionPerRoute: boolean; + middlewareSecret: string; }): AstroAdapter { return { name: PACKAGE_NAME, serverEntrypoint: `${PACKAGE_NAME}/entrypoint`, exports: ['default'], + args: { middlewareSecret }, adapterFeatures: { edgeMiddleware, functionPerRoute, @@ -190,6 +194,8 @@ export default function vercelServerless({ let _middlewareEntryPoint: URL | undefined; // Extra files to be merged with `includeFiles` during build const extraFilesToInclude: URL[] = []; + // Secret used to verify that the caller is the astro-generated edge middleware and not a third-party + const middlewareSecret = crypto.randomUUID(); return { name: PACKAGE_NAME, @@ -248,7 +254,7 @@ export default function vercelServerless({ ); } - setAdapter(getAdapter({ functionPerRoute, edgeMiddleware })); + setAdapter(getAdapter({ functionPerRoute, edgeMiddleware, middlewareSecret })); _config = config; _buildTempFolder = config.build.server; @@ -356,7 +362,11 @@ export default function vercelServerless({ } } if (_middlewareEntryPoint) { - await builder.buildMiddlewareFolder(_middlewareEntryPoint, MIDDLEWARE_PATH); + await builder.buildMiddlewareFolder( + _middlewareEntryPoint, + MIDDLEWARE_PATH, + middlewareSecret + ); } const fourOhFourRoute = routes.find((route) => route.pathname === '/404'); // Output configuration @@ -472,13 +482,14 @@ class VercelBuilder { }); } - async buildMiddlewareFolder(entry: URL, functionName: string) { + async buildMiddlewareFolder(entry: URL, functionName: string, middlewareSecret: string) { const functionFolder = new URL(`./functions/${functionName}.func/`, this.config.outDir); await generateEdgeMiddleware( entry, new URL(VERCEL_EDGE_MIDDLEWARE_FILE, this.config.srcDir), - new URL('./middleware.mjs', functionFolder) + new URL('./middleware.mjs', functionFolder), + middlewareSecret ); await writeJson(new URL(`./.vc-config.json`, functionFolder), { diff --git a/packages/integrations/vercel/src/serverless/entrypoint.ts b/packages/integrations/vercel/src/serverless/entrypoint.ts index a60f03d7ac..5dfba76976 100644 --- a/packages/integrations/vercel/src/serverless/entrypoint.ts +++ b/packages/integrations/vercel/src/serverless/entrypoint.ts @@ -1,26 +1,44 @@ import type { SSRManifest } from 'astro'; import { applyPolyfills, NodeApp } from 'astro/app/node'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import { ASTRO_PATH_HEADER, ASTRO_PATH_PARAM, ASTRO_LOCALS_HEADER } from './adapter.js'; +import { + ASTRO_PATH_HEADER, + ASTRO_PATH_PARAM, + ASTRO_LOCALS_HEADER, + ASTRO_MIDDLEWARE_SECRET_HEADER, +} from './adapter.js'; applyPolyfills(); -export const createExports = (manifest: SSRManifest) => { +export const createExports = ( + manifest: SSRManifest, + { middlewareSecret }: { middlewareSecret: string } +) => { const app = new NodeApp(manifest); const handler = async (req: IncomingMessage, res: ServerResponse) => { const url = new URL(`https://example.com${req.url}`); const clientAddress = req.headers['x-forwarded-for'] as string | undefined; const localsHeader = req.headers[ASTRO_LOCALS_HEADER]; + const middlewareSecretHeader = req.headers[ASTRO_MIDDLEWARE_SECRET_HEADER]; const realPath = req.headers[ASTRO_PATH_HEADER] ?? url.searchParams.get(ASTRO_PATH_PARAM); if (typeof realPath === 'string') { req.url = realPath; } - const locals = - typeof localsHeader === 'string' + + let locals = {}; + if (localsHeader) { + if (middlewareSecretHeader !== middlewareSecret) { + res.statusCode = 403; + res.end('Forbidden'); + return; + } + locals = typeof localsHeader === 'string' ? JSON.parse(localsHeader) - : Array.isArray(localsHeader) - ? JSON.parse(localsHeader[0]) - : {}; + : JSON.parse(localsHeader[0]); + } + // hide the secret from the rest of user code + delete req.headers[ASTRO_MIDDLEWARE_SECRET_HEADER]; + const webResponse = await app.render(req, { addCookieHeader: true, clientAddress, locals }); await NodeApp.writeResponse(webResponse, res); }; diff --git a/packages/integrations/vercel/src/serverless/middleware.ts b/packages/integrations/vercel/src/serverless/middleware.ts index 648bc0d68f..91d0328737 100644 --- a/packages/integrations/vercel/src/serverless/middleware.ts +++ b/packages/integrations/vercel/src/serverless/middleware.ts @@ -1,7 +1,12 @@ import { existsSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { builtinModules } from 'node:module'; -import { ASTRO_LOCALS_HEADER, ASTRO_PATH_HEADER, NODE_PATH } from './adapter.js'; +import { + ASTRO_MIDDLEWARE_SECRET_HEADER, + ASTRO_LOCALS_HEADER, + ASTRO_PATH_HEADER, + NODE_PATH, +} from './adapter.js'; /** * It generates the Vercel Edge Middleware file. @@ -17,11 +22,13 @@ import { ASTRO_LOCALS_HEADER, ASTRO_PATH_HEADER, NODE_PATH } from './adapter.js' export async function generateEdgeMiddleware( astroMiddlewareEntryPointPath: URL, vercelEdgeMiddlewareHandlerPath: URL, - outPath: URL + outPath: URL, + middlewareSecret: string ): Promise { const code = edgeMiddlewareTemplate( astroMiddlewareEntryPointPath, - vercelEdgeMiddlewareHandlerPath + vercelEdgeMiddlewareHandlerPath, + middlewareSecret ); // https://vercel.com/docs/concepts/functions/edge-middleware#create-edge-middleware const bundledFilePath = fileURLToPath(outPath); @@ -56,7 +63,8 @@ export async function generateEdgeMiddleware( function edgeMiddlewareTemplate( astroMiddlewareEntryPointPath: URL, - vercelEdgeMiddlewareHandlerPath: URL + vercelEdgeMiddlewareHandlerPath: URL, + middlewareSecret: string ) { const middlewarePath = JSON.stringify( fileURLToPath(astroMiddlewareEntryPointPath).replace(/\\/g, '/') @@ -85,6 +93,7 @@ export default async function middleware(request, context) { fetch(new URL('${NODE_PATH}', request.url), { headers: { ...Object.fromEntries(request.headers.entries()), + '${ASTRO_MIDDLEWARE_SECRET_HEADER}': '${middlewareSecret}', '${ASTRO_PATH_HEADER}': request.url.replace(origin, ''), '${ASTRO_LOCALS_HEADER}': trySerializeLocals(ctx.locals) }