Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-preview-vite-allowed-hosts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes `astro preview` ignoring `vite.preview.allowedHosts` set in `astro.config.mjs`
30 changes: 26 additions & 4 deletions packages/astro/src/core/preview/static-preview-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
Expand All @@ -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);
Expand Down
136 changes: 136 additions & 0 deletions packages/astro/test/astro-preview-allowed-hosts.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/astro-preview-allowed-hosts",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<html>
<head>
</head>
<body>
<h1>Hello world!</h1>
</body>
</html>
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading