From 1567e8cc9153f4e8089b2d942ffb73c14cca8031 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 5 Mar 2026 10:19:20 -0500 Subject: [PATCH 1/9] Normalize static file paths before evaluating dotfile access rules (#15763) Co-authored-by: github-actions[bot] --- .changeset/normalize-dotfile-pathname.md | 5 +++ .../integrations/node/src/serve-static.ts | 7 ++-- .../well-known-locations/public/.hidden-file | 1 + .../node/test/well-known-locations.test.js | 40 ++++++++++++++++++- 4 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 .changeset/normalize-dotfile-pathname.md create mode 100644 packages/integrations/node/test/fixtures/well-known-locations/public/.hidden-file diff --git a/.changeset/normalize-dotfile-pathname.md b/.changeset/normalize-dotfile-pathname.md new file mode 100644 index 000000000000..57f29faee108 --- /dev/null +++ b/.changeset/normalize-dotfile-pathname.md @@ -0,0 +1,5 @@ +--- +'@astrojs/node': patch +--- + +Normalizes static file paths before evaluating dotfile access rules for improved consistency diff --git a/packages/integrations/node/src/serve-static.ts b/packages/integrations/node/src/serve-static.ts index 33c5ef9cc258..2050c6236e71 100644 --- a/packages/integrations/node/src/serve-static.ts +++ b/packages/integrations/node/src/serve-static.ts @@ -120,9 +120,10 @@ export function createStaticHandler( // app.removeBase sometimes returns a path without a leading slash pathname = prependForwardSlash(app.removeBase(pathname)); - const stream = send(req, pathname, { + const normalizedPathname = path.posix.normalize(pathname); + const stream = send(req, normalizedPathname, { root: client, - dotfiles: pathname.startsWith('/.well-known/') ? 'allow' : 'deny', + dotfiles: normalizedPathname.startsWith('/.well-known/') ? 'allow' : 'deny', }); let forwardError = false; @@ -139,7 +140,7 @@ export function createStaticHandler( }); stream.on('headers', (_res: ServerResponse) => { // assets in dist/_astro are hashed and should get the immutable header - if (pathname.startsWith(`/${app.manifest.assetsDir}/`)) { + if (normalizedPathname.startsWith(`/${app.manifest.assetsDir}/`)) { // This is the "far future" cache header, used for static files whose name includes their digest hash. // 1 year (31,536,000 seconds) is convention. // Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#immutable diff --git a/packages/integrations/node/test/fixtures/well-known-locations/public/.hidden-file b/packages/integrations/node/test/fixtures/well-known-locations/public/.hidden-file new file mode 100644 index 000000000000..1170b6e1930f --- /dev/null +++ b/packages/integrations/node/test/fixtures/well-known-locations/public/.hidden-file @@ -0,0 +1 @@ +should-not-serve diff --git a/packages/integrations/node/test/well-known-locations.test.js b/packages/integrations/node/test/well-known-locations.test.js index 0951d6c27cc9..57082f28ad6e 100644 --- a/packages/integrations/node/test/well-known-locations.test.js +++ b/packages/integrations/node/test/well-known-locations.test.js @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import nodejs from '../dist/index.js'; -import { loadFixture } from './test-utils.js'; +import { createRequestAndResponse, loadFixture } from './test-utils.js'; describe('test URIs beginning with a dot', () => { /** @type {import('./test-utils').Fixture} */ @@ -43,4 +43,42 @@ describe('test URIs beginning with a dot', () => { assert.equal(res.status, 404); }); }); + + describe('dotfile access via unnormalized paths', async () => { + it('denies dotfile access when path contains .well-known/../ traversal', async () => { + const { handler } = await import('./fixtures/well-known-locations/dist/server/entry.mjs'); + const { req, res, done } = createRequestAndResponse({ + method: 'GET', + url: '/.well-known/../.hidden-file', + }); + + handler(req, res); + req.send(); + + await done; + assert.notEqual( + res.statusCode, + 200, + 'dotfile should not be served via .well-known path traversal', + ); + }); + + it('denies dotfolder file access when path contains .well-known/../ traversal', async () => { + const { handler } = await import('./fixtures/well-known-locations/dist/server/entry.mjs'); + const { req, res, done } = createRequestAndResponse({ + method: 'GET', + url: '/.well-known/../.hidden/file.json', + }); + + handler(req, res); + req.send(); + + await done; + assert.notEqual( + res.statusCode, + 200, + 'dotfolder file should not be served via .well-known path traversal', + ); + }); + }); }); From 44daecfc722cd5763a2afc4b1697169a9a0bb74e Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 5 Mar 2026 10:40:32 -0500 Subject: [PATCH 2/9] Improve form action handling consistency during error page rendering (#15764) * Improve form action handling consistency during error page rendering * Update changeset with more detailed description --------- Co-authored-by: bugbot --- .changeset/warm-pens-glow.md | 5 + packages/astro/src/core/render-context.ts | 5 +- .../test/units/render/render-context.test.js | 130 ++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 .changeset/warm-pens-glow.md create mode 100644 packages/astro/test/units/render/render-context.test.js diff --git a/.changeset/warm-pens-glow.md b/.changeset/warm-pens-glow.md new file mode 100644 index 000000000000..0c40ed0af75a --- /dev/null +++ b/.changeset/warm-pens-glow.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes form actions incorrectly auto-executing during error page rendering. When an error page (e.g. 404) is rendered, form actions from the original request are no longer executed, since the full request handling pipeline is not active. diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 8e40ea12a761..d0a518bc0358 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -303,7 +303,10 @@ export class RenderContext { } let response: Response; - if (!ctx.isPrerendered) { + // Only auto-execute form actions when middleware is part of the pipeline. + // When middleware is skipped (e.g. during error page recovery), form actions + // should not run since the full request handling pipeline is not active. + if (!ctx.isPrerendered && !this.skipMiddleware) { const { action, setActionResult, serializeActionResult } = getActionContext(ctx); if (action?.calledFrom === 'form') { diff --git a/packages/astro/test/units/render/render-context.test.js b/packages/astro/test/units/render/render-context.test.js new file mode 100644 index 000000000000..aa4068cda721 --- /dev/null +++ b/packages/astro/test/units/render/render-context.test.js @@ -0,0 +1,130 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { RenderContext } from '../../../dist/core/render-context.js'; +import { createComponent, maybeRenderHead, render } from '../../../dist/runtime/server/index.js'; +import { createBasicPipeline } from '../test-utils.js'; + +const createAstroModule = (AstroComponent) => ({ default: AstroComponent }); + +describe('RenderContext', () => { + describe('skipMiddleware and form action handling', () => { + it('does not auto-execute form actions when skipMiddleware is true', async () => { + let actionWasCalled = false; + + const pipeline = createBasicPipeline({ + manifest: { + rootDir: import.meta.url, + serverLike: true, + experimentalQueuedRendering: { enabled: true }, + }, + }); + + // Set up a mock action on the pipeline + pipeline.resolvedActions = { + server: { + testAction: async function () { + actionWasCalled = true; + return { data: 'should not be called', error: undefined }; + }, + }, + }; + + const SimplePage = createComponent((result) => { + return render`${maybeRenderHead(result)}

Error page

`; + }); + const PageModule = createAstroModule(SimplePage); + + // POST request with _action param (simulates form action submission) + const request = new Request('http://example.com/404?_action=testAction', { + method: 'POST', + body: new FormData(), + }); + + const routeData = { + type: 'page', + pathname: '/404', + component: 'src/pages/404.astro', + params: {}, + route: '/404', + prerender: false, + }; + + // Create context with skipMiddleware=true (as happens during error recovery) + const renderContext = await RenderContext.create({ + pipeline, + request, + routeData, + status: 404, + skipMiddleware: true, + }); + + const response = await renderContext.render(PageModule); + + assert.equal(response.status, 404); + assert.equal( + actionWasCalled, + false, + 'Form action should not be auto-executed when skipMiddleware is true', + ); + }); + + it('auto-executes form actions when skipMiddleware is false', async () => { + let actionWasCalled = false; + + const pipeline = createBasicPipeline({ + manifest: { + rootDir: import.meta.url, + serverLike: true, + experimentalQueuedRendering: { enabled: true }, + }, + }); + + // Set up a mock action on the pipeline + pipeline.resolvedActions = { + server: { + testAction: async function () { + actionWasCalled = true; + return { data: 'action result', error: undefined }; + }, + }, + }; + + const SimplePage = createComponent((result) => { + return render`${maybeRenderHead(result)}

Page

`; + }); + const PageModule = createAstroModule(SimplePage); + + // POST request with _action param (simulates form action submission) + const request = new Request('http://example.com/page?_action=testAction', { + method: 'POST', + body: new FormData(), + }); + + const routeData = { + type: 'page', + pathname: '/page', + component: 'src/pages/page.astro', + params: {}, + route: '/page', + prerender: false, + }; + + // Create context with skipMiddleware=false (normal flow) + const renderContext = await RenderContext.create({ + pipeline, + request, + routeData, + skipMiddleware: false, + }); + + const response = await renderContext.render(PageModule); + + assert.equal(response.status, 200); + assert.equal( + actionWasCalled, + true, + 'Form action should be auto-executed when skipMiddleware is false', + ); + }); + }); +}); From e0ac1250bb6db87f4c2ac79b6521b0fee0092d7a Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 5 Mar 2026 15:56:11 +0000 Subject: [PATCH 3/9] fix(env): add validation against vite.envPrefix (#15780) --- .changeset/fix-envprefix-secret-leak.md | 24 ++++ packages/astro/src/core/create-vite.ts | 4 + packages/astro/src/core/errors/errors-data.ts | 17 +++ packages/astro/src/env/env-loader.ts | 43 +++++- packages/astro/src/env/validators.ts | 36 +++++ .../astro/test/env-secret-envprefix.test.js | 36 +++++ .../astro.config.mjs | 13 ++ .../astro-env-secret-envprefix/package.json | 8 ++ .../src/pages/index.astro | 18 +++ .../test/units/env/env-validators.test.js | 132 +++++++++++++++++- pnpm-lock.yaml | 6 + 11 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-envprefix-secret-leak.md create mode 100644 packages/astro/test/env-secret-envprefix.test.js create mode 100644 packages/astro/test/fixtures/astro-env-secret-envprefix/astro.config.mjs create mode 100644 packages/astro/test/fixtures/astro-env-secret-envprefix/package.json create mode 100644 packages/astro/test/fixtures/astro-env-secret-envprefix/src/pages/index.astro diff --git a/.changeset/fix-envprefix-secret-leak.md b/.changeset/fix-envprefix-secret-leak.md new file mode 100644 index 000000000000..68a9f3324984 --- /dev/null +++ b/.changeset/fix-envprefix-secret-leak.md @@ -0,0 +1,24 @@ +--- +'astro': patch +--- + +Prevents `vite.envPrefix` misconfiguration from exposing `access: "secret"` environment variables in client-side bundles. Astro now throws a clear error at startup if any `vite.envPrefix` entry matches a variable declared with `access: "secret"` in `env.schema`. + +For example, the following configuration will throw an error for `API_SECRET` because it's defined as `secret` its name matches `['PUBLIC_', 'API_']` defined in `env.schema`: + +```js +// astro.config.mjs +import { defineConfig } from "astro/config"; + +export default defineConfig({ + env: { + schema: { + API_SECRET: envField.string({ context: 'server', access: 'secret', optional: true }), + API_URL: envField.string({ context: 'server', access: 'public', optional: true }), + } + }, + vite: { + envPrefix: ['PUBLIC_', 'API_'], + }, +}) +``` diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 323a384e89d0..41f5ef06a6e4 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -12,6 +12,7 @@ import { astroContentVirtualModPlugin, } from '../content/index.js'; import { createEnvLoader } from '../env/env-loader.js'; +import { validateEnvPrefixAgainstSchema } from '../env/validators.js'; import { astroEnv } from '../env/vite-plugin-env.js'; import { importMetaEnv } from '../env/vite-plugin-import-meta-env.js'; import astroInternationalization from '../i18n/vite-plugin-i18n.js'; @@ -111,6 +112,9 @@ export async function createVite( config: settings.config, }); + // Validate that envPrefix doesn't conflict with secret env schema variables + validateEnvPrefixAgainstSchema(settings.config); + // Start with the Vite configuration that Astro core needs const commonConfig: vite.InlineConfig = { // Tell Vite not to combine config from vite.config.js with our provided inline config diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 0dfeb19a31a4..53ed86e74a7b 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1308,6 +1308,23 @@ export const EnvInvalidVariables = { `The following environment variables defined in \`env.schema\` are invalid:\n\n${errors.map((err) => `- ${err}`).join('\n')}\n`, } satisfies ErrorData; +/** + * @docs + * @description + * The configured `vite.envPrefix` includes prefixes that match environment variables declared with `access: "secret"` in `env.schema`. + * This would cause Vite to expose those secret values in client-side JavaScript bundles, bypassing the `access: "secret"` protection. + * + * To fix this, either: + * - Remove the conflicting prefixes from `vite.envPrefix`, or + * - Rename your secret environment variables to use a prefix that is not in `vite.envPrefix`. + */ +export const EnvPrefixConflictsWithSecret = { + name: 'EnvPrefixConflictsWithSecret', + title: 'envPrefix conflicts with secret environment variables', + message: (conflicts: Array) => + `The following environment variables are declared with \`access: "secret"\` in \`env.schema\`, but their names match a prefix in \`vite.envPrefix\`, which would expose them in client-side bundles:\n\n${conflicts.map((c) => `- ${c}`).join('\n')}\n\nEither remove the conflicting prefixes from \`vite.envPrefix\`, or rename these variables to use a prefix not in \`vite.envPrefix\`.`, +} satisfies ErrorData; + /** * @docs * @description diff --git a/packages/astro/src/env/env-loader.ts b/packages/astro/src/env/env-loader.ts index 114a717e1fe1..be8b08a2c111 100644 --- a/packages/astro/src/env/env-loader.ts +++ b/packages/astro/src/env/env-loader.ts @@ -1,21 +1,41 @@ import { fileURLToPath } from 'node:url'; import { loadEnv } from 'vite'; import type { AstroConfig } from '../types/public/index.js'; +import type { EnvSchema } from './schema.js'; // Match valid JS variable names (identifiers), which accepts most alphanumeric characters, // except that the first character cannot be a number. const isValidIdentifierRe = /^[_$a-zA-Z][\w$]*$/; +/** + * Collects the set of env variable names declared with `access: "secret"` in the env schema. + */ +function getSecretKeys(envSchema: EnvSchema): Set { + const secrets = new Set(); + for (const [key, options] of Object.entries(envSchema)) { + if (options.access === 'secret') { + secrets.add(key); + } + } + return secrets; +} + /** * From public env, returns private env. Each value may be stringified, transformed as `process.env` * or coerced depending on options. + * + * Variables declared with `access: "secret"` in the env schema are always treated as private, + * even if their name matches a configured `envPrefix`. This prevents envPrefix misconfiguration + * from leaking secrets to client bundles. */ function getPrivateEnv({ fullEnv, viteConfig, + envSchema, }: { fullEnv: Record; viteConfig: AstroConfig['vite']; + envSchema: EnvSchema; }): Record { let envPrefixes: string[] = ['PUBLIC_']; if (viteConfig.envPrefix) { @@ -24,12 +44,25 @@ function getPrivateEnv({ : [viteConfig.envPrefix]; } + const secretKeys = getSecretKeys(envSchema); const privateEnv: Record = {}; for (const key in fullEnv) { - // Ignore public env var - if (!isValidIdentifierRe.test(key) || envPrefixes.some((prefix) => key.startsWith(prefix))) { + if (!isValidIdentifierRe.test(key)) { continue; } + + // Variables declared as secret in the env schema are always private, + // regardless of whether they match an envPrefix. + if (secretKeys.has(key)) { + privateEnv[key] = JSON.stringify(fullEnv[key]); + continue; + } + + // Skip variables matching envPrefix — these are public (handled by Vite) + if (envPrefixes.some((prefix) => key.startsWith(prefix))) { + continue; + } + privateEnv[key] = JSON.stringify(fullEnv[key]); } return privateEnv; @@ -42,7 +75,11 @@ interface EnvLoaderOptions { function getEnv({ mode, config }: EnvLoaderOptions) { const loaded = loadEnv(mode, config.vite.envDir ?? fileURLToPath(config.root), ''); - const privateEnv = getPrivateEnv({ fullEnv: loaded, viteConfig: config.vite }); + const privateEnv = getPrivateEnv({ + fullEnv: loaded, + viteConfig: config.vite, + envSchema: config.env.schema, + }); return { loaded, privateEnv }; } diff --git a/packages/astro/src/env/validators.ts b/packages/astro/src/env/validators.ts index c0d4f4bd8a87..82c7e76e5a46 100644 --- a/packages/astro/src/env/validators.ts +++ b/packages/astro/src/env/validators.ts @@ -1,3 +1,5 @@ +import { AstroError, AstroErrorData } from '../core/errors/index.js'; +import type { AstroConfig } from '../types/public/index.js'; import type { EnumSchema, EnvFieldType, NumberSchema, StringSchema } from './schema.js'; export type ValidationResultValue = EnvFieldType['default']; @@ -177,3 +179,37 @@ export function validateEnvVariable( return selectValidator(options)(value); } + +/** + * Validates that `vite.envPrefix` doesn't match any environment variables declared + * with `access: "secret"` in `env.schema`. If it does, those secrets would be exposed + * by Vite in client-side bundles via `import.meta.env`, completely bypassing the + * `access: "secret"` protection. + * + * Throws an `AstroError` if conflicts are found. + */ +export function validateEnvPrefixAgainstSchema(config: AstroConfig): void { + const schema = config.env.schema; + const envPrefix = config.vite?.envPrefix; + + // No schema or using default prefix — nothing to validate + if (Object.keys(schema).length === 0 || !envPrefix) { + return; + } + + const prefixes = Array.isArray(envPrefix) ? envPrefix : [envPrefix]; + const conflicts: string[] = []; + + for (const [key, options] of Object.entries(schema)) { + if (options.access === 'secret' && prefixes.some((prefix) => key.startsWith(prefix))) { + conflicts.push(key); + } + } + + if (conflicts.length > 0) { + throw new AstroError({ + ...AstroErrorData.EnvPrefixConflictsWithSecret, + message: AstroErrorData.EnvPrefixConflictsWithSecret.message(conflicts), + }); + } +} diff --git a/packages/astro/test/env-secret-envprefix.test.js b/packages/astro/test/env-secret-envprefix.test.js new file mode 100644 index 000000000000..d959cbd02107 --- /dev/null +++ b/packages/astro/test/env-secret-envprefix.test.js @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; + +describe('astro:env secret variables with envPrefix conflict', () => { + it('throws an error when envPrefix matches a secret env schema variable', async () => { + const fixture = await loadFixture({ + root: './fixtures/astro-env-secret-envprefix/', + }); + + try { + await fixture.build(); + assert.fail('expected build to throw'); + } catch (error) { + assert.equal(error instanceof Error, true); + assert.equal(error.name, 'EnvPrefixConflictsWithSecret'); + assert.equal(error.message.includes('API_SECRET'), true); + } + }); + + it('does not throw when envPrefix does not match any secret env schema variable', async () => { + // Use the server-secret fixture which has secrets but default envPrefix (PUBLIC_) + const fixture = await loadFixture({ + root: './fixtures/astro-env-server-secret/', + output: 'server', + adapter: (await import('./test-adapter.js')).default({ + env: { + KNOWN_SECRET: '123456', + UNKNOWN_SECRET: 'abc', + }, + }), + }); + await fixture.build(); + assert.equal(true, true); + }); +}); diff --git a/packages/astro/test/fixtures/astro-env-secret-envprefix/astro.config.mjs b/packages/astro/test/fixtures/astro-env-secret-envprefix/astro.config.mjs new file mode 100644 index 000000000000..269943e3d774 --- /dev/null +++ b/packages/astro/test/fixtures/astro-env-secret-envprefix/astro.config.mjs @@ -0,0 +1,13 @@ +import { defineConfig, envField } from 'astro/config'; + +export default defineConfig({ + env: { + schema: { + API_SECRET: envField.string({ context: 'server', access: 'secret', optional: true }), + API_URL: envField.string({ context: 'server', access: 'public', optional: true }), + }, + }, + vite: { + envPrefix: ['PUBLIC_', 'API_'], + }, +}); diff --git a/packages/astro/test/fixtures/astro-env-secret-envprefix/package.json b/packages/astro/test/fixtures/astro-env-secret-envprefix/package.json new file mode 100644 index 000000000000..799e1e64070b --- /dev/null +++ b/packages/astro/test/fixtures/astro-env-secret-envprefix/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/astro-env-secret-envprefix", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/astro-env-secret-envprefix/src/pages/index.astro b/packages/astro/test/fixtures/astro-env-secret-envprefix/src/pages/index.astro new file mode 100644 index 000000000000..a5089b944d52 --- /dev/null +++ b/packages/astro/test/fixtures/astro-env-secret-envprefix/src/pages/index.astro @@ -0,0 +1,18 @@ +--- +import { API_SECRET, getSecret } from "astro:env/server" +--- + + + +
{JSON.stringify({ API_SECRET })}
+ + + diff --git a/packages/astro/test/units/env/env-validators.test.js b/packages/astro/test/units/env/env-validators.test.js index 02c2b5a56c4e..1668408a9726 100644 --- a/packages/astro/test/units/env/env-validators.test.js +++ b/packages/astro/test/units/env/env-validators.test.js @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import { before, describe, it } from 'node:test'; -import { getEnvFieldType, validateEnvVariable } from '../../../dist/env/validators.js'; +import { + getEnvFieldType, + validateEnvVariable, + validateEnvPrefixAgainstSchema, +} from '../../../dist/env/validators.js'; /** * @typedef {Parameters} Params @@ -550,3 +554,129 @@ describe('astro:env validators', () => { }); }); }); + +describe('validateEnvPrefixAgainstSchema', () => { + /** + * Helper to build a minimal config object matching the shape + * validateEnvPrefixAgainstSchema expects. + * + * @param {Record} schema + * @param {string | string[] | undefined} envPrefix + */ + function makeConfig(schema, envPrefix) { + return /** @type {any} */ ({ + env: { schema }, + vite: envPrefix !== undefined ? { envPrefix } : {}, + }); + } + + it('should not throw when schema is empty', () => { + assert.doesNotThrow(() => { + validateEnvPrefixAgainstSchema(makeConfig({}, ['PUBLIC_', 'API_'])); + }); + }); + + it('should not throw when envPrefix is not set', () => { + assert.doesNotThrow(() => { + validateEnvPrefixAgainstSchema( + makeConfig( + { API_SECRET: { context: 'server', access: 'secret', type: 'string' } }, + undefined, + ), + ); + }); + }); + + it('should not throw when envPrefix does not match any secret variable', () => { + assert.doesNotThrow(() => { + validateEnvPrefixAgainstSchema( + makeConfig({ DB_PASSWORD: { context: 'server', access: 'secret', type: 'string' } }, [ + 'PUBLIC_', + 'API_', + ]), + ); + }); + }); + + it('should not throw when envPrefix matches a public variable', () => { + assert.doesNotThrow(() => { + validateEnvPrefixAgainstSchema( + makeConfig({ API_URL: { context: 'server', access: 'public', type: 'string' } }, [ + 'PUBLIC_', + 'API_', + ]), + ); + }); + }); + + it('should throw when envPrefix matches a secret variable (array prefix)', () => { + assert.throws( + () => { + validateEnvPrefixAgainstSchema( + makeConfig({ API_SECRET: { context: 'server', access: 'secret', type: 'string' } }, [ + 'PUBLIC_', + 'API_', + ]), + ); + }, + (err) => { + assert.equal(err.name, 'EnvPrefixConflictsWithSecret'); + assert.equal(err.message.includes('API_SECRET'), true); + return true; + }, + ); + }); + + it('should throw when envPrefix matches a secret variable (string prefix)', () => { + assert.throws( + () => { + validateEnvPrefixAgainstSchema( + makeConfig( + { SECRET_KEY: { context: 'server', access: 'secret', type: 'string' } }, + 'SECRET_', + ), + ); + }, + (err) => { + assert.equal(err.name, 'EnvPrefixConflictsWithSecret'); + assert.equal(err.message.includes('SECRET_KEY'), true); + return true; + }, + ); + }); + + it('should list all conflicting secret variables in the error', () => { + assert.throws( + () => { + validateEnvPrefixAgainstSchema( + makeConfig( + { + API_SECRET: { context: 'server', access: 'secret', type: 'string' }, + API_KEY: { context: 'server', access: 'secret', type: 'string' }, + API_URL: { context: 'server', access: 'public', type: 'string' }, + }, + ['PUBLIC_', 'API_'], + ), + ); + }, + (err) => { + assert.equal(err.name, 'EnvPrefixConflictsWithSecret'); + assert.equal(err.message.includes('API_SECRET'), true); + assert.equal(err.message.includes('API_KEY'), true); + assert.equal(err.message.includes('API_URL'), false); + return true; + }, + ); + }); + + it('should not throw when only the default PUBLIC_ prefix is used', () => { + assert.doesNotThrow(() => { + validateEnvPrefixAgainstSchema( + makeConfig( + { DB_PASSWORD: { context: 'server', access: 'secret', type: 'string' } }, + 'PUBLIC_', + ), + ); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c63ea01bbc24..a2f468ffbf0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2192,6 +2192,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/astro-env-secret-envprefix: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/astro-env-server-fail: dependencies: astro: From e9a9cc6002e35325447856e9a3e0866f285c0638 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 5 Mar 2026 10:57:06 -0500 Subject: [PATCH 4/9] Harden error page response merging to strip framing headers (#15776) Co-authored-by: astro-security-bot --- .changeset/harden-merge-responses-framing.md | 5 + packages/astro/src/core/app/base.ts | 12 +- .../units/middleware/middleware-app.test.js | 126 ++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 .changeset/harden-merge-responses-framing.md diff --git a/.changeset/harden-merge-responses-framing.md b/.changeset/harden-merge-responses-framing.md new file mode 100644 index 000000000000..f3f30e1ad1b2 --- /dev/null +++ b/.changeset/harden-merge-responses-framing.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Hardens error page response merging to ensure framing headers from the original response are not carried over to the rendered error page diff --git a/packages/astro/src/core/app/base.ts b/packages/astro/src/core/app/base.ts index 9c8300b07218..fa07d5427bf4 100644 --- a/packages/astro/src/core/app/base.ts +++ b/packages/astro/src/core/app/base.ts @@ -728,9 +728,17 @@ export abstract class BaseApp

{ : originalResponse.status; try { - // this function could throw an error... + // this function could throw an error if the headers are immutable... originalResponse.headers.delete('Content-type'); - } catch {} + // Framing headers describe the original response's body encoding/size and must + // not carry over to the error page response which has a different body. + originalResponse.headers.delete('Content-Length'); + originalResponse.headers.delete('Transfer-Encoding'); + } catch { + // Headers may be immutable (e.g. when the Response was constructed by a fetch). + // In that case, the loop below still copies from originalResponse.headers, + // so we need to filter out framing headers there instead. + } // Build merged headers using append() to preserve multi-value headers (e.g. Set-Cookie). // Headers from the original response take priority over new response headers for // single-value headers, but we use append to avoid collapsing multi-value entries. diff --git a/packages/astro/test/units/middleware/middleware-app.test.js b/packages/astro/test/units/middleware/middleware-app.test.js index 284edbc88281..f8de122ab568 100644 --- a/packages/astro/test/units/middleware/middleware-app.test.js +++ b/packages/astro/test/units/middleware/middleware-app.test.js @@ -761,6 +761,132 @@ describe('Middleware via App.render()', () => { }); }); + describe('framing headers on error pages', () => { + it('should not preserve Content-Length from middleware when rendering 404 error page', async () => { + // Middleware calls next(), then decides to return 404 with a stale Content-Length header. + // On re-render for the error page, middleware passes the response through unchanged. + let callCount = 0; + const onRequest = async (ctx, next) => { + callCount++; + const response = await next(); + if (callCount === 1 && ctx.url.pathname.startsWith('/api/guarded')) { + return new Response(null, { + status: 404, + headers: { 'Content-Length': '999', 'X-Custom': 'keep-me' }, + }); + } + return response; + }; + + const guardedRouteData = createRouteData({ + route: '/api/guarded/[...path]', + pathname: undefined, + segments: undefined, + }); + guardedRouteData.params = ['...path']; + guardedRouteData.pattern = /^\/api\/guarded(?:\/(.*))?$/; + guardedRouteData.pathname = undefined; + guardedRouteData.segments = [ + [{ content: 'api', dynamic: false, spread: false }], + [{ content: 'guarded', dynamic: false, spread: false }], + [{ content: '...path', dynamic: true, spread: true }], + ]; + + const pageMap = new Map([ + [ + guardedRouteData.component, + async () => ({ + page: async () => ({ + default: simplePage(), + }), + }), + ], + [ + notFoundRouteData.component, + async () => ({ page: async () => ({ default: notFoundPage }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: guardedRouteData }, { routeData: notFoundRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/api/guarded/secret')); + + assert.equal(response.status, 404); + // Content-Length from middleware's original response must not leak into the error page response + assert.equal( + response.headers.get('Content-Length'), + null, + 'Content-Length from middleware should be stripped during error page merge', + ); + // Non-framing custom headers should still be preserved + assert.equal(response.headers.get('X-Custom'), 'keep-me'); + }); + + it('should not preserve Transfer-Encoding from middleware when rendering 500 error page', async () => { + let callCount = 0; + const onRequest = async (ctx, next) => { + callCount++; + const response = await next(); + if (callCount === 1 && ctx.url.pathname.startsWith('/api/error')) { + return new Response(null, { + status: 500, + headers: { 'Transfer-Encoding': 'chunked', 'X-Error-Source': 'middleware' }, + }); + } + return response; + }; + + const errorRouteData = createRouteData({ + route: '/api/error/[...path]', + pathname: undefined, + segments: undefined, + }); + errorRouteData.params = ['...path']; + errorRouteData.pattern = /^\/api\/error(?:\/(.*))?$/; + errorRouteData.pathname = undefined; + errorRouteData.segments = [ + [{ content: 'api', dynamic: false, spread: false }], + [{ content: 'error', dynamic: false, spread: false }], + [{ content: '...path', dynamic: true, spread: true }], + ]; + + const pageMap = new Map([ + [ + errorRouteData.component, + async () => ({ + page: async () => ({ + default: simplePage(), + }), + }), + ], + [ + serverErrorRouteData.component, + async () => ({ page: async () => ({ default: serverErrorPage }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: errorRouteData }, { routeData: serverErrorRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/api/error/test')); + + assert.equal(response.status, 500); + // Transfer-Encoding from middleware's original response must not leak into the error page response + assert.equal( + response.headers.get('Transfer-Encoding'), + null, + 'Transfer-Encoding from middleware should be stripped during error page merge', + ); + // Non-framing custom headers should still be preserved + assert.equal(response.headers.get('X-Error-Source'), 'middleware'); + }); + }); + describe('middleware with custom headers', () => { it('should correctly set custom headers in middleware', async () => { const onRequest = async (_ctx, next) => { From 6414732a12a4dff3da224dfda56f0e26db0c98c4 Mon Sep 17 00:00:00 2001 From: Josh Soref <2119212+jsoref@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:24:33 -0500 Subject: [PATCH 5/9] Spelling (#15601) * spelling: ; otherwise, Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: "..." Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: a new Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: a special Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: a Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: accessed Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: adapter Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: all bugs Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: also need to Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: an Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: and Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: astro Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: attributes Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: audited Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: available Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: baked Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: best-effort Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: build, Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: caches Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: can Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: cannot Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: case-insensitive Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: case. Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: cause Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: centauri Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: chevron Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: cloudflare Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: committed Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: components Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: condition to Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: config Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: consistently Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: convoluted Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: could not Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: desperately Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: devtoolbar Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: ensure Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: entrypoint Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: equals Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: example -- use rfc6761 domain Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: exist Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: external Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: failure Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: fall back Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: flexible Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: frontmatter Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: has no Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: highlighting Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: image Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: implicitly Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: import, Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: impractical Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: include Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: incorrect Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: infrastructure Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: initialization Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: issue Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: its Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: javascript Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: labelable Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: loaded Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: long and short Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: lorem Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: lowercase Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: maintenance Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: malformed Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: markdown Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: middleware Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: mistaken Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: necessary Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: neither-nor Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: nonexistent Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: occurred Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: occurrences Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: of Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: open graph Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: optimized Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: otherwise, Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: out-of-date Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: overridden Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: packages Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: parsed Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: past, Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: performance Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: plugin Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: pre-text Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: preexisting Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: prerendered Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: project Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: punctuation Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: re-export Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: redirects Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: registries Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: rehype Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: replacements Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: resolved Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: safeguard Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: serverless Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: set up Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: significantly Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: solely Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: some Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: stylesheet Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: surname Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: that is Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: that Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: the github Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: to be Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: to take effect Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: transitions Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: typescript Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: unnecessary Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: uppercase Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: use case Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: user's requested...to Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: whether or not Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: with Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: work around Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: workaround Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: your Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> --------- Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> Co-authored-by: Armand Philippot <59021693+ArmandPhilippot@users.noreply.github.com> --- .changeset/clean-planets-flow.md | 2 +- .changeset/full-poems-divide.md | 2 +- .changeset/moody-owls-refuse.md | 2 +- .changeset/social-kings-swim.md | 2 +- CONTRIBUTING.md | 20 +++--- STYLE_GUIDE.md | 2 +- examples/blog/README.md | 2 +- examples/ssr/src/components/AddToCart.svelte | 4 +- examples/starlog/src/components/Header.astro | 2 +- examples/starlog/src/components/SEO.astro | 2 +- packages/astro-rss/CHANGELOG.md | 2 +- packages/astro-rss/README.md | 4 +- packages/astro-rss/test/rss.test.js | 2 +- packages/astro/CHANGELOG-v1.md | 2 +- packages/astro/CHANGELOG-v2.md | 16 ++--- packages/astro/CHANGELOG-v3.md | 8 +-- packages/astro/CHANGELOG-v4.md | 24 +++---- packages/astro/CHANGELOG.md | 62 +++++++++---------- packages/astro/components/ClientRouter.astro | 6 +- packages/astro/e2e/astro-envs.test.js | 4 +- packages/astro/e2e/dev-toolbar.test.js | 2 +- .../fixtures/cloudflare/src/pages/index.astro | 4 +- .../fixtures/server-islands/src/pages/mdx.mdx | 2 +- .../src/pages/form-seven.astro | 2 +- .../src/pages/transition-name.astro | 4 +- packages/astro/e2e/view-transitions.test.js | 6 +- packages/astro/src/assets/endpoint/dev.ts | 2 +- .../core/filter-and-transform-font-faces.ts | 2 +- .../astro/src/assets/fonts/providers/local.ts | 2 +- packages/astro/src/assets/services/service.ts | 2 +- packages/astro/src/assets/utils/etag.ts | 2 +- packages/astro/src/assets/utils/imageKind.ts | 4 +- .../astro/src/assets/utils/resolveImports.ts | 4 +- packages/astro/src/container/index.ts | 2 +- packages/astro/src/content/data-store.ts | 2 +- packages/astro/src/content/types-generator.ts | 2 +- packages/astro/src/content/utils.ts | 2 +- packages/astro/src/core/app/base.ts | 2 +- .../src/core/app/createOutgoingHttpHeaders.ts | 2 +- packages/astro/src/core/app/pipeline.ts | 2 +- .../astro/src/core/app/validate-headers.ts | 2 +- packages/astro/src/core/build/common.ts | 2 +- packages/astro/src/core/build/generate.ts | 2 +- packages/astro/src/core/build/pipeline.ts | 2 +- .../build/plugins/plugin-component-entry.ts | 2 +- packages/astro/src/core/build/plugins/util.ts | 2 +- packages/astro/src/core/build/static-build.ts | 4 +- .../astro/src/core/config/schemas/base.ts | 4 +- .../astro/src/core/config/schemas/refined.ts | 2 +- .../astro/src/core/config/schemas/relative.ts | 2 +- packages/astro/src/core/create-vite.ts | 2 +- packages/astro/src/core/errors/README.md | 2 +- packages/astro/src/core/errors/errors-data.ts | 16 ++--- packages/astro/src/core/errors/errors.ts | 2 +- packages/astro/src/core/logger/core.ts | 2 +- .../src/core/middleware/callMiddleware.ts | 2 +- .../astro/src/core/middleware/vite-plugin.ts | 2 +- packages/astro/src/core/render-context.ts | 4 +- packages/astro/src/core/render/slots.ts | 2 +- .../astro/src/core/routing/create-manifest.ts | 4 +- packages/astro/src/core/routing/match.ts | 6 +- packages/astro/src/core/sync/index.ts | 2 +- .../src/env/vite-plugin-import-meta-env.ts | 2 +- packages/astro/src/i18n/index.ts | 4 +- .../dev-toolbar/apps/audit/rules/a11y.ts | 6 +- .../apps/audit/ui/audit-list-window.ts | 2 +- .../dev-toolbar/apps/utils/highlight.ts | 2 +- .../src/runtime/client/dev-toolbar/toolbar.ts | 2 +- packages/astro/src/runtime/client/visible.ts | 2 +- packages/astro/src/runtime/server/endpoint.ts | 2 +- .../src/runtime/server/render/astro/render.ts | 2 +- .../astro/src/runtime/server/render/page.ts | 2 +- .../astro/src/runtime/server/transition.ts | 2 +- packages/astro/src/transitions/router.ts | 8 +-- .../astro/src/transitions/swap-functions.ts | 2 +- packages/astro/src/types/astro.ts | 2 +- packages/astro/src/types/public/config.ts | 2 +- packages/astro/src/types/public/context.ts | 6 +- .../astro/src/types/public/integrations.ts | 2 +- packages/astro/src/types/public/internal.ts | 2 +- packages/astro/src/types/public/manifest.ts | 2 +- packages/astro/src/vite-plugin-astro/index.ts | 2 +- packages/astro/test/0-css.test.js | 2 +- packages/astro/test/actions.test.js | 4 +- packages/astro/test/astro-doctype.test.js | 2 +- .../astro/test/astro-external-files.test.js | 2 +- packages/astro/test/astro-sync.test.js | 6 +- packages/astro/test/config-mode.test.js | 2 +- packages/astro/test/core-image.test.js | 4 +- .../src/layouts/WithDoctype.astro | 2 +- .../src/layouts/WithoutDoctype.astro | 2 +- .../core-image-svg/src/pages/index.astro | 4 +- .../core-image/src/custom-endpoint.ts | 2 +- .../src/pages/test.mjs | 2 +- .../fixtures/markdown/src/pages/realworld.md | 4 +- .../server-islands/ssr/src/pages/test.mdx | 2 +- .../src/components/async-components.jsx | 2 +- .../src/pages/ssr-client-load.astro | 2 +- .../src/pages/ssr-client-none.astro | 2 +- packages/astro/test/hmr-new-page.test.js | 2 +- .../astro/test/i18n-double-prefix.test.js | 2 +- packages/astro/test/rewrite.test.js | 2 +- packages/astro/test/route-guard.test.js | 2 +- .../test/underscore-in-folder-name.test.js | 2 +- packages/astro/test/units/app/node.test.js | 4 +- .../test/units/assets/fonts/core.test.js | 4 +- .../test/units/config/config-tsconfig.test.js | 2 +- .../test/units/config/config-validate.test.js | 2 +- packages/astro/test/units/dev/dev.test.js | 2 +- .../astro/test/units/routing/manifest.test.js | 4 +- .../units/vite-plugin-astro/compile.test.js | 2 +- packages/create-astro/CHANGELOG.md | 6 +- packages/create-astro/create-astro.mjs | 2 +- packages/create-astro/src/actions/context.ts | 2 +- packages/create-astro/src/actions/template.ts | 4 +- .../test/template-processing.test.js | 2 +- packages/db/CHANGELOG.md | 2 +- packages/db/test/basics.test.js | 4 +- packages/integrations/cloudflare/CHANGELOG.md | 30 ++++----- packages/integrations/cloudflare/README.md | 2 +- .../vite-plugin/src/pages/index.astro | 4 +- .../markdoc/src/content-entry-type.ts | 2 +- packages/integrations/markdoc/src/utils.ts | 2 +- .../src/content/blog/components.mdoc | 10 +-- .../markdoc/test/render-html.test.js | 10 +-- packages/integrations/mdx/CHANGELOG.md | 6 +- packages/integrations/mdx/src/README.md | 2 +- .../src/pages/index.mdx | 0 .../mdx/test/mdx-plus-react.test.js | 2 +- .../mdx/test/mdx-script-style-raw.test.js | 4 +- .../mdx/test/mdx-syntax-highlighting.test.js | 2 +- packages/integrations/netlify/CHANGELOG.md | 6 +- packages/integrations/netlify/src/index.ts | 4 +- packages/integrations/node/CHANGELOG.md | 12 ++-- packages/integrations/partytown/src/sirv.ts | 2 +- packages/integrations/preact/src/client.ts | 2 +- packages/integrations/react/CHANGELOG.md | 2 +- packages/integrations/sitemap/CHANGELOG.md | 4 +- packages/integrations/vercel/CHANGELOG.md | 6 +- .../vercel/src/image/build-service.ts | 2 +- .../vercel/src/image/dev-service.ts | 2 +- .../integrations/vercel/src/image/shared.ts | 10 +-- .../integrations/vercel/test/static.test.js | 2 +- .../language-server/CHANGELOG.md | 8 +-- .../language-server/src/core/parseHTML.ts | 2 +- .../src/languageServerPlugin.ts | 4 +- .../src/plugins/typescript-addons/snippets.ts | 2 +- .../src/plugins/typescript/utils.ts | 2 +- .../language-server/src/plugins/yaml.ts | 4 +- packages/language-tools/vscode/CHANGELOG.md | 2 +- packages/language-tools/vscode/package.json | 2 +- packages/markdown/remark/CHANGELOG.md | 6 +- packages/markdown/remark/src/shiki.ts | 2 +- packages/upgrade/src/actions/verify.ts | 2 +- packages/upgrade/upgrade.mjs | 2 +- scripts/cmd/test.js | 2 +- 156 files changed, 308 insertions(+), 308 deletions(-) rename packages/integrations/mdx/test/fixtures/{mdx-syntax-hightlighting => mdx-syntax-highlighting}/src/pages/index.mdx (100%) diff --git a/.changeset/clean-planets-flow.md b/.changeset/clean-planets-flow.md index 395589d4f9a8..40804e46970e 100644 --- a/.changeset/clean-planets-flow.md +++ b/.changeset/clean-planets-flow.md @@ -2,4 +2,4 @@ 'astro': patch --- -Improves rendering by preserving `hidden="until-found"` value in attribues +Improves rendering by preserving `hidden="until-found"` value in attributes diff --git a/.changeset/full-poems-divide.md b/.changeset/full-poems-divide.md index 8abe7fb50c0d..a778d93c242b 100644 --- a/.changeset/full-poems-divide.md +++ b/.changeset/full-poems-divide.md @@ -3,4 +3,4 @@ 'astro': patch --- -Fixes a bug where the Astro, with the Cloudlfare integration, couldn't correctly serve certain routes in the development server. +Fixes a bug where the Astro, with the Cloudflare integration, couldn't correctly serve certain routes in the development server. diff --git a/.changeset/moody-owls-refuse.md b/.changeset/moody-owls-refuse.md index f3fcdf20bb43..4c08f1fd04b3 100644 --- a/.changeset/moody-owls-refuse.md +++ b/.changeset/moody-owls-refuse.md @@ -2,4 +2,4 @@ '@astrojs/cloudflare': patch --- -Removes unneccessary warning about sharp from being printed at start of dev server and build +Removes unnecessary warning about sharp from being printed at start of dev server and build diff --git a/.changeset/social-kings-swim.md b/.changeset/social-kings-swim.md index 778e12f63253..47db520798a3 100644 --- a/.changeset/social-kings-swim.md +++ b/.changeset/social-kings-swim.md @@ -2,4 +2,4 @@ 'astro': patch --- -Fixes an issue where the internal perfomance timers weren't correctly updated to reflect new build pipeline. +Fixes an issue where the internal performance timers weren't correctly updated to reflect new build pipeline. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 984929a52f32..a6c860cd0ac6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,7 +116,7 @@ pnpm run test pnpm run test:match "$STRING_MATCH" # run tests on another package # (example - `pnpm --filter @astrojs/rss run test` runs `packages/astro-rss/test/rss.test.js`) -pnpm --filter $STRING_MATCH run test +pnpm --filter "$STRING_MATCH" run test ``` Most tests use [`mocha`](https://mochajs.org) as the test runner. We're slowly migrating to use [`node:test`](https://nodejs.org/api/test.html) instead through the custom [`astro-scripts test`](./scripts/cmd/test.js) command. For packages that use `node:test`, you can run these commands in their directories: @@ -165,7 +165,7 @@ node --test --test-only test/astro-basic.test.js #### Debugging tests in CI -There might be occasions where some tests fail in certain CI runs due to some timeout issue. If this happens, it will be very difficult to understand which file cause the timeout. That's caused by come quirks of the Node.js test runner combined with our architecture. +There might be occasions where some tests fail in certain CI runs due to some timeout issue. If this happens, it will be very difficult to understand which file cause the timeout. That's caused by some quirks of the Node.js test runner combined with our architecture. To understand which file causes the issue, you can modify the `test` script inside the `package.json` by adding the `--parallel` option: @@ -176,7 +176,7 @@ To understand which file causes the issue, you can modify the `test` script insi } ``` -Save the change and **push it** to your PR. This change will make the test CI slower, but it will allow to see which files causes the timeout. Once you fixed the issue **revert the change and push it**. +Save the change and **push it** to your PR. This change will make the test CI slower, but it will allow to see which files cause the timeout. Once you fixed the issue **revert the change and push it**. #### E2E tests @@ -194,7 +194,7 @@ pnpm run test:e2e:match "$STRING_MATCH" Any tests for `astro build` output should use the main `mocha` tests rather than E2E - these tests will run faster than having Playwright start the `astro preview` server. -If a test needs to validate what happens on the page after it's loading in the browser, that's a perfect use for E2E dev server tests, i.e. to verify that hot-module reloading works in `astro dev` or that components were client hydrated and are interactive. +If a test needs to validate what happens on the page after it's loaded in the browser, that's a perfect use for E2E dev server tests, i.e. to verify that hot-module reloading works in `astro dev` or that components were client hydrated and are interactive. #### Creating tests @@ -203,8 +203,8 @@ When creating new tests, it's best to reference other existing test files and re - When re-using a fixture multiple times with different configurations, you should also configure unique `outDir`, `build.client`, and `build.server` values so the build output runtime isn't cached and shared by ESM between test runs. > [!IMPORTANT] -> If tests start to fail for no apparent reason, the first thing to look at the `outDir` configuration. As build cache artifacts between runs, different tests might end up sharing some of the emitted modules. -> To avoid this possible overlap, **make sure to add a custom `outDir` to your test case** +> If tests start to fail for no apparent reason, the first thing to look at the `outDir` configuration. As build caches artifacts between runs, different tests might end up sharing some of the emitted modules. +> To avoid this possible overlap, **make sure to add a custom `outDir` to your test case**. > > ```js > await loadFixture({ @@ -259,7 +259,7 @@ To run only a specific benchmark on CI, add its name after the command in your c ## For maintainers -This paragraph provides some guidance to the maintainers of the monorepo. The guidelines explained here aren't necessarily followed by other repositories of the same GitHub organisation. +This paragraph provides some guidance to the maintainers of the monorepo. The guidelines explained here aren't necessarily followed by other repositories of the GitHub organisation. ### Issue triaging workflow @@ -301,7 +301,7 @@ The Astro project has five levels of priority to issues, where `p5` is the highe - `p4`: the bug impacts _many_ Astro projects, it doesn't have a workaround but Astro is still stable/usable. - `p3`: any bug that doesn't fall in the `p4` or `p5` category. If the documentation doesn't cover the case reported by the user, it's useful to initiate a discussion via the `"needs discussion"` label. Seek opinions from OP and other maintainers. -- `p2`: all the bugs that have workarounds. +- `p2`: all bugs that have workarounds. - `p1`: very minor bug, that impacts a small amount of users. Sometimes it's an edge case and it's easy to fix. Very useful if you want to assign the fix to a first-time contributor. > [!IMPORTANT] @@ -312,7 +312,7 @@ Assigning labels isn't always easy and many times the distinction between the di - When assigning a `p2`, **always** add a comment that explains the workaround. If a workaround isn't provided, ping the person that assigned the label and ask them to provide one. - Astro has **many** features, but there are some that have a larger impact than others: development server, build command, HMR (TBD, we don't have a page that explains expectations of HMR in Astro), **evident** regressions in performance. - In case the number of reactions of an issue grows, the number of users affected grows, or a discussion uncovers some insights that weren't clear before, it's OK to change the priority of the issue. The maintainer **should** provide an explanation when assigning a different label. - As with any other contribution, triaging is voluntary and best-efforts. We welcome and appreciate all the help you're happy to give (including reading this!) and nothing more. If you are not confident about an issue, you are welcome to leave an issue untriaged for someone who would have more context, or to bring it to their attention. + As with any other contribution, triaging is voluntary and best-effort. We welcome and appreciate all the help you're happy to give (including reading this!) and nothing more. If you are not confident about an issue, you are welcome to leave an issue untriaged for someone who would have more context, or to bring it to their attention. ### Preview releases @@ -348,7 +348,7 @@ Understanding in which environment code runs, and at which stage in the process, To make it easier to test code, try decoupling **business logic** from **infrastructure**: -- **Infrastucture** is code that depends on external systems and/or requires aspecial environment to run. For example: DB calls, file system, randomness etc... +- **Infrastructure** is code that depends on external systems and/or requires a special environment to run. For example: DB calls, file system, randomness etc... - **Business logic** (or _core logic_ or _domain_) is the rest. It's pure logic that's easy to run from anywhere. That means avoiding side-effects by making external dependencies explicit. This often means passing more things as arguments. diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 6d0cc9e26a8f..69b69660481c 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -45,7 +45,7 @@ For example: "This is clean code" is a subjective point and should have limited In contrast: "Tabs are more accessible than spaces" is an objective point and should be strongly considered in a theoretical style discussion on tabs vs. spaces. (Fred: Believe me, I write this as someone who personally prefers spaces over tabs in my own code!) -Sometimes, not everyone will agree on style changes and 100% consensus is impossible. This is a condition commonly referred to as bike-shedding. If consensus can not be reached, a simple majority vote among core contributors (L3) will suffice. +Sometimes, not everyone will agree on style changes and 100% consensus is impossible. This is a condition commonly referred to as bike-shedding. If consensus cannot be reached, a simple majority vote among core contributors (L3) will suffice. _Note: This process is new, we are still figuring it out! This process will be moved into GOVERNANCE.md when finalized._ diff --git a/examples/blog/README.md b/examples/blog/README.md index 1e88fbf226fc..4307d60ba3c9 100644 --- a/examples/blog/README.md +++ b/examples/blog/README.md @@ -24,7 +24,7 @@ Features: - ✅ Minimal styling (make it your own!) - ✅ 100/100 Lighthouse performance -- ✅ SEO-friendly with canonical URLs and OpenGraph data +- ✅ SEO-friendly with canonical URLs and Open Graph data - ✅ Sitemap support - ✅ RSS Feed support - ✅ Markdown & MDX support diff --git a/examples/ssr/src/components/AddToCart.svelte b/examples/ssr/src/components/AddToCart.svelte index 9e6c8ba866e8..47136c1dcaa8 100644 --- a/examples/ssr/src/components/AddToCart.svelte +++ b/examples/ssr/src/components/AddToCart.svelte @@ -34,7 +34,7 @@ button:hover { transform:scale(1.1); } -.pretext { +.pre-text { color:#fff; background:#0652DD; position:absolute; @@ -50,5 +50,5 @@ button:hover { } diff --git a/examples/starlog/src/components/Header.astro b/examples/starlog/src/components/Header.astro index bbdaf1e70c10..e74f30f2aff8 100644 --- a/examples/starlog/src/components/Header.astro +++ b/examples/starlog/src/components/Header.astro @@ -42,7 +42,7 @@ import { SiteTitle } from '../consts';

diff --git a/examples/starlog/src/components/SEO.astro b/examples/starlog/src/components/SEO.astro index 8816bd7bee27..2ceebb4c9d6d 100644 --- a/examples/starlog/src/components/SEO.astro +++ b/examples/starlog/src/components/SEO.astro @@ -69,7 +69,7 @@ function normalizeImageUrl(image: string | ImageMetadata) { - + diff --git a/packages/astro-rss/CHANGELOG.md b/packages/astro-rss/CHANGELOG.md index ae1e8cd21725..2cc092daad9c 100644 --- a/packages/astro-rss/CHANGELOG.md +++ b/packages/astro-rss/CHANGELOG.md @@ -250,7 +250,7 @@ ### Patch Changes -- [#6614](https://github.com/withastro/astro/pull/6614) [`b1b9b1390`](https://github.com/withastro/astro/commit/b1b9b1390f95c6ae91389eba55f7563b911bccc7) Thanks [@aivarsliepa](https://github.com/aivarsliepa)! - Fixes `RSSOptions` type error when using `strictest` Typescript tsconfig +- [#6614](https://github.com/withastro/astro/pull/6614) [`b1b9b1390`](https://github.com/withastro/astro/commit/b1b9b1390f95c6ae91389eba55f7563b911bccc7) Thanks [@aivarsliepa](https://github.com/aivarsliepa)! - Fixes `RSSOptions` type error when using `strictest` TypeScript tsconfig ## 2.3.1 diff --git a/packages/astro-rss/README.md b/packages/astro-rss/README.md index de897d476029..7a660f46e3d1 100644 --- a/packages/astro-rss/README.md +++ b/packages/astro-rss/README.md @@ -49,7 +49,7 @@ An `RSSFeedItem` is a single item in the list of items in your feed. An example ```js const item = { title: 'Alpha Centauri: so close you can touch it', - link: '/blog/alpha-centuari', + link: '/blog/alpha-centauri', pubDate: new Date('2023-06-04'), description: 'Alpha Centauri is a triple star system, containing Proxima Centauri, the closest star to our sun at only 4.24 light-years away.', @@ -116,7 +116,7 @@ An object that defines the `title` and `url` of the original feed for items that ```js const item = { title: 'Alpha Centauri: so close you can touch it', - link: '/blog/alpha-centuari', + link: '/blog/alpha-centauri', pubDate: new Date('2023-06-04'), description: 'Alpha Centauri is a triple star system, containing Proxima Centauri, the closest star to our sun at only 4.24 light-years away.', diff --git a/packages/astro-rss/test/rss.test.js b/packages/astro-rss/test/rss.test.js index 63c91887d283..a52caf45c361 100644 --- a/packages/astro-rss/test/rss.test.js +++ b/packages/astro-rss/test/rss.test.js @@ -105,7 +105,7 @@ describe('getRssString', () => { assertXmlDeepEqual(str, validXmlWithContentResult); }); - it('should generate on valid RSSFeedItem array with missing date', async () => { + it('should generate on valid RSSFeedItem array that is missing date', async () => { const str = await getRssString({ title, description, diff --git a/packages/astro/CHANGELOG-v1.md b/packages/astro/CHANGELOG-v1.md index 1ebd4a598179..57c7c7a7bb5b 100644 --- a/packages/astro/CHANGELOG-v1.md +++ b/packages/astro/CHANGELOG-v1.md @@ -815,7 +815,7 @@ ### Patch Changes -- [#4768](https://github.com/withastro/astro/pull/4768) [`9a59e24e0`](https://github.com/withastro/astro/commit/9a59e24e0250617333c1a0fd89b7d52fd1c829de) Thanks [@matthewp](https://github.com/matthewp)! - nsure before-hydration is only loaded when used +- [#4768](https://github.com/withastro/astro/pull/4768) [`9a59e24e0`](https://github.com/withastro/astro/commit/9a59e24e0250617333c1a0fd89b7d52fd1c829de) Thanks [@matthewp](https://github.com/matthewp)! - Ensure before-hydration is only loaded when used - [#4759](https://github.com/withastro/astro/pull/4759) [`fc885eaea`](https://github.com/withastro/astro/commit/fc885eaea1f08429599c0ab4697ab6382f3d7fa4) Thanks [@matthewp](https://github.com/matthewp)! - Read jsxImportSource from tsconfig diff --git a/packages/astro/CHANGELOG-v2.md b/packages/astro/CHANGELOG-v2.md index d24276f25e8c..c3dd4e4519dd 100644 --- a/packages/astro/CHANGELOG-v2.md +++ b/packages/astro/CHANGELOG-v2.md @@ -268,7 +268,7 @@ - [#7786](https://github.com/withastro/astro/pull/7786) [`188eeddd4`](https://github.com/withastro/astro/commit/188eeddd47a61e04639670496924c37866180749) Thanks [@matthewp](https://github.com/matthewp)! - Execute scripts when navigating to a new page. - When navigating to an new page with client-side navigation, scripts are executed (and re-executed) so that any new scripts on the incoming page are run and the DOM can be updated. + When navigating to a new page with client-side navigation, scripts are executed (and re-executed) so that any new scripts on the incoming page are run and the DOM can be updated. However, `type=module` scripts never re-execute in Astro, and will not do so in client-side routing. To support cases where you want to modify the DOM, a new `astro:load` event listener been added: @@ -550,7 +550,7 @@ ### Patch Changes -- [#7527](https://github.com/withastro/astro/pull/7527) [`9e2426f75`](https://github.com/withastro/astro/commit/9e2426f75637a6318961f483de90b635f3fdadeb) Thanks [@natemoo-re](https://github.com/natemoo-re)! - Default registry logic to fallback to NPM if registry command fails (sorry, Bun users!) +- [#7527](https://github.com/withastro/astro/pull/7527) [`9e2426f75`](https://github.com/withastro/astro/commit/9e2426f75637a6318961f483de90b635f3fdadeb) Thanks [@natemoo-re](https://github.com/natemoo-re)! - Default registry logic to fall back to NPM if registry command fails (sorry, Bun users!) - [#7542](https://github.com/withastro/astro/pull/7542) [`cdc28326c`](https://github.com/withastro/astro/commit/cdc28326cf21f305924363e9c8c02ce54b6ff895) Thanks [@natemoo-re](https://github.com/natemoo-re)! - Fix bug when using `define:vars` with a `style` object @@ -1087,7 +1087,7 @@ ### Patch Changes -- [#7009](https://github.com/withastro/astro/pull/7009) [`1d4db68e6`](https://github.com/withastro/astro/commit/1d4db68e64b7c3faf8863bf67f8332aa28e2f34b) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Fix types from `astro/client` not working properly due to `client-base.d.ts` being an non-ambient declaration file +- [#7009](https://github.com/withastro/astro/pull/7009) [`1d4db68e6`](https://github.com/withastro/astro/commit/1d4db68e64b7c3faf8863bf67f8332aa28e2f34b) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Fix types from `astro/client` not working properly due to `client-base.d.ts` being a non-ambient declaration file - [#7010](https://github.com/withastro/astro/pull/7010) [`e9f0dd9b4`](https://github.com/withastro/astro/commit/e9f0dd9b473c4793c958a6c81e743fd9b02b4f64) Thanks [@ematipico](https://github.com/ematipico)! - Call `next()` without return anything should work, with a warning @@ -1361,7 +1361,7 @@ - [#6675](https://github.com/withastro/astro/pull/6675) [`1f783e320`](https://github.com/withastro/astro/commit/1f783e32075c20b13063599696644f5d47b75d8d) Thanks [@matthewp](https://github.com/matthewp)! - Prevent frontmatter errors from crashing the dev server -- [#6688](https://github.com/withastro/astro/pull/6688) [`2e92e9aa9`](https://github.com/withastro/astro/commit/2e92e9aa976735c3ddb647152bb9c4850136e386) Thanks [@JohannesKlauss](https://github.com/JohannesKlauss)! - Add a additional check for `null` on the `req.body` check in `NodeApp.render`. +- [#6688](https://github.com/withastro/astro/pull/6688) [`2e92e9aa9`](https://github.com/withastro/astro/commit/2e92e9aa976735c3ddb647152bb9c4850136e386) Thanks [@JohannesKlauss](https://github.com/JohannesKlauss)! - Add an additional check for `null` on the `req.body` check in `NodeApp.render`. - [#6578](https://github.com/withastro/astro/pull/6578) [`adecda7d6`](https://github.com/withastro/astro/commit/adecda7d6009793c5d20519a997e3b7afb08ad57) Thanks [@wulinsheng123](https://github.com/wulinsheng123)! - add new flag with open for dev and preview @@ -1755,8 +1755,8 @@ - [#6052](https://github.com/withastro/astro/pull/6052) [`9793f19ec`](https://github.com/withastro/astro/commit/9793f19ecd4e64cbf3140454fe52aeee2c22c8c9) Thanks [@mayank99](https://github.com/mayank99)! - Error overlay will now show the error's `cause` if available. -- [#6070](https://github.com/withastro/astro/pull/6070) [`f91615f5c`](https://github.com/withastro/astro/commit/f91615f5c04fde36f115dad9110dd75254efd61d) Thanks [@AirBorne04](https://github.com/AirBorne04)! - \* safe guard against TextEncode.encode(HTMLString) [errors on vercel edge] - - safe guard against html.replace when html is undefined +- [#6070](https://github.com/withastro/astro/pull/6070) [`f91615f5c`](https://github.com/withastro/astro/commit/f91615f5c04fde36f115dad9110dd75254efd61d) Thanks [@AirBorne04](https://github.com/AirBorne04)! - \* safeguard against TextEncode.encode(HTMLString) [errors on vercel edge] + - safeguard against html.replace when html is undefined - [#6064](https://github.com/withastro/astro/pull/6064) [`2fb72c887`](https://github.com/withastro/astro/commit/2fb72c887f71c0a69ab512870d65b8c867774766) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Apply MDX `components` export when rendering as a content collection entry @@ -1853,7 +1853,7 @@ } ``` - This differs from previous behavior, where a Markdown file's frontmatter would _always_ override frontmatter injected via remark or reype. + This differs from previous behavior, where a Markdown file's frontmatter would _always_ override frontmatter injected via remark or rehype. - [#5891](https://github.com/withastro/astro/pull/5891) [`05caf445d`](https://github.com/withastro/astro/commit/05caf445d4d2728f1010aeb2179a9e756c2fd17d) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Remove deprecated Markdown APIs from Astro v0.X. This includes `getHeaders()`, the `.astro` property for layouts, and the `rawContent()` and `compiledContent()` error messages for MDX. @@ -2481,7 +2481,7 @@ } ``` - This differs from previous behavior, where a Markdown file's frontmatter would _always_ override frontmatter injected via remark or reype. + This differs from previous behavior, where a Markdown file's frontmatter would _always_ override frontmatter injected via remark or rehype. - [#5728](https://github.com/withastro/astro/pull/5728) [`8fb28648f`](https://github.com/withastro/astro/commit/8fb28648f66629741cb976bfe34ccd9d8f55661e) Thanks [@natemoo-re](https://github.com/natemoo-re)! - The previously experimental features `--experimental-error-overlay` and `--experimental-prerender`, both added in v1.7.0, are now the default. diff --git a/packages/astro/CHANGELOG-v3.md b/packages/astro/CHANGELOG-v3.md index 06133bd50189..8c4e914fe0f5 100644 --- a/packages/astro/CHANGELOG-v3.md +++ b/packages/astro/CHANGELOG-v3.md @@ -146,7 +146,7 @@ - [#9121](https://github.com/withastro/astro/pull/9121) [`f4efd1c80`](https://github.com/withastro/astro/commit/f4efd1c808476c7e60fe00fcfb86276cf14fee79) Thanks [@peng](https://github.com/peng)! - Adds a warning if `astro add` fetches a package but returns a non-404 status -- [#9142](https://github.com/withastro/astro/pull/9142) [`7d55cf68d`](https://github.com/withastro/astro/commit/7d55cf68d89cb46bfb89a109b09af61be8431c89) Thanks [@ematipico](https://github.com/ematipico)! - Consistely emit fallback routes in the correct folders. +- [#9142](https://github.com/withastro/astro/pull/9142) [`7d55cf68d`](https://github.com/withastro/astro/commit/7d55cf68d89cb46bfb89a109b09af61be8431c89) Thanks [@ematipico](https://github.com/ematipico)! - Consistently emit fallback routes in the correct folders. - [#9119](https://github.com/withastro/astro/pull/9119) [`306781795`](https://github.com/withastro/astro/commit/306781795d5f4b755bbdf650a937f1f3c00030bd) Thanks [@ematipico](https://github.com/ematipico)! - Fix a flaw in the i18n fallback logic, where the routes didn't preserve their metadata, such as hoisted scripts @@ -166,7 +166,7 @@ ### Patch Changes -- [#9085](https://github.com/withastro/astro/pull/9085) [`fc66ecff1`](https://github.com/withastro/astro/commit/fc66ecff18a20dd436026cb8e75bcc8b5ab0e681) Thanks [@ematipico](https://github.com/ematipico)! - When redirecting to the default root locale, Astro middleare should take into consideration the value of `trailingSlash` +- [#9085](https://github.com/withastro/astro/pull/9085) [`fc66ecff1`](https://github.com/withastro/astro/commit/fc66ecff18a20dd436026cb8e75bcc8b5ab0e681) Thanks [@ematipico](https://github.com/ematipico)! - When redirecting to the default root locale, Astro middleware should take into consideration the value of `trailingSlash` - [#9067](https://github.com/withastro/astro/pull/9067) [`c6e449c5b`](https://github.com/withastro/astro/commit/c6e449c5b3e6e994b362b9ce441c8a1a81129f23) Thanks [@danielhajduk](https://github.com/danielhajduk)! - Fixes display of debug messages when using the `--verbose` flag @@ -403,7 +403,7 @@ ### Patch Changes -- [#9016](https://github.com/withastro/astro/pull/9016) [`1ecc9aa32`](https://github.com/withastro/astro/commit/1ecc9aa3240b79a3879b1329aa4f671d80e87649) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Add ability to "Click to go editor" on auditted elements in the dev overlay +- [#9016](https://github.com/withastro/astro/pull/9016) [`1ecc9aa32`](https://github.com/withastro/astro/commit/1ecc9aa3240b79a3879b1329aa4f671d80e87649) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Add ability to "Click to go editor" on audited elements in the dev overlay - [#9029](https://github.com/withastro/astro/pull/9029) [`29b83e9e4`](https://github.com/withastro/astro/commit/29b83e9e4b906cc0b5d92fae854fb350fc2be7c8) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Use UInt8Array instead of Buffer for both the input and return values of the `transform()` hook of the Image Service API to ensure compatibility with non-Node runtimes. @@ -873,7 +873,7 @@ - [#8484](https://github.com/withastro/astro/pull/8484) [`78b82bb39`](https://github.com/withastro/astro/commit/78b82bb3929bee5d8d9bd32d65374956ddb05859) Thanks [@bb010g](https://github.com/bb010g)! - fix(astro): add support for `src/content/config.mts` files -- [#8504](https://github.com/withastro/astro/pull/8504) [`5e1099f68`](https://github.com/withastro/astro/commit/5e1099f686abcc7026bd4fa74727f3b311c6d6d6) Thanks [@ematipico](https://github.com/ematipico)! - Minify the HTML of the redicts emitted during the build. +- [#8504](https://github.com/withastro/astro/pull/8504) [`5e1099f68`](https://github.com/withastro/astro/commit/5e1099f686abcc7026bd4fa74727f3b311c6d6d6) Thanks [@ematipico](https://github.com/ematipico)! - Minify the HTML of the redirects emitted during the build. - [#8480](https://github.com/withastro/astro/pull/8480) [`644825845`](https://github.com/withastro/astro/commit/644825845c11c8d100a9b0d16b69a23c165c529e) Thanks [@yamanoku](https://github.com/yamanoku)! - Do not add type="text/css" to inline style tag diff --git a/packages/astro/CHANGELOG-v4.md b/packages/astro/CHANGELOG-v4.md index 447c8b3d0ed5..32abbf44eba1 100644 --- a/packages/astro/CHANGELOG-v4.md +++ b/packages/astro/CHANGELOG-v4.md @@ -4,7 +4,7 @@ - [#12542](https://github.com/withastro/astro/pull/12542) [`65e50eb`](https://github.com/withastro/astro/commit/65e50eb7b6d7b10a193bba7d292804ac0e55be18) Thanks [@kadykov](https://github.com/kadykov)! - Fix JPEG image size determination -- [#12525](https://github.com/withastro/astro/pull/12525) [`cf0d8b0`](https://github.com/withastro/astro/commit/cf0d8b08a0f16bba7310d1a92c82b5a276682e8c) Thanks [@ematipico](https://github.com/ematipico)! - Fixes an issue where with `i18n` enabled, Astro couldn't render the `404.astro` component for non-existent routes. +- [#12525](https://github.com/withastro/astro/pull/12525) [`cf0d8b0`](https://github.com/withastro/astro/commit/cf0d8b08a0f16bba7310d1a92c82b5a276682e8c) Thanks [@ematipico](https://github.com/ematipico)! - Fixes an issue where with `i18n` enabled, Astro couldn't render the `404.astro` component for nonexistent routes. ## 4.16.15 @@ -770,7 +770,7 @@ - [#11360](https://github.com/withastro/astro/pull/11360) [`a79a8b0`](https://github.com/withastro/astro/commit/a79a8b0230b06ed32ce1802f2a5f84a6cf92dbe7) Thanks [@ascorbic](https://github.com/ascorbic)! - Adds a new [`injectTypes()` utility](https://docs.astro.build/en/reference/integrations-reference/#injecttypes-options) to the Integration API and refactors how type generation works - Use `injectTypes()` in the `astro:config:done` hook to inject types into your user's project by adding a new a `*.d.ts` file. + Use `injectTypes()` in the `astro:config:done` hook to inject types into your user's project by adding a new `*.d.ts` file. The `filename` property will be used to generate a file at `/.astro/integrations//.d.ts` and must end with `".d.ts"`. @@ -1236,7 +1236,7 @@ ``` - You may also construct form action URLs using string concatenation, or by using the `URL()` constructor, with the an action's `.queryString` property: + You may also construct form action URLs using string concatenation, or by using the `URL()` constructor, with an action's `.queryString` property: ```astro --- @@ -1589,7 +1589,7 @@ - [#11352](https://github.com/withastro/astro/pull/11352) [`a55ee02`](https://github.com/withastro/astro/commit/a55ee0268e1ca22597e9b5e6d1f24b4f28ad978b) Thanks [@ematipico](https://github.com/ematipico)! - Fixes an issue where the rewrites didn't update the status code when using manual i18n routing. -- [#11388](https://github.com/withastro/astro/pull/11388) [`3a223b4`](https://github.com/withastro/astro/commit/3a223b4811708cc93ebb27706118c1723e1fc013) Thanks [@mingjunlu](https://github.com/mingjunlu)! - Adjusts the color of punctuations in error overlay. +- [#11388](https://github.com/withastro/astro/pull/11388) [`3a223b4`](https://github.com/withastro/astro/commit/3a223b4811708cc93ebb27706118c1723e1fc013) Thanks [@mingjunlu](https://github.com/mingjunlu)! - Adjusts the color of punctuation in error overlay. - [#11369](https://github.com/withastro/astro/pull/11369) [`e6de11f`](https://github.com/withastro/astro/commit/e6de11f4a941e29123da3714e5b8f17d25744f0f) Thanks [@bluwy](https://github.com/bluwy)! - Fixes attribute rendering for non-boolean attributes with boolean values @@ -2571,7 +2571,7 @@ } ``` - This is a backwards compatible change and your your existing dev toolbar apps will continue to function. However, we encourage you to build your apps with the new helpers, following the [updated Dev Toolbar API documentation](https://docs.astro.build/en/reference/dev-toolbar-app-reference/). + This is a backwards compatible change and your existing dev toolbar apps will continue to function. However, we encourage you to build your apps with the new helpers, following the [updated Dev Toolbar API documentation](https://docs.astro.build/en/reference/dev-toolbar-app-reference/). - [#10734](https://github.com/withastro/astro/pull/10734) [`6fc4c0e`](https://github.com/withastro/astro/commit/6fc4c0e420da7629b4cfc28ee7efce1d614447be) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Astro will now automatically check for updates when you run the dev server. If a new version is available, a message will appear in the terminal with instructions on how to update. Updates will be checked once per 10 days, and the message will only appear if the project is multiple versions behind the latest release. @@ -2898,7 +2898,7 @@ - [#10438](https://github.com/withastro/astro/pull/10438) [`5b48cc0fc8383b0659a595afd3a6ee28b28779c3`](https://github.com/withastro/astro/commit/5b48cc0fc8383b0659a595afd3a6ee28b28779c3) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Generate Astro DB types when running `astro sync`. -- [#10456](https://github.com/withastro/astro/pull/10456) [`1900a8f9bc337f3a882178d1770e10ab67fab0ce`](https://github.com/withastro/astro/commit/1900a8f9bc337f3a882178d1770e10ab67fab0ce) Thanks [@martrapp](https://github.com/martrapp)! - Fixes an error when using `astro:transtions/client` without `` +- [#10456](https://github.com/withastro/astro/pull/10456) [`1900a8f9bc337f3a882178d1770e10ab67fab0ce`](https://github.com/withastro/astro/commit/1900a8f9bc337f3a882178d1770e10ab67fab0ce) Thanks [@martrapp](https://github.com/martrapp)! - Fixes an error when using `astro:transitions/client` without `` ## 4.5.4 @@ -3107,7 +3107,7 @@ - [#10343](https://github.com/withastro/astro/pull/10343) [`f973aa9110592fa9017bbe84387f22c24a6d7159`](https://github.com/withastro/astro/commit/f973aa9110592fa9017bbe84387f22c24a6d7159) Thanks [@ematipico](https://github.com/ematipico)! - Fixes some false positive in the dev toolbar a11y audits, by adding the `a` element to the list of interactive elements. -- [#10295](https://github.com/withastro/astro/pull/10295) [`fdd5bf277e5c1cfa30c1bd2ca123f4e90e8d09d9`](https://github.com/withastro/astro/commit/fdd5bf277e5c1cfa30c1bd2ca123f4e90e8d09d9) Thanks [@rossrobino](https://github.com/rossrobino)! - Adds a prefetch fallback when using the `experimental.clientPrerender` option. If prerendering fails, which can happen if [Chrome extensions block prerendering](https://developer.chrome.com/blog/speculation-rules-improvements#chrome-limits), it will fallback to prefetching the URL. This works by adding a `prefetch` field to the `speculationrules` script, but does not create an extra request. +- [#10295](https://github.com/withastro/astro/pull/10295) [`fdd5bf277e5c1cfa30c1bd2ca123f4e90e8d09d9`](https://github.com/withastro/astro/commit/fdd5bf277e5c1cfa30c1bd2ca123f4e90e8d09d9) Thanks [@rossrobino](https://github.com/rossrobino)! - Adds a prefetch fallback when using the `experimental.clientPrerender` option. If prerendering fails, which can happen if [Chrome extensions block prerendering](https://developer.chrome.com/blog/speculation-rules-improvements#chrome-limits), it will fall back to prefetching the URL. This works by adding a `prefetch` field to the `speculationrules` script, but does not create an extra request. ## 4.4.13 @@ -3307,15 +3307,15 @@ Although the incorrect first comparison is not a problem by itself, it could cause the algorithm to make the wrong decision. Depending on the other routes in the project, the sorting could perform just the last two comparisons and by transitivity infer the inverse of the third (`/blog/[...slug` > `/` > `/blog`), which is incorrect. - Now the algorithm doesn't have a special case for index pages and instead does the comparison soleley for rest parameter segments and their immediate parents, which is consistent with the transitivity property. + Now the algorithm doesn't have a special case for index pages and instead does the comparison solely for rest parameter segments and their immediate parents, which is consistent with the transitivity property. - [#10120](https://github.com/withastro/astro/pull/10120) [`787e6f52470cf07fb50c865948b2bc8fe45a6d31`](https://github.com/withastro/astro/commit/787e6f52470cf07fb50c865948b2bc8fe45a6d31) Thanks [@bluwy](https://github.com/bluwy)! - Updates and supports Vite 5.1 - [#10096](https://github.com/withastro/astro/pull/10096) [`227cd83a51bbd451dc223fd16f4cf1b87b8e44f8`](https://github.com/withastro/astro/commit/227cd83a51bbd451dc223fd16f4cf1b87b8e44f8) Thanks [@Fryuni](https://github.com/Fryuni)! - Fixes edge case on i18n fallback routes - Previously index routes deeply nested in the default locale, like `/some/nested/index.astro` could be mistaked as the root index for the default locale, resulting in an incorrect redirect on `/`. + Previously index routes deeply nested in the default locale, like `/some/nested/index.astro` could be mistaken as the root index for the default locale, resulting in an incorrect redirect on `/`. -- [#10112](https://github.com/withastro/astro/pull/10112) [`476b79a61165d0aac5e98459a4ec90762050a14b`](https://github.com/withastro/astro/commit/476b79a61165d0aac5e98459a4ec90762050a14b) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Renames the home Astro Devoolbar App to `astro:home` +- [#10112](https://github.com/withastro/astro/pull/10112) [`476b79a61165d0aac5e98459a4ec90762050a14b`](https://github.com/withastro/astro/commit/476b79a61165d0aac5e98459a4ec90762050a14b) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Renames the home Astro Devtoolbar App to `astro:home` - [#10117](https://github.com/withastro/astro/pull/10117) [`51b6ff7403c1223b1c399e88373075972c82c24c`](https://github.com/withastro/astro/commit/51b6ff7403c1223b1c399e88373075972c82c24c) Thanks [@hippotastic](https://github.com/hippotastic)! - Fixes an issue where `create astro`, `astro add` and `@astrojs/upgrade` would fail due to unexpected package manager CLI output. @@ -3379,7 +3379,7 @@ - [#9932](https://github.com/withastro/astro/pull/9932) [`9f0d89fa7e9e7c08c8600b0c49c2cce7489a7582`](https://github.com/withastro/astro/commit/9f0d89fa7e9e7c08c8600b0c49c2cce7489a7582) Thanks [@ematipico](https://github.com/ematipico)! - Fixes a case where a warning was logged even when the feature `i18nDomains` wasn't enabled -- [#9907](https://github.com/withastro/astro/pull/9907) [`6c894af5ab79f290f4ff7feb68617a66e91febc1`](https://github.com/withastro/astro/commit/6c894af5ab79f290f4ff7feb68617a66e91febc1) Thanks [@ktym4a](https://github.com/ktym4a)! - Load 404.html on all non-existent paths on astro preview. +- [#9907](https://github.com/withastro/astro/pull/9907) [`6c894af5ab79f290f4ff7feb68617a66e91febc1`](https://github.com/withastro/astro/commit/6c894af5ab79f290f4ff7feb68617a66e91febc1) Thanks [@ktym4a](https://github.com/ktym4a)! - Load 404.html on all nonexistent paths on astro preview. ## 4.3.1 @@ -3733,7 +3733,7 @@ Enabling this feature overrides the default `prefetch` behavior globally to prerender links on the client according to your `prefetch` configuration. Instead of appending a `` tag to the head of the document or fetching the page with JavaScript, a `' }, + }); + assert.equal(res.status, 500); + }); + + it('rejects SQL injection in cf-connecting-ip', async () => { + const res = await fixture.fetch('/api/address', { + headers: { 'cf-connecting-ip': "'; DROP TABLE users; --" }, + }); + assert.equal(res.status, 500); + }); + + it('rejects path traversal in cf-connecting-ip', async () => { + const res = await fixture.fetch('/api/address', { + headers: { 'cf-connecting-ip': '../../etc/passwd' }, + }); + assert.equal(res.status, 500); + }); +}); diff --git a/packages/integrations/cloudflare/test/fixtures/client-address/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/client-address/astro.config.mjs new file mode 100644 index 000000000000..339f0e2a49c0 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/client-address/astro.config.mjs @@ -0,0 +1,7 @@ +import cloudflare from '@astrojs/cloudflare'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + adapter: cloudflare(), + output: 'server', +}); diff --git a/packages/integrations/cloudflare/test/fixtures/client-address/package.json b/packages/integrations/cloudflare/test/fixtures/client-address/package.json new file mode 100644 index 000000000000..4ecd2d2bcfb2 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/client-address/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/astro-cloudflare-client-address", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/cloudflare": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/client-address/src/pages/api/address.ts b/packages/integrations/cloudflare/test/fixtures/client-address/src/pages/api/address.ts new file mode 100644 index 000000000000..4d765c569ac6 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/client-address/src/pages/api/address.ts @@ -0,0 +1,5 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = (ctx) => { + return Response.json({ clientAddress: ctx.clientAddress }); +}; diff --git a/packages/integrations/cloudflare/test/fixtures/client-address/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/client-address/src/pages/index.astro new file mode 100644 index 000000000000..9fb9db9f4b3b --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/client-address/src/pages/index.astro @@ -0,0 +1,11 @@ +--- +const address = Astro.clientAddress; +--- + + + Client Address + + +
{address}
+ + diff --git a/packages/integrations/cloudflare/test/fixtures/client-address/wrangler.jsonc b/packages/integrations/cloudflare/test/fixtures/client-address/wrangler.jsonc new file mode 100644 index 000000000000..b0f325b609e6 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/client-address/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "test-client-address", + "main": "@astrojs/cloudflare/entrypoints/server", + "compatibility_date": "2026-01-28" +} diff --git a/packages/integrations/vercel/src/serverless/entrypoint.ts b/packages/integrations/vercel/src/serverless/entrypoint.ts index afec811faec7..e0ce65cc5551 100644 --- a/packages/integrations/vercel/src/serverless/entrypoint.ts +++ b/packages/integrations/vercel/src/serverless/entrypoint.ts @@ -7,6 +7,7 @@ import { } from '../index.js'; import { middlewareSecret, skewProtection } from 'virtual:astro-vercel:config'; import { createApp } from 'astro/app/entrypoint'; +import { getClientIpAddress } from '@astrojs/internal-helpers/request'; setGetEnv((key) => process.env[key]); @@ -48,7 +49,7 @@ export default { const response = await app.render(request, { routeData, - clientAddress: request.headers.get('x-forwarded-for') ?? undefined, + clientAddress: getClientIpAddress(request), locals, }); diff --git a/packages/internal-helpers/package.json b/packages/internal-helpers/package.json index 89afb6abef92..8951d5e53d0d 100644 --- a/packages/internal-helpers/package.json +++ b/packages/internal-helpers/package.json @@ -16,7 +16,8 @@ "./remote": "./dist/remote.js", "./fs": "./dist/fs.js", "./cli": "./dist/cli.js", - "./create-filter": "./dist/create-filter.js" + "./create-filter": "./dist/create-filter.js", + "./request": "./dist/request.js" }, "typesVersions": { "*": { diff --git a/packages/internal-helpers/src/request.ts b/packages/internal-helpers/src/request.ts new file mode 100644 index 000000000000..45082b82b24b --- /dev/null +++ b/packages/internal-helpers/src/request.ts @@ -0,0 +1,62 @@ +/** + * Utilities for extracting information from `Request` + */ + +// Parses multiple header and returns first value if available. +export function getFirstForwardedValue(multiValueHeader?: string | string[] | null) { + return multiValueHeader + ?.toString() + ?.split(',') + .map((e) => e.trim())?.[0]; +} + +// Character-allowlist for IP addresses. Rejects injection payloads (HTML, SQL, +// path traversal, etc.) while accepting any well-formed IPv4/IPv6 string. +// +// Allowed characters: +// 0-9 digits (IPv4 octets, IPv6 groups) +// a-fA-F hex digits (IPv6) +// . dot separator (IPv4, IPv4-mapped IPv6) +// : colon separator (IPv6) +// +// Max length 45 covers the longest valid representation +// (full IPv6 with IPv4-mapped suffix is 45 chars). +const IP_RE = /^[0-9a-fA-F.:]{1,45}$/; + +/** + * Checks whether a string looks like an IP address (contains only characters + * that can appear in IPv4/IPv6 addresses and is within a reasonable length). + * + * This is a permissive allowlist — it won't catch every malformed IP, but it + * reliably rejects injection payloads. Does NOT use Node.js APIs so it works + * in all runtimes (Workers, Deno, etc.). + */ +export function isValidIpAddress(value: string): boolean { + return IP_RE.test(value); +} + +/** + * Extracts the first value from a potentially multi-value header and validates + * that it is a syntactically valid IP address. + * + * Useful for adapters that read client IP from a platform-specific header + */ +export function getValidatedIpFromHeader( + headerValue: string | string[] | null | undefined, +): string | undefined { + const raw = getFirstForwardedValue(headerValue); + if (raw && isValidIpAddress(raw)) { + return raw; + } + return undefined; +} + +/** + * Returns the first value associated to the `x-forwarded-for` header, + * but only if it is a valid IP address. Returns `undefined` otherwise. + * + * @param {Request} request + */ +export function getClientIpAddress(request: Request): string | undefined { + return getValidatedIpFromHeader(request.headers.get('x-forwarded-for')); +} diff --git a/packages/internal-helpers/test/request.test.js b/packages/internal-helpers/test/request.test.js new file mode 100644 index 000000000000..8741dbdddb62 --- /dev/null +++ b/packages/internal-helpers/test/request.test.js @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + getClientIpAddress, + getFirstForwardedValue, + getValidatedIpFromHeader, + isValidIpAddress, +} from '../dist/request.js'; + +describe('getFirstForwardedValue', () => { + it('should return the first value from a comma-separated string', () => { + assert.equal(getFirstForwardedValue('a, b, c'), 'a'); + }); + + it('should return the only value when there is a single value', () => { + assert.equal(getFirstForwardedValue('203.0.113.50'), '203.0.113.50'); + }); + + it('should trim whitespace from the returned value', () => { + assert.equal(getFirstForwardedValue(' 203.0.113.50 , 10.0.0.1 '), '203.0.113.50'); + }); + + it('should return undefined for undefined input', () => { + assert.equal(getFirstForwardedValue(undefined), undefined); + }); + + it('should return undefined for null input', () => { + assert.equal(getFirstForwardedValue(null), undefined); + }); + + it('should handle an empty string', () => { + assert.equal(getFirstForwardedValue(''), ''); + }); + + it('should handle a string array by joining and splitting', () => { + // .toString() on a string array produces "a,b", then split(',') works + assert.equal(getFirstForwardedValue(['203.0.113.50', '10.0.0.1']), '203.0.113.50'); + }); + + it('should handle an IPv6 address', () => { + assert.equal(getFirstForwardedValue('2001:db8::1'), '2001:db8::1'); + }); +}); + +describe('isValidIpAddress', () => { + const validAddresses = [ + // IPv4 + '127.0.0.1', + '0.0.0.0', + '255.255.255.255', + '192.168.1.1', + '10.0.0.1', + '203.0.113.50', + + // IPv6 + '::1', + '::', + '2001:db8::1', + 'fe80::1', + '::ffff:192.0.2.1', + '2001:0db8:0000:0000:0000:0000:0000:0001', + 'fd12:3456:789a::1', + ]; + + const invalidAddresses = [ + // Injection payloads + '', + "'; DROP TABLE users; --", + '../../etc/passwd', + '', + + // Arbitrary strings + 'not-an-ip', + 'hello world', + 'localhost', + 'example.com', + + // Empty / whitespace + '', + ' ', + + // Oversized + '1'.repeat(46), + + // Path-like + '/home/user', + 'C:\\Windows', + + // URL-like + 'http://evil.com', + ]; + + it('should accept valid IP addresses', () => { + for (const addr of validAddresses) { + assert.equal(isValidIpAddress(addr), true, `Expected "${addr}" to be valid`); + } + }); + + it('should reject non-IP strings', () => { + for (const addr of invalidAddresses) { + assert.equal(isValidIpAddress(addr), false, `Expected "${addr}" to be invalid`); + } + }); +}); + +describe('getValidatedIpFromHeader', () => { + it('should return a valid IP from a single-value header', () => { + assert.equal(getValidatedIpFromHeader('203.0.113.50'), '203.0.113.50'); + }); + + it('should return the first valid IP from a multi-value header', () => { + assert.equal(getValidatedIpFromHeader('203.0.113.50, 10.0.0.1'), '203.0.113.50'); + }); + + it('should return undefined for non-IP header values', () => { + assert.equal(getValidatedIpFromHeader(''), undefined); + }); + + it('should return undefined for null', () => { + assert.equal(getValidatedIpFromHeader(null), undefined); + }); + + it('should return undefined for undefined', () => { + assert.equal(getValidatedIpFromHeader(undefined), undefined); + }); + + it('should return undefined for empty string', () => { + assert.equal(getValidatedIpFromHeader(''), undefined); + }); + + it('should handle IPv6 addresses', () => { + assert.equal(getValidatedIpFromHeader('2001:db8::1'), '2001:db8::1'); + }); +}); + +describe('getClientIpAddress', () => { + /** + * Helper to create a minimal Request with given headers. + */ + function makeRequest(headers = {}) { + return new Request('https://example.com', { headers }); + } + + it('should return the IP when x-forwarded-for contains a single address', () => { + const request = makeRequest({ 'x-forwarded-for': '203.0.113.50' }); + assert.equal(getClientIpAddress(request), '203.0.113.50'); + }); + + it('should return the first IP when x-forwarded-for contains multiple addresses', () => { + const request = makeRequest({ + 'x-forwarded-for': '203.0.113.50, 70.41.3.18, 150.172.238.178', + }); + assert.equal(getClientIpAddress(request), '203.0.113.50'); + }); + + it('should trim whitespace around addresses', () => { + const request = makeRequest({ 'x-forwarded-for': ' 203.0.113.50 , 70.41.3.18 ' }); + assert.equal(getClientIpAddress(request), '203.0.113.50'); + }); + + it('should return undefined when x-forwarded-for header is absent', () => { + const request = makeRequest(); + assert.equal(getClientIpAddress(request), undefined); + }); + + it('should return undefined when x-forwarded-for header is empty', () => { + const request = makeRequest({ 'x-forwarded-for': '' }); + assert.equal(getClientIpAddress(request), undefined); + }); + + it('should handle an IPv6 address', () => { + const request = makeRequest({ 'x-forwarded-for': '2001:db8::1' }); + assert.equal(getClientIpAddress(request), '2001:db8::1'); + }); + + it('should return the first IPv6 address from a mixed list', () => { + const request = makeRequest({ 'x-forwarded-for': '2001:db8::1, 203.0.113.50' }); + assert.equal(getClientIpAddress(request), '2001:db8::1'); + }); + + it('should handle IPv4-mapped IPv6 address', () => { + const request = makeRequest({ 'x-forwarded-for': '::ffff:192.0.2.1' }); + assert.equal(getClientIpAddress(request), '::ffff:192.0.2.1'); + }); + + it('should handle the loopback address', () => { + const request = makeRequest({ 'x-forwarded-for': '127.0.0.1' }); + assert.equal(getClientIpAddress(request), '127.0.0.1'); + }); + + it('should return undefined for whitespace-only values', () => { + const request = makeRequest({ 'x-forwarded-for': ' , ' }); + assert.equal(getClientIpAddress(request), undefined); + }); + + it('should not be affected by other headers', () => { + const request = makeRequest({ + 'x-real-ip': '10.0.0.1', + forwarded: 'for=10.0.0.2', + }); + assert.equal(getClientIpAddress(request), undefined); + }); + + it('should reject HTML injection in x-forwarded-for', () => { + const request = makeRequest({ 'x-forwarded-for': '' }); + assert.equal(getClientIpAddress(request), undefined); + }); + + it('should reject SQL injection in x-forwarded-for', () => { + const request = makeRequest({ 'x-forwarded-for': "'; DROP TABLE users; --" }); + assert.equal(getClientIpAddress(request), undefined); + }); + + it('should reject path traversal in x-forwarded-for', () => { + const request = makeRequest({ 'x-forwarded-for': '../../etc/passwd' }); + assert.equal(getClientIpAddress(request), undefined); + }); + + it('should reject oversized x-forwarded-for values', () => { + const request = makeRequest({ 'x-forwarded-for': '1'.repeat(100) }); + assert.equal(getClientIpAddress(request), undefined); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2f468ffbf0a..7987840bc87b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4956,6 +4956,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/cloudflare/test/fixtures/client-address: + dependencies: + '@astrojs/cloudflare': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/cloudflare/test/fixtures/compile-image-service: dependencies: '@astrojs/cloudflare': From 631aaedce99a2233d69fc2aa369164d286f34dbc Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 5 Mar 2026 13:44:47 -0500 Subject: [PATCH 7/9] Normalize backslash characters in URL pathname after decoding (#15757) * Normalize backslash characters in URL pathname after decoding * Update ssr-request test to expect normalized pathname in middleware Duplicate slashes are now collapsed before middleware runs, so the middleware and test need to check for the normalized path instead of the raw path with duplicate slashes. * Simplify slash normalization with collapseDuplicateSlashes helper * Address PR review feedback: clarify comment ordering, simplify test fixtures --------- Co-authored-by: astro-security[bot] --- .changeset/normalize-backslash-pathname.md | 5 + packages/astro/src/core/render-context.ts | 10 +- .../fixtures/ssr-request/src/middleware.ts | 4 +- packages/astro/test/ssr-request.test.js | 2 +- .../app/encoded-backslash-bypass.test.js | 112 ++++++++++++++++++ packages/internal-helpers/src/path.ts | 9 ++ 6 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 .changeset/normalize-backslash-pathname.md create mode 100644 packages/astro/test/units/app/encoded-backslash-bypass.test.js diff --git a/.changeset/normalize-backslash-pathname.md b/.changeset/normalize-backslash-pathname.md new file mode 100644 index 000000000000..8484143ed228 --- /dev/null +++ b/.changeset/normalize-backslash-pathname.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Hardens URL pathname normalization to consistently handle backslash characters after decoding, ensuring middleware and router see the same canonical pathname diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 1f80c74c47fa..491c0ec2bc46 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -40,7 +40,7 @@ import { AstroCache, type CacheLike } from './cache/runtime/cache.js'; import { NoopAstroCache, DisabledAstroCache } from './cache/runtime/noop.js'; import { compileCacheRoutes, matchCacheRoute } from './cache/runtime/route-matching.js'; import { AstroSession } from './session/runtime.js'; -import { collapseDuplicateLeadingSlashes } from '@astrojs/internal-helpers/path'; +import { collapseDuplicateSlashes } from '@astrojs/internal-helpers/path'; import { validateAndDecodePathname } from './util/pathname.js'; /** @@ -91,10 +91,6 @@ export class RenderContext { static #createNormalizedUrl(requestUrl: string): URL { const url = new URL(requestUrl); - // Collapse multiple leading slashes so middleware sees the canonical pathname. - // Without this, a request to `//admin` would preserve `//admin` in context.url.pathname, - // bypassing middleware checks like `pathname.startsWith('/admin')`. - url.pathname = collapseDuplicateLeadingSlashes(url.pathname); try { // Decode and validate pathname to prevent multi-level encoding bypass attacks url.pathname = validateAndDecodePathname(url.pathname); @@ -108,6 +104,10 @@ export class RenderContext { // If even basic decoding fails, return URL as-is } } + // This must run after decoding so it catches slashes introduced by decoding (e.g., `%5C` → `\` → `/`). + // Collapse duplicate slashes so middleware sees the canonical pathname + // and bypass attacks like `//admin` evading `/admin` checks are prevented. + url.pathname = collapseDuplicateSlashes(url.pathname); return url; } diff --git a/packages/astro/test/fixtures/ssr-request/src/middleware.ts b/packages/astro/test/fixtures/ssr-request/src/middleware.ts index 6e7a20d7a676..6f202913f4c3 100644 --- a/packages/astro/test/fixtures/ssr-request/src/middleware.ts +++ b/packages/astro/test/fixtures/ssr-request/src/middleware.ts @@ -1,8 +1,8 @@ import { defineMiddleware } from 'astro:middleware'; export const onRequest = defineMiddleware(({ url }, next) => { - // Redirect when there are extra slashes - if(url.pathname === '/this//is/my/////directory') { + // Redirect when the path matches (duplicate slashes are normalized before middleware runs) + if(url.pathname === '/this/is/my/directory') { return new Response(null, { status: 301, headers: { diff --git a/packages/astro/test/ssr-request.test.js b/packages/astro/test/ssr-request.test.js index a520b350cceb..537592f3a7d0 100644 --- a/packages/astro/test/ssr-request.test.js +++ b/packages/astro/test/ssr-request.test.js @@ -96,7 +96,7 @@ describe('Using Astro.request in SSR', () => { assert.equal(data instanceof Array, true); }); - it('middleware gets the actual path sent in the request', async () => { + it('middleware gets the normalized path sent in the request', async () => { const app = await fixture.loadTestAdapterApp(); const request = new Request('http://example.com/this//is/my/////directory'); const response = await app.render(request); diff --git a/packages/astro/test/units/app/encoded-backslash-bypass.test.js b/packages/astro/test/units/app/encoded-backslash-bypass.test.js new file mode 100644 index 000000000000..fcf6c3e56d82 --- /dev/null +++ b/packages/astro/test/units/app/encoded-backslash-bypass.test.js @@ -0,0 +1,112 @@ +// @ts-check +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { App } from '../../../dist/core/app/app.js'; +import { parseRoute } from '../../../dist/core/routing/parse-route.js'; +import { createComponent, render } from '../../../dist/runtime/server/index.js'; +import { createManifest } from './test-helpers.js'; + +/** + * Tests that encoded backslash characters (%5C) in URL paths do not cause + * a mismatch between what middleware sees and what the router matches. + * + * When %5C is decoded to \, the URL spec's pathname setter normalizes \ to /, + * which can create unexpected double slashes in context.url.pathname. + * The middleware then sees a different path than what the router matched. + */ + +const routeOptions = /** @type {Parameters[1]} */ ( + /** @type {any} */ ({ + config: { base: '/', trailingSlash: 'ignore' }, + pageExtensions: [], + }) +); + +// Dynamic route: /users/[slug] +const userSlugRouteData = parseRoute('users/[slug]', routeOptions, { + component: 'src/pages/users/[slug].astro', +}); + +const publicRouteData = parseRoute('index.astro', routeOptions, { + component: 'src/pages/index.astro', +}); + +const page = createComponent(() => { + return render`

Page

`; +}); + +const pageModule = async () => ({ + page: async () => ({ + default: page, + }), +}); + +const pageMap = new Map([ + [userSlugRouteData.component, pageModule], + [publicRouteData.component, pageModule], +]); + +/** + * Middleware that blocks access to /users/admin path, + * simulating authorization checks on dynamic routes. + */ +const middleware = + /** @type {() => Promise<{onRequest: import('../../../dist/types/public/common.js').MiddlewareHandler}>} */ ( + async () => ({ + onRequest: async (context, next) => { + const pathname = context.url.pathname; + if (pathname === '/users/admin' || pathname.startsWith('/users/admin/')) { + return new Response('Forbidden', { status: 403 }); + } + return next(); + }, + }) + ); + +const app = new App( + createManifest({ + routes: [{ routeData: userSlugRouteData }, { routeData: publicRouteData }], + pageMap, + middleware, + }), +); + +describe('URL normalization: encoded backslash handling in pathname', () => { + it('middleware blocks /users/admin with normal request', async () => { + const request = new Request('http://example.com/users/admin'); + const response = await app.render(request); + assert.equal(response.status, 403, '/users/admin should be blocked by middleware'); + }); + + it('middleware blocks /users/%5Cadmin (encoded backslash)', async () => { + const request = new Request('http://example.com/users/%5Cadmin'); + const response = await app.render(request); + // After decoding %5C to \, and URL normalization of \ to /, + // this should become /users/admin which the middleware should block + assert.equal(response.status, 403, '/users/%5Cadmin should be blocked by middleware'); + }); + + it('middleware blocks /users/%5cadmin (lowercase hex encoded backslash)', async () => { + const request = new Request('http://example.com/users/%5cadmin'); + const response = await app.render(request); + assert.equal(response.status, 403, '/users/%5cadmin should be blocked by middleware'); + }); + + it('middleware blocks /users/%5C%5Cadmin (double encoded backslash)', async () => { + const request = new Request('http://example.com/users/%5C%5Cadmin'); + const response = await app.render(request); + assert.equal(response.status, 403, '/users/%5C%5Cadmin should be blocked by middleware'); + }); + + it('public route is still accessible', async () => { + const request = new Request('http://example.com/'); + const response = await app.render(request); + assert.equal(response.status, 200, '/ should be accessible'); + }); + + it('non-protected dynamic route is still accessible', async () => { + const request = new Request('http://example.com/users/john'); + const response = await app.render(request); + assert.equal(response.status, 200, '/users/john should be accessible'); + }); +}); diff --git a/packages/internal-helpers/src/path.ts b/packages/internal-helpers/src/path.ts index 060c8ca4e4d5..11b4c843bf21 100644 --- a/packages/internal-helpers/src/path.ts +++ b/packages/internal-helpers/src/path.ts @@ -24,6 +24,15 @@ export function collapseDuplicateLeadingSlashes(path: string) { return path.replace(MANY_LEADING_SLASHES, '/'); } +const MANY_SLASHES = /\/{2,}/g; + +export function collapseDuplicateSlashes(path: string) { + if (!path) { + return path; + } + return path.replace(MANY_SLASHES, '/'); +} + export const MANY_TRAILING_SLASHES = /\/{2,}$/g; export function collapseDuplicateTrailingSlashes(path: string, trailingSlash: boolean) { From 2ff96f44d9fd5254b468f21e4ef66ccaf84bf2b0 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 5 Mar 2026 18:45:59 +0000 Subject: [PATCH 8/9] [ci] format --- packages/astro/e2e/astro-envs.test.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/astro/e2e/astro-envs.test.js b/packages/astro/e2e/astro-envs.test.js index 6e7b6fdc3d99..fc629dcc667c 100644 --- a/packages/astro/e2e/astro-envs.test.js +++ b/packages/astro/e2e/astro-envs.test.js @@ -34,8 +34,6 @@ test.describe('Astro Environment BASE_URL', () => { await expect(astroBaseUrl, 'astroBaseUrl equals /blog').toHaveText('/blog'); const clientComponentBaseUrl = page.locator('id=client-component-base-url'); - await expect(clientComponentBaseUrl, 'clientComponentBaseUrl equals /blog').toHaveText( - '/blog', - ); + await expect(clientComponentBaseUrl, 'clientComponentBaseUrl equals /blog').toHaveText('/blog'); }); }); From 39ff2a565614250acae83d35bf196e0463857d9e Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 5 Mar 2026 14:24:31 -0500 Subject: [PATCH 9/9] Harden Node adapter HTTP server defaults and request body handling (#15759) * Harden Node adapter HTTP server defaults and add global body size limit * Make bodySizeLimit a user-configurable option in the Node adapter * Update changeset: bump @astrojs/node to minor for new bodySizeLimit option * Update .changeset/harden-node-server-defaults.md Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> * Remove timeout defaults * Remove timeout hardening tests --------- Co-authored-by: astro-actions[bot] Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> --- .changeset/harden-node-server-defaults.md | 20 ++++ packages/astro/src/core/app/node.ts | 48 ++++++++-- packages/astro/test/units/app/node.test.js | 101 ++++++++++++++++++++ packages/integrations/node/src/index.ts | 1 + packages/integrations/node/src/serve-app.ts | 7 ++ packages/integrations/node/src/types.ts | 11 +++ 6 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 .changeset/harden-node-server-defaults.md diff --git a/.changeset/harden-node-server-defaults.md b/.changeset/harden-node-server-defaults.md new file mode 100644 index 000000000000..3a227f9c8ddf --- /dev/null +++ b/.changeset/harden-node-server-defaults.md @@ -0,0 +1,20 @@ +--- +'astro': patch +'@astrojs/node': minor +--- + +Adds a new `bodySizeLimit` option to the `@astrojs/node` adapter + +You can now configure a maximum allowed request body size for your Node.js standalone server. The default limit is 1 GB. Set the value in bytes, or pass `0` to disable the limit entirely: + +```js +import node from '@astrojs/node'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + adapter: node({ + mode: 'standalone', + bodySizeLimit: 1024 * 1024 * 100, // 100 MB + }), +}); +``` diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts index 2127327d5cf1..06e30e6bef1d 100644 --- a/packages/astro/src/core/app/node.ts +++ b/packages/astro/src/core/app/node.ts @@ -39,8 +39,14 @@ export function createRequest( { skipBody = false, allowedDomains = [], + bodySizeLimit, port: serverPort, - }: { skipBody?: boolean; allowedDomains?: Partial[]; port?: number } = {}, + }: { + skipBody?: boolean; + allowedDomains?: Partial[]; + bodySizeLimit?: number; + port?: number; + } = {}, ): Request { const controller = new AbortController(); @@ -103,7 +109,7 @@ export function createRequest( }; const bodyAllowed = options.method !== 'HEAD' && options.method !== 'GET' && skipBody === false; if (bodyAllowed) { - Object.assign(options, makeRequestBody(req)); + Object.assign(options, makeRequestBody(req, bodySizeLimit)); } const request = new Request(url, options); @@ -322,7 +328,7 @@ function makeRequestHeaders(req: NodeRequest): Headers { return headers; } -function makeRequestBody(req: NodeRequest): RequestInit { +function makeRequestBody(req: NodeRequest, bodySizeLimit?: number): RequestInit { if (req.body !== undefined) { if (typeof req.body === 'string' && req.body.length > 0) { return { body: Buffer.from(req.body) }; @@ -338,20 +344,24 @@ function makeRequestBody(req: NodeRequest): RequestInit { req.body !== null && typeof (req.body as any)[Symbol.asyncIterator] !== 'undefined' ) { - return asyncIterableToBodyProps(req.body as AsyncIterable); + return asyncIterableToBodyProps(req.body as AsyncIterable, bodySizeLimit); } } // Return default body. - return asyncIterableToBodyProps(req); + return asyncIterableToBodyProps(req, bodySizeLimit); } -function asyncIterableToBodyProps(iterable: AsyncIterable): RequestInit { +function asyncIterableToBodyProps( + iterable: AsyncIterable, + bodySizeLimit?: number, +): RequestInit { + const source = bodySizeLimit != null ? limitAsyncIterable(iterable, bodySizeLimit) : iterable; return { // Node uses undici for the Request implementation. Undici accepts // a non-standard async iterable for the body. // @ts-expect-error - body: iterable, + body: source, // The duplex property is required when using a ReadableStream or async // iterable for the body. The type definitions do not include the duplex // property because they are not up-to-date. @@ -359,6 +369,30 @@ function asyncIterableToBodyProps(iterable: AsyncIterable): RequestInit { }; } +/** + * Wraps an async iterable with a size limit. If the total bytes received + * exceed the limit, an error is thrown. + */ +async function* limitAsyncIterable( + iterable: AsyncIterable, + limit: number, +): AsyncGenerator { + let received = 0; + for await (const chunk of iterable) { + const byteLength = + chunk instanceof Uint8Array + ? chunk.byteLength + : typeof chunk === 'string' + ? Buffer.byteLength(chunk) + : 0; + received += byteLength; + if (received > limit) { + throw new Error(`Body size limit exceeded: received more than ${limit} bytes`); + } + yield chunk; + } +} + function getAbortControllerCleanup(req?: NodeRequest): (() => void) | undefined { if (!req) return undefined; const cleanup = Reflect.get(req, nodeRequestAbortControllerCleanupSymbol); diff --git a/packages/astro/test/units/app/node.test.js b/packages/astro/test/units/app/node.test.js index c688d2ae709c..e820cd8e84a7 100644 --- a/packages/astro/test/units/app/node.test.js +++ b/packages/astro/test/units/app/node.test.js @@ -855,6 +855,107 @@ describe('node', () => { }); }); + describe('body size limit', () => { + it('rejects request body that exceeds the configured bodySizeLimit', async () => { + const { Readable } = await import('node:stream'); + // Create a stream that produces data exceeding the limit + const limit = 1024; // 1KB limit + const chunks = []; + // Create 2KB of data (exceeds 1KB limit) + for (let i = 0; i < 4; i++) { + chunks.push(Buffer.alloc(512, 0x41)); + } + const stream = Readable.from(chunks); + const req = { + ...mockNodeRequest, + method: 'POST', + headers: { + ...mockNodeRequest.headers, + 'content-type': 'application/octet-stream', + }, + socket: mockNodeRequest.socket, + [Symbol.asyncIterator]: stream[Symbol.asyncIterator].bind(stream), + }; + + const request = createRequest(req, { bodySizeLimit: limit }); + + // The request should be created, but reading the body should fail + await assert.rejects( + async () => { + const reader = request.body.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + }, + (err) => { + assert.ok(err.message.includes('Body size limit exceeded')); + return true; + }, + ); + }); + + it('allows request body within the configured bodySizeLimit', async () => { + const { Readable } = await import('node:stream'); + const limit = 2048; // 2KB limit + const data = Buffer.alloc(1024, 0x42); // 1KB of data (within limit) + const stream = Readable.from([data]); + const req = { + ...mockNodeRequest, + method: 'POST', + headers: { + ...mockNodeRequest.headers, + 'content-type': 'application/octet-stream', + }, + socket: mockNodeRequest.socket, + [Symbol.asyncIterator]: stream[Symbol.asyncIterator].bind(stream), + }; + + const request = createRequest(req, { bodySizeLimit: limit }); + + // Reading the body should succeed + const reader = request.body.getReader(); + const chunks = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const totalSize = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + assert.equal(totalSize, 1024); + }); + + it('does not enforce body size limit when bodySizeLimit is not set', async () => { + const { Readable } = await import('node:stream'); + // Create 2KB of data with no limit configured + const data = Buffer.alloc(2048, 0x43); + const stream = Readable.from([data]); + const req = { + ...mockNodeRequest, + method: 'POST', + headers: { + ...mockNodeRequest.headers, + 'content-type': 'application/octet-stream', + }, + socket: mockNodeRequest.socket, + [Symbol.asyncIterator]: stream[Symbol.asyncIterator].bind(stream), + }; + + const request = createRequest(req); + + // Reading the body should succeed without limit + const reader = request.body.getReader(); + const chunks = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const totalSize = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + assert.equal(totalSize, 2048); + }); + }); + describe('abort signal', () => { it('aborts the request.signal when the underlying socket closes', () => { const socket = new EventEmitter(); diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index 31547844098c..7a2e4c767afd 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -78,6 +78,7 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr host: _config.server.host, port: _config.server.port, staticHeaders: userOptions.staticHeaders ?? false, + bodySizeLimit: userOptions.bodySizeLimit ?? 1024 * 1024 * 1024, }), ], }, diff --git a/packages/integrations/node/src/serve-app.ts b/packages/integrations/node/src/serve-app.ts index 3f646e9d7845..94dc29940e7a 100644 --- a/packages/integrations/node/src/serve-app.ts +++ b/packages/integrations/node/src/serve-app.ts @@ -75,11 +75,18 @@ export function createAppHandler(app: BaseApp, options: Options): RequestHandler return new Response(null, { status: 404 }); }; + // Use the configured body size limit. A value of 0 or Infinity disables the limit. + const effectiveBodySizeLimit = + options.bodySizeLimit === 0 || options.bodySizeLimit === Number.POSITIVE_INFINITY + ? undefined + : options.bodySizeLimit; + return async (req, res, next, locals) => { let request: Request; try { request = createRequest(req, { allowedDomains: app.getAllowedDomains?.() ?? [], + bodySizeLimit: effectiveBodySizeLimit, port: options.port, }); } catch (err) { diff --git a/packages/integrations/node/src/types.ts b/packages/integrations/node/src/types.ts index 9bb7fd5cdec8..4d853c9ebbc3 100644 --- a/packages/integrations/node/src/types.ts +++ b/packages/integrations/node/src/types.ts @@ -20,6 +20,16 @@ export interface UserOptions { * - The CSP header of the static pages is added when CSP support is enabled. */ staticHeaders?: boolean; + + /** + * Maximum allowed request body size in bytes. Requests with bodies larger than + * this limit will throw an error when the body is consumed. + * + * Set to `Infinity` or `0` to disable the limit. + * + * @default {1073741824} 1GB + */ + bodySizeLimit?: number; } export interface Options extends UserOptions { @@ -28,6 +38,7 @@ export interface Options extends UserOptions { server: string; client: string; staticHeaders: boolean; + bodySizeLimit: number; } export type RequestHandler = (...args: RequestHandlerParams) => void | Promise;