Skip to content

Commit

Permalink
feat(vercel): middleware verification (#9987)
Browse files Browse the repository at this point in the history
* feat(vercel): verification for edge middleware

* add changeset

* Apply suggestions from code review

---------

Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
  • Loading branch information
lilnasy and natemoo-re authored Feb 7, 2024
1 parent 9ef7917 commit 0699f34
Show file tree
Hide file tree
Showing 4 changed files with 60 additions and 15 deletions.
7 changes: 7 additions & 0 deletions .changeset/slimy-zebras-march.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 15 additions & 4 deletions packages/integrations/vercel/src/serverless/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -248,7 +254,7 @@ export default function vercelServerless({
);
}

setAdapter(getAdapter({ functionPerRoute, edgeMiddleware }));
setAdapter(getAdapter({ functionPerRoute, edgeMiddleware, middlewareSecret }));

_config = config;
_buildTempFolder = config.build.server;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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), {
Expand Down
32 changes: 25 additions & 7 deletions packages/integrations/vercel/src/serverless/entrypoint.ts
Original file line number Diff line number Diff line change
@@ -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);
};
Expand Down
17 changes: 13 additions & 4 deletions packages/integrations/vercel/src/serverless/middleware.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<URL> {
const code = edgeMiddlewareTemplate(
astroMiddlewareEntryPointPath,
vercelEdgeMiddlewareHandlerPath
vercelEdgeMiddlewareHandlerPath,
middlewareSecret
);
// https://vercel.com/docs/concepts/functions/edge-middleware#create-edge-middleware
const bundledFilePath = fileURLToPath(outPath);
Expand Down Expand Up @@ -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, '/')
Expand Down Expand Up @@ -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)
}
Expand Down

0 comments on commit 0699f34

Please sign in to comment.