mirror of
https://github.com/withastro/astro.git
synced 2025-03-31 23:31:30 -05:00
feat(vercel): middleware verification (#9987)
* feat(vercel): verification for edge middleware * add changeset * Apply suggestions from code review --------- Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
This commit is contained in:
parent
9ef79173a6
commit
0699f34d5c
4 changed files with 60 additions and 15 deletions
7
.changeset/slimy-zebras-march.md
Normal file
7
.changeset/slimy-zebras-march.md
Normal file
|
@ -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.
|
|
@ -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), {
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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<URL> {
|
||||
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)
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue