From 9986df4639b9510079a13aa36285394a1467ed34 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 26 Mar 2026 13:55:13 -0400 Subject: [PATCH 1/2] fix(preview): respect vite.preview.allowedHosts from astro.config.mjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static preview server was building its own Vite config from scratch and only reading from settings.config.server.*, completely ignoring settings.config.vite. This meant that vite.preview.allowedHosts (and other vite.preview.* options) set via the vite field in astro.config.mjs were silently dropped. Fix by merging the user's vite config as the base before applying Astro's overrides, matching how the dev server handles this via createVite(). allowedHosts is handled separately after the merge to avoid array concatenation — Astro's server.allowedHosts takes precedence when explicitly set, otherwise vite.preview.allowedHosts is preserved. Closes #16088 --- .changeset/fix-preview-vite-allowed-hosts.md | 5 + .../src/core/preview/static-preview-server.ts | 30 +++- .../test/astro-preview-allowed-hosts.test.js | 136 ++++++++++++++++++ .../astro-preview-allowed-hosts/package.json | 8 ++ .../src/pages/index.astro | 7 + 5 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-preview-vite-allowed-hosts.md create mode 100644 packages/astro/test/astro-preview-allowed-hosts.test.js create mode 100644 packages/astro/test/fixtures/astro-preview-allowed-hosts/package.json create mode 100644 packages/astro/test/fixtures/astro-preview-allowed-hosts/src/pages/index.astro diff --git a/.changeset/fix-preview-vite-allowed-hosts.md b/.changeset/fix-preview-vite-allowed-hosts.md new file mode 100644 index 000000000000..7e69eec8b4b1 --- /dev/null +++ b/.changeset/fix-preview-vite-allowed-hosts.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes `astro preview` ignoring `vite.preview.allowedHosts` set in `astro.config.mjs` diff --git a/packages/astro/src/core/preview/static-preview-server.ts b/packages/astro/src/core/preview/static-preview-server.ts index 5e5cce1e4665..cee1e448f089 100644 --- a/packages/astro/src/core/preview/static-preview-server.ts +++ b/packages/astro/src/core/preview/static-preview-server.ts @@ -2,7 +2,7 @@ import type http from 'node:http'; import { performance } from 'node:perf_hooks'; import { fileURLToPath } from 'node:url'; import type * as vite from 'vite'; -import { preview, type PreviewServer as VitePreviewServer } from 'vite'; +import { mergeConfig, preview, type PreviewServer as VitePreviewServer } from 'vite'; import type { AstroSettings } from '../../types/astro.js'; import type { Logger } from '../logger/core.js'; import * as msg from '../messages/runtime.js'; @@ -27,7 +27,8 @@ export default async function createStaticPreviewServer( let previewServer: VitePreviewServer; try { - previewServer = await preview({ + // Build the Astro-specific preview config + const astroPreviewConfig: vite.InlineConfig = { configFile: false, base: settings.config.base, appType: 'mpa', @@ -40,10 +41,31 @@ export default async function createStaticPreviewServer( port: settings.config.server.port, headers: settings.config.server.headers, open: settings.config.server.open, - allowedHosts: settings.config.server.allowedHosts, }, plugins: [vitePluginAstroPreview(settings)], - }); + }; + + // Merge user's vite config (from astro.config.mjs `vite` field) as the base, + // then apply Astro's overrides on top. This ensures vite.preview.* settings + // are respected while Astro-specific values (like configFile: false) always win. + // Plugins are excluded from the user config since Astro manages its own plugin set. + const { plugins: _plugins, ...userViteConfig } = settings.config.vite ?? {}; + const mergedViteConfig = mergeConfig(userViteConfig, astroPreviewConfig); + + // Apply allowedHosts after merging to avoid Vite's array concatenation behavior. + // If the user explicitly set server.allowedHosts in Astro config (boolean or non-empty + // array), that takes precedence. Otherwise, the user's vite.preview.allowedHosts from + // settings.config.vite (merged above) is preserved. + const { allowedHosts } = settings.config.server; + if ( + typeof allowedHosts === 'boolean' || + (Array.isArray(allowedHosts) && allowedHosts.length > 0) + ) { + mergedViteConfig.preview ??= {}; + mergedViteConfig.preview.allowedHosts = allowedHosts; + } + + previewServer = await preview(mergedViteConfig); } catch (err) { if (err instanceof Error) { logger.error(null, err.stack || err.message); diff --git a/packages/astro/test/astro-preview-allowed-hosts.test.js b/packages/astro/test/astro-preview-allowed-hosts.test.js new file mode 100644 index 000000000000..fc16e9cf2559 --- /dev/null +++ b/packages/astro/test/astro-preview-allowed-hosts.test.js @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { after, before, describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; + +/** + * Make a raw HTTP request with a custom Host header. + * Node's built-in fetch ignores Host header overrides, so we use node:http directly. + * We also use the actual bound port from previewServer.server.address() to avoid + * port mismatch if the configured port is already in use. + */ +function fetchWithHost(port, hostHeader) { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: 'localhost', + port, + path: '/', + method: 'GET', + headers: { host: hostHeader }, + }, + (res) => { + res.resume(); // drain response body + resolve(res); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +function getBoundPort(previewServer) { + return previewServer.server.address().port; +} + +describe('astro preview - allowedHosts via vite config', () => { + let fixture; + let previewServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-preview-allowed-hosts/', + // Set allowedHosts via the vite.preview path (the broken path pre-fix) + vite: { + preview: { + allowedHosts: ['example.com'], + }, + }, + }); + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + await previewServer.stop(); + await fixture.clean(); + }); + + it('allows requests from a host listed in vite.preview.allowedHosts', async () => { + const res = await fetchWithHost(getBoundPort(previewServer), 'example.com'); + assert.equal(res.statusCode, 200); + }); + + it('blocks requests from a host not in vite.preview.allowedHosts', async () => { + const res = await fetchWithHost(getBoundPort(previewServer), 'evil.com'); + assert.equal(res.statusCode, 403); + }); +}); + +describe('astro preview - allowedHosts true via vite config', () => { + let fixture; + let previewServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-preview-allowed-hosts/', + outDir: './dist-allow-all/', + // Set allowedHosts: true via the vite.preview path + vite: { + preview: { + allowedHosts: true, + }, + }, + }); + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + await previewServer.stop(); + await fixture.clean(); + }); + + it('allows requests from any host when vite.preview.allowedHosts is true', async () => { + const res = await fetchWithHost(getBoundPort(previewServer), 'any-host.example'); + assert.equal(res.statusCode, 200); + }); +}); + +describe('astro preview - server.allowedHosts takes precedence over vite.preview.allowedHosts', () => { + let fixture; + let previewServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-preview-allowed-hosts/', + outDir: './dist-precedence/', + // Astro server.allowedHosts should win over vite.preview.allowedHosts + server: { + allowedHosts: ['astro-wins.com'], + }, + vite: { + preview: { + allowedHosts: ['vite-host.com'], + }, + }, + }); + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + await previewServer.stop(); + await fixture.clean(); + }); + + it('allows the host from server.allowedHosts', async () => { + const res = await fetchWithHost(getBoundPort(previewServer), 'astro-wins.com'); + assert.equal(res.statusCode, 200); + }); + + it('blocks the host only in vite.preview.allowedHosts when server.allowedHosts is set', async () => { + const res = await fetchWithHost(getBoundPort(previewServer), 'vite-host.com'); + assert.equal(res.statusCode, 403); + }); +}); diff --git a/packages/astro/test/fixtures/astro-preview-allowed-hosts/package.json b/packages/astro/test/fixtures/astro-preview-allowed-hosts/package.json new file mode 100644 index 000000000000..8c5a8eb1ade1 --- /dev/null +++ b/packages/astro/test/fixtures/astro-preview-allowed-hosts/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/astro-preview-allowed-hosts", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/astro-preview-allowed-hosts/src/pages/index.astro b/packages/astro/test/fixtures/astro-preview-allowed-hosts/src/pages/index.astro new file mode 100644 index 000000000000..62fab9ae89a0 --- /dev/null +++ b/packages/astro/test/fixtures/astro-preview-allowed-hosts/src/pages/index.astro @@ -0,0 +1,7 @@ + + + + +

Hello world!

+ + From 8b8dc99877ea68e8efb8d71c980b73e1764cb877 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 26 Mar 2026 14:17:40 -0400 Subject: [PATCH 2/2] chore: update lockfile --- pnpm-lock.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59117aa01bf4..0f14e5781132 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2419,6 +2419,12 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + packages/astro/test/fixtures/astro-preview-allowed-hosts: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/astro-preview-headers: dependencies: astro: