diff --git a/.changeset/forty-crabs-judge.md b/.changeset/forty-crabs-judge.md new file mode 100644 index 000000000000..b91d6b418481 --- /dev/null +++ b/.changeset/forty-crabs-judge.md @@ -0,0 +1,41 @@ +--- +'astro': patch +--- + +Adds an optional middleware for usage with `astro:env` + +If you're using `astro:env`, you can now use a middleware to detect server envrionment variables leaks on the client: + +```ts +// src/middleware.js + +import { leakDetectionMiddleware } from 'astro/env/middleware' +import { sequence, defineMiddleware } from 'astro:middleware' + +const userMiddleware = defineMiddleware((_, next) => { + return next() +}) + +export const onRequest = sequence( + // It's important to use use it first to be able to catch the returned response last + leakDetectionMiddleware(), + userMiddleware +); +``` + +An error will be thrown instead of rendering the page if a leak is detected. + +You can pass 2 options: + +1. `filterContentType`: filters what response content type should trigger the check. Defaults to the content type starting with `text/` or `application/json` +2. `excludeKeys`: by default, all server environment variables are checked. However, you may have variables whose value is really likely to end up on the client but not because it leaked (eg. `test`). In this case, you can exclude those keys. + +```ts +import { leakDetectionMiddleware } from 'astro/env/middleware' + +export const onRequest = leakDetectionMiddleware({ + // Do not filter json response + filterContentType: (contentType) => contentType.startsWith('text/'), + excludeKeys: ['PORT'] +}) +``` \ No newline at end of file diff --git a/packages/astro/package.json b/packages/astro/package.json index 04381e294c48..de67a146de1b 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -32,7 +32,8 @@ "default": "./dist/core/index.js" }, "./env": "./env.d.ts", - "./env/runtime": "./dist/env/runtime.js", + "./env/runtime": "./dist/env/runtime/index.js", + "./env/middleware": "./dist/env/runtime/middleware.js", "./env/setup": "./dist/env/setup.js", "./types": "./types.d.ts", "./client": "./client.d.ts", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index d30b1b3bab1a..1edfefa40262 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -2138,7 +2138,6 @@ export interface AstroUserConfig { * @name experimental.env.schema * @kind h4 * @type {EnvSchema} - * @default `undefined` * @version 4.10.0 * @description * @@ -2160,7 +2159,7 @@ export interface AstroUserConfig { * }) * ``` */ - schema?: EnvSchema; + schema: EnvSchema; }; }; } diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index b9eccc327c4d..9803f7fbf89b 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -8,7 +8,7 @@ import type { SSRManifest, SSRResult, } from '../@types/astro.js'; -import { setGetEnv } from '../env/runtime.js'; +import { setGetEnv } from '../env/runtime/index.js'; import { createI18nMiddleware } from '../i18n/middleware.js'; import { AstroError } from './errors/errors.js'; import { AstroErrorData } from './errors/index.js'; diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 15025a765fd8..cdc1d1af79a5 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -89,6 +89,10 @@ export const ASTRO_CONFIG_DEFAULTS = { clientPrerender: false, globalRoutePriority: false, rewriting: false, + env: { + // Make TS happy + schema: {}, + }, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -525,7 +529,7 @@ export const AstroConfigSchema = z.object({ rewriting: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.rewriting), env: z .object({ - schema: EnvSchema.optional(), + schema: EnvSchema, }) .strict() .optional(), diff --git a/packages/astro/src/env/runtime.ts b/packages/astro/src/env/runtime/index.ts similarity index 83% rename from packages/astro/src/env/runtime.ts rename to packages/astro/src/env/runtime/index.ts index 317e9110fe83..3679fa5310ac 100644 --- a/packages/astro/src/env/runtime.ts +++ b/packages/astro/src/env/runtime/index.ts @@ -1,5 +1,5 @@ -import { AstroError, AstroErrorData } from '../core/errors/index.js'; -export { validateEnvVariable } from './validators.js'; +import { AstroError, AstroErrorData } from '../../core/errors/index.js'; +export { validateEnvVariable } from '../validators.js'; export type GetEnv = (key: string) => string | undefined; diff --git a/packages/astro/src/env/runtime/middleware.ts b/packages/astro/src/env/runtime/middleware.ts new file mode 100644 index 000000000000..28b416d14fd9 --- /dev/null +++ b/packages/astro/src/env/runtime/middleware.ts @@ -0,0 +1,48 @@ +import type { MiddlewareHandler } from '../../@types/astro.js'; + +interface MiddewareOptions { + /** + * Filters what response content type should trigger the check. Defaults to the content type + * starting with `/text` or `application/json` + */ + filterContentType?: (contentType: string) => boolean; + /** + * By default, all server environment variables are checked. However, you may have variables + * whose value is really likely to end up on the client but not because it leaked (eg. `test`). + * In this case, you can exclude those keys. + */ + // @ts-ignore + excludeKeys?: Array>; +} + +/** + * This middleware will throw if a response with the specified content type contains a server + * environment variable. + */ +export function leakDetectionMiddleware({ + filterContentType = (v) => v.startsWith('text/') || v.startsWith('application/json'), + excludeKeys = [], +}: MiddewareOptions = {}): MiddlewareHandler { + return async (_, next) => { + const response = await next(); + + const contentType = response.headers.get('Content-Type'); + if (contentType && filterContentType(contentType)) { + const content = await response.clone().text(); + const { getSecret, ...secrets }: Record = await import( + // @ts-ignore + 'astro:env/server' + ); + for (const [key, value] of Object.entries(secrets)) { + if (excludeKeys.includes(key) || value === undefined) { + continue; + } + if (content.includes(value)) { + throw new Error(`[astro:env] \`${key}\` leaked client-side.`); + } + } + } + + return response; + }; +} diff --git a/packages/astro/src/env/setup.ts b/packages/astro/src/env/setup.ts index 179067b10b24..7dc6b107dd23 100644 --- a/packages/astro/src/env/setup.ts +++ b/packages/astro/src/env/setup.ts @@ -1 +1 @@ -export { setGetEnv, type GetEnv } from './runtime.js'; +export { setGetEnv, type GetEnv } from './runtime/index.js'; diff --git a/packages/astro/src/env/vite-plugin-env.ts b/packages/astro/src/env/vite-plugin-env.ts index 7ca5e4b0a085..84f8ae181372 100644 --- a/packages/astro/src/env/vite-plugin-env.ts +++ b/packages/astro/src/env/vite-plugin-env.ts @@ -33,7 +33,7 @@ export function astroEnv({ if (!settings.config.experimental.env) { return; } - const schema = settings.config.experimental.env.schema ?? {}; + const schema = settings.config.experimental.env?.schema ?? {}; let templates: { client: string; server: string; internal: string } | null = null; diff --git a/packages/astro/test/env-leak-detection.test.js b/packages/astro/test/env-leak-detection.test.js new file mode 100644 index 000000000000..c232d341e955 --- /dev/null +++ b/packages/astro/test/env-leak-detection.test.js @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; + +describe('astro:env leak detection', () => { + it('should fail if a secret is sent to the client', async () => { + const fixture = await loadFixture({ + root: './fixtures/astro-env-leak-detection/', + }); + + let error; + try { + await fixture.build(); + } catch (err) { + error = err; + } + + assert.equal(error instanceof Error, true); + assert.equal(error.message.includes('leaked client-side'), true); + }); +}); diff --git a/packages/astro/test/fixtures/astro-env-leak-detection/astro.config.mjs b/packages/astro/test/fixtures/astro-env-leak-detection/astro.config.mjs new file mode 100644 index 000000000000..b800fa9d8d60 --- /dev/null +++ b/packages/astro/test/fixtures/astro-env-leak-detection/astro.config.mjs @@ -0,0 +1,16 @@ +import { defineConfig, envField } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + experimental: { + env: { + schema: { + FOO: envField.string({ + context: 'server', + access: 'secret', + default: 'this is a secret' + }), + }, + } + } +}); \ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-env-leak-detection/package.json b/packages/astro/test/fixtures/astro-env-leak-detection/package.json new file mode 100644 index 000000000000..80c22b3c904b --- /dev/null +++ b/packages/astro/test/fixtures/astro-env-leak-detection/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/astro-env-leak-detection", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/astro-env-leak-detection/src/middleware.ts b/packages/astro/test/fixtures/astro-env-leak-detection/src/middleware.ts new file mode 100644 index 000000000000..794c5f30ab4d --- /dev/null +++ b/packages/astro/test/fixtures/astro-env-leak-detection/src/middleware.ts @@ -0,0 +1,3 @@ +import { leakDetectionMiddleware } from 'astro/env/middleware' + +export const onRequest = leakDetectionMiddleware() diff --git a/packages/astro/test/fixtures/astro-env-leak-detection/src/pages/index.astro b/packages/astro/test/fixtures/astro-env-leak-detection/src/pages/index.astro new file mode 100644 index 000000000000..687910433370 --- /dev/null +++ b/packages/astro/test/fixtures/astro-env-leak-detection/src/pages/index.astro @@ -0,0 +1,4 @@ +--- +import { FOO } from "astro:env/server" +--- +

{FOO}

\ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-env-leak-detection/tsconfig.json b/packages/astro/test/fixtures/astro-env-leak-detection/tsconfig.json new file mode 100644 index 000000000000..d78f81ec4e8e --- /dev/null +++ b/packages/astro/test/fixtures/astro-env-leak-detection/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "astro/tsconfigs/base" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8a8f8471758..297a82ee9aaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2112,6 +2112,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/astro-env-leak-detection: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/astro-env-server-fail: dependencies: astro: @@ -9575,7 +9581,6 @@ packages: libsql@0.3.12: resolution: {integrity: sha512-to30hj8O3DjS97wpbKN6ERZ8k66MN1IaOfFLR6oHqd25GMiPJ/ZX0VaZ7w+TsPmxcFS3p71qArj/hiedCyvXCg==} - cpu: [x64, arm64, wasm32] os: [darwin, linux, win32] lilconfig@2.1.0: