diff --git a/.changeset/fix-cloudflare-static-output.md b/.changeset/fix-cloudflare-static-output.md
new file mode 100644
index 000000000000..5b6b635d3e78
--- /dev/null
+++ b/.changeset/fix-cloudflare-static-output.md
@@ -0,0 +1,7 @@
+---
+'@astrojs/cloudflare': patch
+---
+
+Fixes deployment of static sites with the Cloudflare adapter
+
+Fixes an issue with detecting and building fully static sites that caused deployment errors when using `output: 'static'` with the Cloudflare adapter
\ No newline at end of file
diff --git a/.changeset/harden-merge-responses-cookies.md b/.changeset/harden-merge-responses-cookies.md
new file mode 100644
index 000000000000..4b9bd521ff92
--- /dev/null
+++ b/.changeset/harden-merge-responses-cookies.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Fixes cookie handling during error page rendering to ensure cookies set by middleware are consistently included in the response
diff --git a/.changeset/harden-xff-allowed-domains.md b/.changeset/harden-xff-allowed-domains.md
new file mode 100644
index 000000000000..655133d29546
--- /dev/null
+++ b/.changeset/harden-xff-allowed-domains.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Hardens `clientAddress` resolution to respect `security.allowedDomains` for `X-Forwarded-For`, consistent with the existing handling of `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-Port`. The `X-Forwarded-For` header is now only used to determine `Astro.clientAddress` when the request's host has been validated against an `allowedDomains` entry. Without a matching domain, `clientAddress` falls back to the socket's remote address.
diff --git a/.changeset/preserve-directory-structure.md b/.changeset/preserve-directory-structure.md
new file mode 100644
index 000000000000..1999a26d9f66
--- /dev/null
+++ b/.changeset/preserve-directory-structure.md
@@ -0,0 +1,29 @@
+---
+'astro': minor
+---
+
+Adds `preserveBuildClientDir` option to adapter features
+
+Adapters can now opt in to preserving the client/server directory structure for static builds by setting `preserveBuildClientDir: true` in their adapter features. When enabled, static builds will output files to `build.client` instead of directly to `outDir`.
+
+This is useful for adapters that require a consistent directory structure regardless of the build output type, such as deploying to platforms with specific file organization requirements.
+
+```js
+// my-adapter/index.js
+export default function myAdapter() {
+ return {
+ name: 'my-adapter',
+ hooks: {
+ 'astro:config:done': ({ setAdapter }) => {
+ setAdapter({
+ name: 'my-adapter',
+ adapterFeatures: {
+ buildOutput: 'static',
+ preserveBuildClientDir: true
+ }
+ });
+ }
+ }
+ };
+}
+```
\ No newline at end of file
diff --git a/.changeset/red-quail-bite.md b/.changeset/red-quail-bite.md
new file mode 100644
index 000000000000..0dca2db5d325
--- /dev/null
+++ b/.changeset/red-quail-bite.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Fixes an issue where a session ID from a cookie with no matching server-side data was accepted as-is. The session now generates a new ID when the cookie value has no corresponding storage entry.
diff --git a/.changeset/redirect-catch-all-hardening.md b/.changeset/redirect-catch-all-hardening.md
new file mode 100644
index 000000000000..4566f24c0d61
--- /dev/null
+++ b/.changeset/redirect-catch-all-hardening.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Hardens config-based redirects with catch-all parameters to prevent producing protocol-relative URLs (e.g. `//example.com`) in the `Location` header
diff --git a/.changeset/soft-vans-ask.md b/.changeset/soft-vans-ask.md
new file mode 100644
index 000000000000..5f91de4aa7df
--- /dev/null
+++ b/.changeset/soft-vans-ask.md
@@ -0,0 +1,5 @@
+---
+'@astrojs/node': patch
+---
+
+Hardens static file handler path resolution to ensure resolved paths stay within the client directory
diff --git a/packages/astro/src/core/app/base.ts b/packages/astro/src/core/app/base.ts
index 2782b0c52f2e..9c8300b07218 100644
--- a/packages/astro/src/core/app/base.ts
+++ b/packages/astro/src/core/app/base.ts
@@ -23,7 +23,12 @@ import {
REWRITE_DIRECTIVE_HEADER_KEY,
ROUTE_TYPE_HEADER,
} from '../constants.js';
-import { getSetCookiesFromResponse } from '../cookies/index.js';
+import {
+ AstroCookies,
+ attachCookiesToResponse,
+ getSetCookiesFromResponse,
+} from '../cookies/index.js';
+import { getCookiesFromResponse } from '../cookies/response.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { consoleLogDestination } from '../logger/console.js';
import { AstroIntegrationLogger, Logger } from '../logger/core.js';
@@ -726,16 +731,24 @@ export abstract class BaseApp
{
// this function could throw an error...
originalResponse.headers.delete('Content-type');
} catch {}
- // we use a map to remove duplicates
- const mergedHeaders = new Map([
- ...Array.from(newResponseHeaders),
- ...Array.from(originalResponse.headers),
- ]);
+ // 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.
const newHeaders = new Headers();
- for (const [name, value] of mergedHeaders) {
- newHeaders.set(name, value);
+ const seen = new Set();
+ // Add original response headers first (they take priority)
+ for (const [name, value] of originalResponse.headers) {
+ newHeaders.append(name, value);
+ seen.add(name.toLowerCase());
+ }
+ // Add new response headers that weren't already set by the original response,
+ // but skip content-type since the error page must return text/html
+ for (const [name, value] of newResponseHeaders) {
+ if (!seen.has(name.toLowerCase())) {
+ newHeaders.append(name, value);
+ }
}
- return new Response(newResponse.body, {
+ const mergedResponse = new Response(newResponse.body, {
status,
statusText: status === 200 ? newResponse.statusText : originalResponse.statusText,
// If you're looking at here for possible bugs, it means that it's not a bug.
@@ -745,6 +758,24 @@ export abstract class BaseApp {
// Although, we don't want it to replace the content-type, because the error page must return `text/html`
headers: newHeaders,
});
+
+ // Transfer AstroCookies from the original or new response so that
+ // #prepareResponse can read them when addCookieHeader is true.
+ const originalCookies = getCookiesFromResponse(originalResponse);
+ const newCookies = getCookiesFromResponse(newResponse);
+ if (originalCookies) {
+ // If both responses have cookies, merge new response cookies into original
+ if (newCookies) {
+ for (const cookieValue of AstroCookies.consume(newCookies)) {
+ originalResponse.headers.append('set-cookie', cookieValue);
+ }
+ }
+ attachCookiesToResponse(mergedResponse, originalCookies);
+ } else if (newCookies) {
+ attachCookiesToResponse(mergedResponse, newCookies);
+ }
+
+ return mergedResponse;
}
getDefaultStatusCode(routeData: RouteData, pathname: string): number {
diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts
index 716ec5205f76..b58db09ec2eb 100644
--- a/packages/astro/src/core/app/node.ts
+++ b/packages/astro/src/core/app/node.ts
@@ -141,8 +141,14 @@ export function createRequest(
}
// Get the IP of end client behind the proxy.
+ // Only trust X-Forwarded-For when the request's host was validated against allowedDomains,
+ // meaning it arrived through a trusted proxy. Without this check, any client can spoof
+ // their IP via this header.
// @example "1.1.1.1,8.8.8.8" => "1.1.1.1"
- const forwardedClientIp = getFirstForwardedValue(req.headers['x-forwarded-for']);
+ const hostValidated = validated.host !== undefined || validatedHostname !== undefined;
+ const forwardedClientIp = hostValidated
+ ? getFirstForwardedValue(req.headers['x-forwarded-for'])
+ : undefined;
const clientIp = forwardedClientIp || req.socket?.remoteAddress;
if (clientIp) {
Reflect.set(request, clientAddressSymbol, clientIp);
diff --git a/packages/astro/src/core/build/common.ts b/packages/astro/src/core/build/common.ts
index 326450b1ba7f..18a3041e6229 100644
--- a/packages/astro/src/core/build/common.ts
+++ b/packages/astro/src/core/build/common.ts
@@ -9,7 +9,9 @@ const STATUS_CODE_PAGES = new Set(['/404', '/500']);
const FALLBACK_OUT_DIR_NAME = './.astro/';
function getOutRoot(astroSettings: AstroSettings): URL {
- if (astroSettings.buildOutput === 'static') {
+ const preserveStructure = astroSettings.adapter?.adapterFeatures?.preserveBuildClientDir;
+
+ if (astroSettings.buildOutput === 'static' && !preserveStructure) {
return new URL('./', astroSettings.config.outDir);
} else {
return new URL('./', astroSettings.config.build.client);
diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts
index 8d160bcfe356..4a8253a63668 100644
--- a/packages/astro/src/core/build/generate.ts
+++ b/packages/astro/src/core/build/generate.ts
@@ -544,7 +544,9 @@ function checkPublicConflict(
): boolean {
const outFilePath = fileURLToPath(outFile);
const outRoot = fileURLToPath(
- settings.buildOutput === 'static' ? settings.config.outDir : settings.config.build.client,
+ settings.buildOutput === 'static' && !settings.adapter?.adapterFeatures?.preserveBuildClientDir
+ ? settings.config.outDir
+ : settings.config.build.client,
);
const relativePath = outFilePath.slice(outRoot.length);
const publicFilePath = new URL(relativePath, settings.config.publicDir);
diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts
index 01b4e8f77b25..97627be4f953 100644
--- a/packages/astro/src/core/build/static-build.ts
+++ b/packages/astro/src/core/build/static-build.ts
@@ -533,10 +533,12 @@ async function ssrMoveAssets(
) {
opts.logger.info('build', 'Rearranging server assets...');
const isFullyStaticSite = opts.settings.buildOutput === 'static';
+ const preserveStructure = opts.settings.adapter?.adapterFeatures?.preserveBuildClientDir;
const serverRoot = opts.settings.config.build.server;
- const clientRoot = isFullyStaticSite
- ? opts.settings.config.outDir
- : opts.settings.config.build.client;
+ const clientRoot =
+ isFullyStaticSite && !preserveStructure
+ ? opts.settings.config.outDir
+ : opts.settings.config.build.client;
// Move prerender assets
const prerenderAssetsToMove = getSSRAssets(internals, ASTRO_VITE_ENVIRONMENT_NAMES.prerender);
diff --git a/packages/astro/src/core/redirects/render.ts b/packages/astro/src/core/redirects/render.ts
index eba4395b36ca..00a4543c109b 100644
--- a/packages/astro/src/core/redirects/render.ts
+++ b/packages/astro/src/core/redirects/render.ts
@@ -2,13 +2,15 @@ import type { RedirectConfig } from '../../types/public/index.js';
import type { RenderContext } from '../render-context.js';
import { getRouteGenerator } from '../routing/generator.js';
+function isExternalURL(url: string): boolean {
+ return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//');
+}
+
export function redirectIsExternal(redirect: RedirectConfig): boolean {
if (typeof redirect === 'string') {
- return redirect.startsWith('http://') || redirect.startsWith('https://');
+ return isExternalURL(redirect);
} else {
- return (
- redirect.destination.startsWith('http://') || redirect.destination.startsWith('https://')
- );
+ return isExternalURL(redirect.destination);
}
}
diff --git a/packages/astro/src/core/routing/generator.ts b/packages/astro/src/core/routing/generator.ts
index 94dcf4324465..7ee9d8806aac 100644
--- a/packages/astro/src/core/routing/generator.ts
+++ b/packages/astro/src/core/routing/generator.ts
@@ -1,3 +1,4 @@
+import { collapseDuplicateLeadingSlashes } from '@astrojs/internal-helpers/path';
import type { AstroConfig } from '../../types/public/config.js';
import type { RoutePart } from '../../types/public/internal.js';
@@ -41,7 +42,7 @@ function getParameter(part: RoutePart, params: Record):
function getSegment(segment: RoutePart[], params: Record): string {
const segmentPath = segment.map((part) => getParameter(part, params)).join('');
- return segmentPath ? '/' + segmentPath : '';
+ return segmentPath ? collapseDuplicateLeadingSlashes('/' + segmentPath) : '';
}
type RouteGenerator = (data?: any) => string;
diff --git a/packages/astro/src/core/session/runtime.ts b/packages/astro/src/core/session/runtime.ts
index d814ca95e528..a72f12ffa232 100644
--- a/packages/astro/src/core/session/runtime.ts
+++ b/packages/astro/src/core/session/runtime.ts
@@ -53,6 +53,8 @@ export class AstroSession {
#dirty = false;
// Whether the session cookie has been set.
#cookieSet = false;
+ // Whether the session ID was sourced from a client cookie rather than freshly generated.
+ #sessionIDFromCookie = false;
// The local data is "partial" if it has not been loaded from storage yet and only
// contains values that have been set or deleted in-memory locally.
// We do this to avoid the need to block on loading data when it is only being set.
@@ -242,6 +244,7 @@ export class AstroSession {
// Create new session
this.#sessionID = crypto.randomUUID();
+ this.#sessionIDFromCookie = false;
this.#data = data;
this.#dirty = true;
await this.#setCookie();
@@ -349,6 +352,16 @@ export class AstroSession {
// We stored this as a devalue string, but unstorage will have parsed it as JSON
const raw = await storage.get(this.#ensureSessionID());
if (!raw) {
+ if (this.#sessionIDFromCookie) {
+ // The session ID was supplied by the client cookie but has no corresponding
+ // server-side data. Generate a new server-controlled ID rather than
+ // accepting an unrecognized value from the client.
+ this.#sessionID = crypto.randomUUID();
+ this.#sessionIDFromCookie = false;
+ if (this.#cookieSet) {
+ await this.#setCookie();
+ }
+ }
// If there is no existing data in storage we don't need to merge anything
// and can just return the existing local data.
return this.#data;
@@ -404,7 +417,15 @@ export class AstroSession {
* Returns the session ID, generating a new one if it does not exist.
*/
#ensureSessionID() {
- this.#sessionID ??= this.#cookies.get(this.#cookieName)?.value ?? crypto.randomUUID();
+ if (!this.#sessionID) {
+ const cookieValue = this.#cookies.get(this.#cookieName)?.value;
+ if (cookieValue) {
+ this.#sessionID = cookieValue;
+ this.#sessionIDFromCookie = true;
+ } else {
+ this.#sessionID = crypto.randomUUID();
+ }
+ }
return this.#sessionID;
}
diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts
index 776ab92ba3e1..7fdf9a4ea2e7 100644
--- a/packages/astro/src/integrations/hooks.ts
+++ b/packages/astro/src/integrations/hooks.ts
@@ -585,8 +585,11 @@ export async function runHookBuildGenerated({
logger: Logger;
routeToHeaders: RouteToHeaders;
}) {
+ const preserveStructure = settings.adapter?.adapterFeatures?.preserveBuildClientDir;
const dir =
- settings.buildOutput === 'server' ? settings.config.build.client : settings.config.outDir;
+ settings.buildOutput === 'server' || preserveStructure
+ ? settings.config.build.client
+ : settings.config.outDir;
for (const integration of settings.config.integrations) {
await runHookInternal({
diff --git a/packages/astro/src/prerender/utils.ts b/packages/astro/src/prerender/utils.ts
index 7c2dd8fcfeb0..eedbdd47543a 100644
--- a/packages/astro/src/prerender/utils.ts
+++ b/packages/astro/src/prerender/utils.ts
@@ -19,5 +19,10 @@ export function getServerOutputDirectory(settings: AstroSettings): URL {
* Returns the correct output directory of the client build based on the configuration
*/
export function getClientOutputDirectory(settings: AstroSettings): URL {
- return settings.buildOutput === 'server' ? settings.config.build.client : settings.config.outDir;
+ const preserveStructure = settings.adapter?.adapterFeatures?.preserveBuildClientDir;
+
+ if (settings.buildOutput === 'server' || preserveStructure) {
+ return settings.config.build.client;
+ }
+ return settings.config.outDir;
}
diff --git a/packages/astro/src/types/public/integrations.ts b/packages/astro/src/types/public/integrations.ts
index 66883143572b..ae5c2392db3e 100644
--- a/packages/astro/src/types/public/integrations.ts
+++ b/packages/astro/src/types/public/integrations.ts
@@ -114,6 +114,15 @@ export interface AstroAdapterFeatures {
* for example, to create a `_headers` file for platforms that support it.
*/
staticHeaders?: boolean;
+
+ /**
+ * When true, static builds will preserve the client/server directory structure
+ * instead of outputting directly to outDir. This ensures static builds use
+ * build.client for assets, maintaining consistency with server builds.
+ * Useful for adapters that require a specific directory structure regardless
+ * of the build output type.
+ */
+ preserveBuildClientDir?: boolean;
}
/**
diff --git a/packages/astro/test/astro-attrs.test.js b/packages/astro/test/astro-attrs.test.js
deleted file mode 100644
index 23d852d37e33..000000000000
--- a/packages/astro/test/astro-attrs.test.js
+++ /dev/null
@@ -1,116 +0,0 @@
-import assert from 'node:assert/strict';
-import { before, describe, it } from 'node:test';
-import * as cheerio from 'cheerio';
-import { loadFixture } from './test-utils.js';
-
-describe('Attributes', async () => {
- let fixture;
-
- before(async () => {
- fixture = await loadFixture({ root: './fixtures/astro-attrs/' });
- await fixture.build();
- });
-
- it('Passes attributes to elements as expected', async () => {
- const html = await fixture.readFile('/index.html');
- const $ = cheerio.load(html);
-
- const attrs = {
- 'download-true': { attribute: 'download', value: '' },
- 'download-false': { attribute: 'download', value: undefined },
- 'download-undefined': { attribute: 'download', value: undefined },
- 'download-string-empty': { attribute: 'download', value: '' },
- 'download-string': { attribute: 'download', value: 'my-document.pdf' },
- 'popover-auto': { attribute: 'popover', value: 'auto' },
- 'popover-true': { attribute: 'popover', value: '' },
- 'popover-false': { attribute: 'popover', value: undefined },
- 'popover-string-empty': { attribute: 'popover', value: '' },
- // Note: cheerio normalizes boolean `hidden` to the string "hidden",
- // so we use "hidden" as the expected value instead of ""
- 'hidden-true': { attribute: 'hidden', value: 'hidden' },
- 'hidden-false': { attribute: 'hidden', value: undefined },
- 'hidden-string-empty': { attribute: 'hidden', value: 'hidden' },
- 'boolean-attr-true': { attribute: 'allowfullscreen', value: '' },
- 'boolean-attr-false': { attribute: 'allowfullscreen', value: undefined },
- 'boolean-attr-string-truthy': { attribute: 'allowfullscreen', value: '' },
- 'boolean-attr-string-falsy': { attribute: 'allowfullscreen', value: undefined },
- 'boolean-attr-number-truthy': { attribute: 'allowfullscreen', value: '' },
- 'boolean-attr-number-falsy': { attribute: 'allowfullscreen', value: undefined },
- 'data-attr-true': { attribute: 'data-foobar', value: 'true' },
- 'data-attr-false': { attribute: 'data-foobar', value: 'false' },
- 'data-attr-string-truthy': { attribute: 'data-foobar', value: 'foo' },
- 'data-attr-string-falsy': { attribute: 'data-foobar', value: '' },
- 'data-attr-number-truthy': { attribute: 'data-foobar', value: '1' },
- 'data-attr-number-falsy': { attribute: 'data-foobar', value: '0' },
- 'normal-attr-true': { attribute: 'foobar', value: 'true' },
- 'normal-attr-false': { attribute: 'foobar', value: 'false' },
- 'normal-attr-string-truthy': { attribute: 'foobar', value: 'foo' },
- 'normal-attr-string-falsy': { attribute: 'foobar', value: '' },
- 'normal-attr-number-truthy': { attribute: 'foobar', value: '1' },
- 'normal-attr-number-falsy': { attribute: 'foobar', value: '0' },
- null: { attribute: 'attr', value: undefined },
- undefined: { attribute: 'attr', value: undefined },
- 'html-enum': { attribute: 'draggable', value: 'true' },
- 'html-enum-true': { attribute: 'draggable', value: 'true' },
- 'html-enum-false': { attribute: 'draggable', value: 'false' },
- };
-
- // cheerio normalizes hidden="until-found" to just hidden, so we check the raw HTML
- assert.ok(
- html.includes('id="hidden-until-found" hidden="until-found"'),
- 'hidden="until-found" should preserve the attribute value',
- );
- assert.ok(!/allowfullscreen=/.test(html), 'boolean attributes should not have values');
- assert.ok(
- !/id="data-attr-string-falsy"\s+data-foobar=/.test(html),
- "data attributes should not have values if it's an empty string",
- );
- assert.ok(
- !/id="normal-attr-string-falsy"\s+data-foobar=/.test(html),
- "normal attributes should not have values if it's an empty string",
- );
-
- // cheerio will unescape the values, so checking that the url rendered escaped has to be done manually
- assert.equal(
- html.includes('https://example.com/api/og?title=hello&description=somedescription'),
- true,
- );
-
- // cheerio will unescape the values, so checking that the url rendered unescaped to begin with has to be done manually
- assert.equal(
- html.includes('cmd: echo "foo" && echo "bar" > /tmp/hello.txt'),
- true,
- );
-
- for (const id of Object.keys(attrs)) {
- const { attribute, value } = attrs[id];
- const attr = $(`#${id}`).attr(attribute);
- assert.equal(attr, value, `Expected ${attribute} to be ${value} for #${id}`);
- }
- });
-
- it('Passes boolean attributes to components as expected', async () => {
- const html = await fixture.readFile('/component/index.html');
- const $ = cheerio.load(html);
-
- assert.equal($('#true').attr('attr'), 'attr-true');
- assert.equal($('#true').attr('type'), 'boolean');
- assert.equal($('#false').attr('attr'), 'attr-false');
- assert.equal($('#false').attr('type'), 'boolean');
- });
-
- it('Passes namespaced attributes as expected', async () => {
- const html = await fixture.readFile('/namespaced/index.html');
- const $ = cheerio.load(html);
-
- assert.equal($('div').attr('xmlns:happy'), 'https://example.com/schemas/happy');
- assert.equal($('img').attr('happy:smile'), 'sweet');
- });
-
- it('Passes namespaced attributes to components as expected', async () => {
- const html = await fixture.readFile('/namespaced-component/index.html');
- const $ = cheerio.load(html);
-
- assert.deepEqual($('span').attr('on:click'), '(event) => console.log(event)');
- });
-});
diff --git a/packages/astro/test/fixtures/astro-attrs/astro.config.mjs b/packages/astro/test/fixtures/astro-attrs/astro.config.mjs
deleted file mode 100644
index e7ce274c003a..000000000000
--- a/packages/astro/test/fixtures/astro-attrs/astro.config.mjs
+++ /dev/null
@@ -1,7 +0,0 @@
-import react from '@astrojs/react';
-import { defineConfig } from 'astro/config';
-
-// https://astro.build/config
-export default defineConfig({
- integrations: [react()],
-});
diff --git a/packages/astro/test/fixtures/astro-attrs/package.json b/packages/astro/test/fixtures/astro-attrs/package.json
deleted file mode 100644
index d0085726d054..000000000000
--- a/packages/astro/test/fixtures/astro-attrs/package.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "name": "@test/astro-attrs",
- "version": "0.0.0",
- "private": true,
- "dependencies": {
- "@astrojs/react": "workspace:*",
- "astro": "workspace:*",
- "react": "^18.3.1",
- "react-dom": "^18.3.1"
- }
-}
diff --git a/packages/astro/test/fixtures/astro-attrs/src/components/NamespacedSpan.astro b/packages/astro/test/fixtures/astro-attrs/src/components/NamespacedSpan.astro
deleted file mode 100644
index bfadf035c518..000000000000
--- a/packages/astro/test/fixtures/astro-attrs/src/components/NamespacedSpan.astro
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/packages/astro/test/fixtures/astro-attrs/src/pages/component.astro b/packages/astro/test/fixtures/astro-attrs/src/pages/component.astro
deleted file mode 100644
index cfdc636c804e..000000000000
--- a/packages/astro/test/fixtures/astro-attrs/src/pages/component.astro
+++ /dev/null
@@ -1,8 +0,0 @@
----
-import Span from '../components/Span.jsx';
----
-
-
-
-
-
diff --git a/packages/astro/test/fixtures/astro-attrs/src/pages/index.astro b/packages/astro/test/fixtures/astro-attrs/src/pages/index.astro
deleted file mode 100644
index 55503e094d82..000000000000
--- a/packages/astro/test/fixtures/astro-attrs/src/pages/index.astro
+++ /dev/null
@@ -1,51 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tmp/hello.txt"} />
-
-
-
-
-
diff --git a/packages/astro/test/fixtures/astro-attrs/src/pages/namespaced-component.astro b/packages/astro/test/fixtures/astro-attrs/src/pages/namespaced-component.astro
deleted file mode 100644
index 5ac53e706122..000000000000
--- a/packages/astro/test/fixtures/astro-attrs/src/pages/namespaced-component.astro
+++ /dev/null
@@ -1,4 +0,0 @@
----
-import NamespacedSpan from '../components/NamespacedSpan.astro'
----
- console.log(event)} />
diff --git a/packages/astro/test/fixtures/astro-attrs/src/pages/namespaced.astro b/packages/astro/test/fixtures/astro-attrs/src/pages/namespaced.astro
deleted file mode 100644
index 4ccbaed03970..000000000000
--- a/packages/astro/test/fixtures/astro-attrs/src/pages/namespaced.astro
+++ /dev/null
@@ -1,3 +0,0 @@
-
-

-
diff --git a/packages/astro/test/fixtures/client-address-node/astro.config.mjs b/packages/astro/test/fixtures/client-address-node/astro.config.mjs
index ad9b3a3b16df..d48e21837c5f 100644
--- a/packages/astro/test/fixtures/client-address-node/astro.config.mjs
+++ b/packages/astro/test/fixtures/client-address-node/astro.config.mjs
@@ -5,4 +5,7 @@ import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'middleware' }),
+ security: {
+ allowedDomains: [{ hostname: 'localhost' }],
+ },
});
diff --git a/packages/astro/test/fixtures/sessions/src/pages/login-safe.ts b/packages/astro/test/fixtures/sessions/src/pages/login-safe.ts
new file mode 100644
index 000000000000..6f5c69ca28a0
--- /dev/null
+++ b/packages/astro/test/fixtures/sessions/src/pages/login-safe.ts
@@ -0,0 +1,8 @@
+import type { APIRoute } from 'astro';
+
+export const POST: APIRoute = async (context) => {
+ const body = await context.request.json() as any;
+ await context.session.regenerate();
+ context.session.set('user', body.username);
+ return Response.json({ username: body.username });
+};
diff --git a/packages/astro/test/fixtures/sessions/src/pages/login.ts b/packages/astro/test/fixtures/sessions/src/pages/login.ts
new file mode 100644
index 000000000000..025cd8fa3856
--- /dev/null
+++ b/packages/astro/test/fixtures/sessions/src/pages/login.ts
@@ -0,0 +1,7 @@
+import type { APIRoute } from 'astro';
+
+export const POST: APIRoute = async (context) => {
+ const body = await context.request.json() as any;
+ context.session.set('user', body.username);
+ return Response.json({ username: body.username });
+};
diff --git a/packages/astro/test/sessions.test.js b/packages/astro/test/sessions.test.js
index b65201e43d60..0ea12c40e3ad 100644
--- a/packages/astro/test/sessions.test.js
+++ b/packages/astro/test/sessions.test.js
@@ -101,6 +101,88 @@ describe('Astro.session', () => {
);
});
+ it('generates a new session ID when cookie value has no server-side data', async () => {
+ const unknownId = 'nonexistent-session-id-12345';
+ const response = await fetchResponse('/login', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ cookie: `astro-session=${unknownId}`,
+ },
+ body: JSON.stringify({ username: 'testuser' }),
+ });
+ assert.equal(response.ok, true);
+ const headers = Array.from(app.setCookieHeaders(response));
+ const sessionId = headers[0].split(';')[0].split('=')[1];
+ // A new ID should be generated since the cookie value had no stored data
+ assert.notEqual(sessionId, unknownId, 'Should not adopt a session ID with no stored data');
+
+ // The original ID should not give access to the new session's data
+ const secondResponse = await fetchResponse('/update', {
+ method: 'GET',
+ headers: {
+ cookie: `astro-session=${unknownId}`,
+ },
+ });
+ const secondData = await secondResponse.json();
+ assert.equal(secondData.previousValue, 'none', 'Original ID should not have session data');
+ });
+
+ it('preserves session ID when cookie value has existing server-side data', async () => {
+ const firstResponse = await fetchResponse('/update');
+ const firstHeaders = Array.from(app.setCookieHeaders(firstResponse));
+ const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1];
+
+ const secondResponse = await fetchResponse('/update', {
+ method: 'GET',
+ headers: {
+ cookie: `astro-session=${firstSessionId}`,
+ },
+ });
+ const secondHeaders = Array.from(app.setCookieHeaders(secondResponse));
+ const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1];
+ assert.equal(secondSessionId, firstSessionId, 'Valid session ID should be preserved');
+ });
+
+ it('regenerate() creates a new ID and cleans up the old session', async () => {
+ const firstResponse = await fetchResponse('/update', {
+ method: 'GET',
+ });
+ const firstHeaders = Array.from(app.setCookieHeaders(firstResponse));
+ const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1];
+
+ const secondResponse = await fetchResponse('/login-safe', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ cookie: `astro-session=${firstSessionId}`,
+ },
+ body: JSON.stringify({ username: 'testuser' }),
+ });
+ assert.equal(secondResponse.ok, true);
+ const secondHeaders = Array.from(app.setCookieHeaders(secondResponse));
+ const secondSessionIds = secondHeaders
+ .filter((h) => h.startsWith('astro-session='))
+ .map((h) => h.split(';')[0].split('=')[1]);
+ const secondSessionId = secondSessionIds[secondSessionIds.length - 1];
+
+ assert.notEqual(secondSessionId, firstSessionId, 'regenerate() should create new ID');
+
+ // Old session ID should no longer have data after regeneration
+ const thirdResponse = await fetchResponse('/update', {
+ method: 'GET',
+ headers: {
+ cookie: `astro-session=${firstSessionId}`,
+ },
+ });
+ const thirdData = await thirdResponse.json();
+ assert.equal(
+ thirdData.previousValue,
+ 'none',
+ 'Old session ID should have no data after regeneration',
+ );
+ });
+
it('can load a session by ID', async () => {
const firstResponse = await fetchResponse('/_actions/addToCart', {
method: 'POST',
diff --git a/packages/astro/test/units/app/astro-attrs.test.js b/packages/astro/test/units/app/astro-attrs.test.js
new file mode 100644
index 000000000000..e6f85d877a6c
--- /dev/null
+++ b/packages/astro/test/units/app/astro-attrs.test.js
@@ -0,0 +1,283 @@
+// @ts-check
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { App } from '../../../dist/core/app/app.js';
+import {
+ createComponent,
+ render,
+ renderComponent,
+ spreadAttributes,
+ addAttribute,
+} from '../../../dist/runtime/server/index.js';
+import * as cheerio from 'cheerio';
+import { createManifest, createRouteInfo } from './test-helpers.js';
+
+const attributesRouteData = {
+ route: '/attributes',
+ component: 'src/pages/attributes.astro',
+ params: [],
+ pathname: '/attributes',
+ distURL: [],
+ pattern: /^\/attributes\/?$/,
+ segments: [[{ content: 'attributes', dynamic: false, spread: false }]],
+ type: 'page',
+ prerender: false,
+ fallbackRoutes: [],
+ isIndex: false,
+ origin: 'project',
+};
+
+const attributesNamespacedRouteData = {
+ route: '/namespaced',
+ component: 'src/pages/namespaced.astro',
+ params: [],
+ pathname: '/namespaced',
+ distURL: [],
+ pattern: /^\/namespaced\/?$/,
+ segments: [[{ content: 'namespaced', dynamic: false, spread: false }]],
+ type: 'page',
+ prerender: false,
+ fallbackRoutes: [],
+ isIndex: false,
+ origin: 'project',
+};
+
+const attributesNamespacedComponentRouteData = {
+ route: '/namespaced-component',
+ component: 'src/pages/namespaced-component.astro',
+ params: [],
+ pathname: '/namespaced-component',
+ distURL: [],
+ pattern: /^\/namespaced-component\/?$/,
+ segments: [[{ content: 'namespaced-component', dynamic: false, spread: false }]],
+ type: 'page',
+ prerender: false,
+ fallbackRoutes: [],
+ isIndex: false,
+ origin: 'project',
+};
+
+const attributesPage = createComponent(() => {
+ return render`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tmp/hello.txt', 'attr')} />
+
+
+
+
+
+ `;
+});
+
+const attributesNamespacedPage = createComponent(() => {
+ return render`
+
+

+
+ `;
+});
+
+const namespacedSpanComponent = createComponent((result, props, slots) => {
+ const Astro = result.createAstro(props, slots);
+
+ return render`
+
+ `;
+});
+
+const attributesNamespacedComponentPage = createComponent((result) => {
+ return render`${renderComponent(result, 'NamespacedSpan', namespacedSpanComponent, {
+ // biome-ignore lint/suspicious/noConsole: allowed
+ 'on:click': /** @type {(e: unknown) => void} */ (event) => console.log(event),
+ })}`;
+});
+
+const pageMap = new Map([
+ [
+ attributesRouteData.component,
+ async () => ({
+ page: async () => ({
+ default: attributesPage,
+ }),
+ }),
+ ],
+ [
+ attributesNamespacedRouteData.component,
+ async () => ({
+ page: async () => ({
+ default: attributesNamespacedPage,
+ }),
+ }),
+ ],
+ [
+ attributesNamespacedComponentRouteData.component,
+ async () => ({
+ page: async () => ({
+ default: attributesNamespacedComponentPage,
+ }),
+ }),
+ ],
+]);
+
+const app = new App(
+ createManifest({
+ // @ts-expect-error routes prop is not yet type-defined
+ routes: [
+ createRouteInfo(attributesRouteData),
+ createRouteInfo(attributesNamespacedRouteData),
+ createRouteInfo(attributesNamespacedComponentRouteData),
+ ],
+ pageMap,
+ }),
+);
+
+describe('Attributes', async () => {
+ it('Passes attributes to elements as expected', async () => {
+ const request = new Request('http://example.com/attributes');
+ const response = await app.render(request);
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ /**
+ * @typedef {Object} TestAttribute
+ * @property {string} attribute
+ * @property {string | undefined} value
+ */
+
+ /** @type {Record} */
+ const attrs = {
+ 'download-true': { attribute: 'download', value: '' },
+ 'download-false': { attribute: 'download', value: undefined },
+ 'download-undefined': { attribute: 'download', value: undefined },
+ 'download-string-empty': { attribute: 'download', value: '' },
+ 'download-string': { attribute: 'download', value: 'my-document.pdf' },
+ 'popover-auto': { attribute: 'popover', value: 'auto' },
+ 'popover-true': { attribute: 'popover', value: '' },
+ 'popover-false': { attribute: 'popover', value: undefined },
+ 'popover-string-empty': { attribute: 'popover', value: '' },
+ // Note: cheerio normalizes boolean `hidden` to the string "hidden",
+ // so we use "hidden" as the expected value instead of ""
+ 'hidden-true': { attribute: 'hidden', value: 'hidden' },
+ 'hidden-false': { attribute: 'hidden', value: undefined },
+ 'hidden-string-empty': { attribute: 'hidden', value: 'hidden' },
+ 'boolean-attr-true': { attribute: 'allowfullscreen', value: '' },
+ 'boolean-attr-false': { attribute: 'allowfullscreen', value: undefined },
+ 'boolean-attr-string-truthy': { attribute: 'allowfullscreen', value: '' },
+ 'boolean-attr-string-falsy': { attribute: 'allowfullscreen', value: undefined },
+ 'boolean-attr-number-truthy': { attribute: 'allowfullscreen', value: '' },
+ 'boolean-attr-number-falsy': { attribute: 'allowfullscreen', value: undefined },
+ 'data-attr-true': { attribute: 'data-foobar', value: 'true' },
+ 'data-attr-false': { attribute: 'data-foobar', value: 'false' },
+ 'data-attr-string-truthy': { attribute: 'data-foobar', value: 'foo' },
+ 'data-attr-string-falsy': { attribute: 'data-foobar', value: '' },
+ 'data-attr-number-truthy': { attribute: 'data-foobar', value: '1' },
+ 'data-attr-number-falsy': { attribute: 'data-foobar', value: '0' },
+ 'normal-attr-true': { attribute: 'foobar', value: 'true' },
+ 'normal-attr-false': { attribute: 'foobar', value: 'false' },
+ 'normal-attr-string-truthy': { attribute: 'foobar', value: 'foo' },
+ 'normal-attr-string-falsy': { attribute: 'foobar', value: '' },
+ 'normal-attr-number-truthy': { attribute: 'foobar', value: '1' },
+ 'normal-attr-number-falsy': { attribute: 'foobar', value: '0' },
+ null: { attribute: 'attr', value: undefined },
+ undefined: { attribute: 'attr', value: undefined },
+ 'html-enum': { attribute: 'draggable', value: 'true' },
+ 'html-enum-true': { attribute: 'draggable', value: 'true' },
+ 'html-enum-false': { attribute: 'draggable', value: 'false' },
+ };
+
+ // cheerio normalizes hidden="until-found" to just hidden, so we check the raw HTML
+ assert.ok(
+ html.includes('id="hidden-until-found" hidden="until-found"'),
+ 'hidden="until-found" should preserve the attribute value',
+ );
+ assert.ok(!html.includes('allowfullscreen='), 'boolean attributes should not have values');
+ assert.ok(
+ !/id="data-attr-string-falsy"\s+data-foobar=/.test(html),
+ "data attributes should not have values if it's an empty string",
+ );
+ assert.ok(
+ !/id="normal-attr-string-falsy"\s+data-foobar=/.test(html),
+ "normal attributes should not have values if it's an empty string",
+ );
+
+ // cheerio will unescape the values, so checking that the url rendered escaped has to be done manually
+ assert.equal(
+ html.includes('https://example.com/api/og?title=hello&description=somedescription'),
+ true,
+ );
+
+ // cheerio will unescape the values, so checking that the url rendered unescaped to begin with has to be done manually
+ assert.equal(
+ html.includes('cmd: echo "foo" && echo "bar" > /tmp/hello.txt'),
+ true,
+ );
+
+ for (const id of Object.keys(attrs)) {
+ const { attribute, value } = attrs[id];
+ const attr = $(`#${id}`).attr(attribute);
+ assert.equal(attr, value, `Expected ${attribute} to be ${value} for #${id}`);
+ }
+ });
+
+ it('Passes namespaced attributes as expected', async () => {
+ const request = new Request('http://example.com/namespaced');
+ const response = await app.render(request);
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ assert.equal($('div').attr('xmlns:happy'), 'https://example.com/schemas/happy');
+ assert.equal($('img').attr('happy:smile'), 'sweet');
+ });
+
+ it('Passes namespaced attributes to components as expected', async () => {
+ const request = new Request('http://example.com/namespaced-component');
+ const response = await app.render(request);
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ assert.deepEqual($('span').attr('on:click'), '(event) => console.log(event)');
+ });
+});
diff --git a/packages/astro/test/units/app/node.test.js b/packages/astro/test/units/app/node.test.js
index 213408915ef5..a7a8d5c9909d 100644
--- a/packages/astro/test/units/app/node.test.js
+++ b/packages/astro/test/units/app/node.test.js
@@ -19,40 +19,186 @@ describe('node', () => {
describe('createRequest', () => {
describe('x-forwarded-for', () => {
it('parses client IP from single-value x-forwarded-for header', () => {
- const result = createRequest({
- ...mockNodeRequest,
- headers: {
- 'x-forwarded-for': '1.1.1.1',
+ const result = createRequest(
+ {
+ ...mockNodeRequest,
+ headers: {
+ host: 'example.com',
+ 'x-forwarded-for': '1.1.1.1',
+ },
},
- });
+ { allowedDomains: [{ hostname: 'example.com' }] },
+ );
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
});
it('parses client IP from multi-value x-forwarded-for header', () => {
- const result = createRequest({
- ...mockNodeRequest,
- headers: {
- 'x-forwarded-for': '1.1.1.1,8.8.8.8',
+ const result = createRequest(
+ {
+ ...mockNodeRequest,
+ headers: {
+ host: 'example.com',
+ 'x-forwarded-for': '1.1.1.1,8.8.8.8',
+ },
},
- });
+ { allowedDomains: [{ hostname: 'example.com' }] },
+ );
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
});
it('parses client IP from multi-value x-forwarded-for header with spaces', () => {
+ const result = createRequest(
+ {
+ ...mockNodeRequest,
+ headers: {
+ host: 'example.com',
+ 'x-forwarded-for': ' 1.1.1.1, 8.8.8.8, 8.8.8.2',
+ },
+ },
+ { allowedDomains: [{ hostname: 'example.com' }] },
+ );
+ assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
+ });
+
+ it('fallbacks to remoteAddress when no x-forwarded-for header is present', () => {
+ const result = createRequest(
+ {
+ ...mockNodeRequest,
+ headers: {
+ host: 'example.com',
+ },
+ },
+ { allowedDomains: [{ hostname: 'example.com' }] },
+ );
+ assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
+ });
+
+ it('ignores x-forwarded-for when no allowedDomains is configured (default)', () => {
const result = createRequest({
...mockNodeRequest,
headers: {
- 'x-forwarded-for': ' 1.1.1.1, 8.8.8.8, 8.8.8.2',
+ host: 'example.com',
+ 'x-forwarded-for': '1.1.1.1',
},
});
+ // Without allowedDomains, x-forwarded-for should NOT be trusted
+ // Falls back to socket remoteAddress
+ assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
+ });
+
+ it('ignores x-forwarded-for when allowedDomains is empty', () => {
+ const result = createRequest(
+ {
+ ...mockNodeRequest,
+ headers: {
+ host: 'example.com',
+ 'x-forwarded-for': '1.1.1.1',
+ },
+ },
+ { allowedDomains: [] },
+ );
+ // Empty allowedDomains means no proxy trust, use socket address
+ assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
+ });
+
+ it('trusts x-forwarded-for when host matches allowedDomains', () => {
+ const result = createRequest(
+ {
+ ...mockNodeRequest,
+ headers: {
+ host: 'example.com',
+ 'x-forwarded-for': '1.1.1.1',
+ },
+ },
+ { allowedDomains: [{ hostname: 'example.com' }] },
+ );
+ // Host matches allowedDomains, so x-forwarded-for is trusted
assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
});
- it('fallbacks to remoteAddress when no x-forwarded-for header is present', () => {
+ it('ignores x-forwarded-for when host does not match allowedDomains', () => {
+ const result = createRequest(
+ {
+ ...mockNodeRequest,
+ headers: {
+ host: 'attacker.com',
+ 'x-forwarded-for': '1.1.1.1',
+ },
+ },
+ { allowedDomains: [{ hostname: 'example.com' }] },
+ );
+ // Host does not match allowedDomains, so x-forwarded-for is NOT trusted
+ assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
+ });
+
+ it('trusts x-forwarded-for when x-forwarded-host matches allowedDomains', () => {
+ const result = createRequest(
+ {
+ ...mockNodeRequest,
+ headers: {
+ 'x-forwarded-host': 'example.com',
+ 'x-forwarded-for': '1.1.1.1',
+ },
+ },
+ { allowedDomains: [{ hostname: 'example.com' }] },
+ );
+ // X-Forwarded-Host validated against allowedDomains, so XFF is trusted
+ assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
+ });
+
+ it('trusts multi-value x-forwarded-for when host matches allowedDomains', () => {
+ const result = createRequest(
+ {
+ ...mockNodeRequest,
+ headers: {
+ host: 'example.com',
+ 'x-forwarded-for': '1.1.1.1, 8.8.8.8',
+ },
+ },
+ { allowedDomains: [{ hostname: 'example.com' }] },
+ );
+ assert.equal(result[Symbol.for('astro.clientAddress')], '1.1.1.1');
+ });
+
+ it('falls back to remoteAddress when host matches allowedDomains but no x-forwarded-for', () => {
+ const result = createRequest(
+ {
+ ...mockNodeRequest,
+ headers: {
+ host: 'example.com',
+ },
+ },
+ { allowedDomains: [{ hostname: 'example.com' }] },
+ );
+ assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
+ });
+
+ it('prevents IP spoofing: attacker cannot override clientAddress without allowedDomains', () => {
+ // Simulates an attacker injecting x-forwarded-for to spoof 127.0.0.1
const result = createRequest({
...mockNodeRequest,
- headers: {},
+ headers: {
+ host: 'example.com',
+ 'x-forwarded-for': '127.0.0.1',
+ },
});
+ // Without allowedDomains, the spoofed IP must be ignored
+ assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
+ });
+
+ it('prevents IP spoofing: attacker cannot override clientAddress when host does not match', () => {
+ // Simulates attacker sending direct request with XFF and mismatched host
+ const result = createRequest(
+ {
+ ...mockNodeRequest,
+ headers: {
+ host: 'evil.com',
+ 'x-forwarded-for': '127.0.0.1',
+ },
+ },
+ { allowedDomains: [{ hostname: 'example.com' }] },
+ );
+ // Host doesn't match allowedDomains, so XFF is not trusted
assert.equal(result[Symbol.for('astro.clientAddress')], '2.2.2.2');
});
});
diff --git a/packages/astro/test/units/build/preserve-build-client-dir.test.js b/packages/astro/test/units/build/preserve-build-client-dir.test.js
new file mode 100644
index 000000000000..b723a8335fc6
--- /dev/null
+++ b/packages/astro/test/units/build/preserve-build-client-dir.test.js
@@ -0,0 +1,59 @@
+import * as assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { getOutFolder } from '../../../dist/core/build/common.js';
+import { getClientOutputDirectory } from '../../../dist/prerender/utils.js';
+import { createSettings } from './test-helpers.js';
+
+describe('preserveBuildClientDir', () => {
+ const outDir = new URL('file:///project/dist/');
+ const clientDir = new URL('file:///project/dist/client/');
+
+ describe('getClientOutputDirectory', () => {
+ it('returns outDir for static builds without preserveBuildClientDir', () => {
+ const settings = createSettings({ buildOutput: 'static' });
+ const result = getClientOutputDirectory(settings);
+ assert.equal(result.href, outDir.href);
+ });
+
+ it('returns client dir for static builds with preserveBuildClientDir', () => {
+ const settings = createSettings({ buildOutput: 'static', preserveBuildClientDir: true });
+ const result = getClientOutputDirectory(settings);
+ assert.equal(result.href, clientDir.href);
+ });
+
+ it('returns client dir for server builds regardless of preserveBuildClientDir', () => {
+ const settings = createSettings({ buildOutput: 'server' });
+ const result = getClientOutputDirectory(settings);
+ assert.equal(result.href, clientDir.href);
+ });
+ });
+
+ describe('getOutFolder', () => {
+ const pageRoute = { type: 'page', isIndex: false };
+
+ it('outputs to outDir for static builds without preserveBuildClientDir', () => {
+ const settings = createSettings({ buildOutput: 'static' });
+ const result = getOutFolder(settings, '/about', pageRoute);
+ assert.equal(result.href, new URL('about/', outDir).href);
+ });
+
+ it('outputs to client dir for static builds with preserveBuildClientDir', () => {
+ const settings = createSettings({ buildOutput: 'static', preserveBuildClientDir: true });
+ const result = getOutFolder(settings, '/about', pageRoute);
+ assert.equal(result.href, new URL('about/', clientDir).href);
+ });
+
+ it('outputs to client dir for server builds regardless of preserveBuildClientDir', () => {
+ const settings = createSettings({ buildOutput: 'server' });
+ const result = getOutFolder(settings, '/about', pageRoute);
+ assert.equal(result.href, new URL('about/', clientDir).href);
+ });
+
+ it('outputs root index to client dir with preserveBuildClientDir', () => {
+ const settings = createSettings({ buildOutput: 'static', preserveBuildClientDir: true });
+ const indexRoute = { type: 'page', isIndex: true };
+ const result = getOutFolder(settings, '/', indexRoute);
+ assert.equal(result.href, new URL('./', clientDir).href);
+ });
+ });
+});
diff --git a/packages/astro/test/units/build/test-helpers.js b/packages/astro/test/units/build/test-helpers.js
new file mode 100644
index 000000000000..582706e9cae2
--- /dev/null
+++ b/packages/astro/test/units/build/test-helpers.js
@@ -0,0 +1,31 @@
+// @ts-check
+
+/**
+ * @param {object} options
+ * @param {'static' | 'server'} options.buildOutput
+ * @param {boolean} [options.preserveBuildClientDir]
+ * @param {URL} [options.outDir]
+ * @param {URL} [options.clientDir]
+ * @param {'directory' | 'file' | 'preserve'} [options.buildFormat]
+ */
+export function createSettings({
+ buildOutput,
+ preserveBuildClientDir = false,
+ outDir = new URL('file:///project/dist/'),
+ clientDir = new URL('file:///project/dist/client/'),
+ buildFormat = 'directory',
+}) {
+ return {
+ buildOutput,
+ adapter: preserveBuildClientDir
+ ? { adapterFeatures: { preserveBuildClientDir: true } }
+ : undefined,
+ config: {
+ outDir,
+ build: {
+ client: clientDir,
+ format: buildFormat,
+ },
+ },
+ };
+}
diff --git a/packages/astro/test/units/middleware/middleware-app.test.js b/packages/astro/test/units/middleware/middleware-app.test.js
index 80013bd41892..284edbc88281 100644
--- a/packages/astro/test/units/middleware/middleware-app.test.js
+++ b/packages/astro/test/units/middleware/middleware-app.test.js
@@ -585,6 +585,182 @@ describe('Middleware via App.render()', () => {
});
});
+ describe('cookies on error pages', () => {
+ it('should preserve cookies set by middleware when returning Response(null, { status: 404 })', async () => {
+ // Middleware sets a cookie and returns 404 with null body (common auth guard pattern)
+ const onRequest = async (ctx, next) => {
+ ctx.cookies.set('session', 'abc123', { path: '/' });
+ if (ctx.url.pathname.startsWith('/api/guarded')) {
+ return new Response(null, { status: 404 });
+ }
+ return next();
+ };
+
+ const guardedRouteData = createRouteData({
+ route: '/api/guarded/[...path]',
+ pathname: undefined,
+ segments: undefined,
+ });
+ // Override for spread route
+ 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'), {
+ addCookieHeader: true,
+ });
+
+ assert.equal(response.status, 404);
+ const setCookie = response.headers.get('set-cookie');
+ assert.ok(setCookie, 'Expected Set-Cookie header to be present on 404 error page response');
+ assert.match(setCookie, /session=abc123/);
+ });
+
+ it('should preserve cookies set by middleware when returning Response(null, { status: 500 })', async () => {
+ const onRequest = async (ctx, next) => {
+ ctx.cookies.set('csrf', 'token456', { path: '/' });
+ if (ctx.url.pathname.startsWith('/api/error')) {
+ return new Response(null, { status: 500 });
+ }
+ return next();
+ };
+
+ 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'), {
+ addCookieHeader: true,
+ });
+
+ assert.equal(response.status, 500);
+ const setCookie = response.headers.get('set-cookie');
+ assert.ok(setCookie, 'Expected Set-Cookie header to be present on 500 error page response');
+ assert.match(setCookie, /csrf=token456/);
+ });
+
+ it('should preserve multiple cookies from sequenced middleware during error page rerouting', async () => {
+ const onRequest = async (ctx, next) => {
+ ctx.cookies.set('session', 'abc123', { path: '/' });
+ ctx.cookies.set('csrf', 'token456', { path: '/' });
+ if (ctx.url.pathname.startsWith('/api/guarded')) {
+ ctx.cookies.set('auth_attempt', 'failed', { path: '/' });
+ return new Response(null, { status: 404 });
+ }
+ return next();
+ };
+
+ 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'), {
+ addCookieHeader: true,
+ });
+
+ assert.equal(response.status, 404);
+ const setCookies = response.headers.getSetCookie();
+ const cookieValues = setCookies.join(', ');
+ assert.ok(
+ cookieValues.includes('session=abc123'),
+ 'Expected session cookie in Set-Cookie headers',
+ );
+ assert.ok(
+ cookieValues.includes('csrf=token456'),
+ 'Expected csrf cookie in Set-Cookie headers',
+ );
+ assert.ok(
+ cookieValues.includes('auth_attempt=failed'),
+ 'Expected auth_attempt cookie in Set-Cookie headers',
+ );
+ });
+ });
+
describe('middleware with custom headers', () => {
it('should correctly set custom headers in middleware', async () => {
const onRequest = async (_ctx, next) => {
diff --git a/packages/astro/test/units/redirects/open-redirect.test.js b/packages/astro/test/units/redirects/open-redirect.test.js
new file mode 100644
index 000000000000..61f27b612014
--- /dev/null
+++ b/packages/astro/test/units/redirects/open-redirect.test.js
@@ -0,0 +1,102 @@
+// @ts-check
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { redirectIsExternal } from '../../../dist/core/redirects/render.js';
+import { getRouteGenerator } from '../../../dist/core/routing/generator.js';
+
+describe('Protocol-relative URLs in redirects', () => {
+ describe('redirectIsExternal', () => {
+ it('detects http:// as external', () => {
+ assert.equal(redirectIsExternal('http://evil.com'), true);
+ });
+
+ it('detects https:// as external', () => {
+ assert.equal(redirectIsExternal('https://evil.com'), true);
+ });
+
+ it('detects protocol-relative //evil.com as external', () => {
+ assert.equal(redirectIsExternal('//evil.com'), true);
+ });
+
+ it('detects protocol-relative //evil.com/path as external', () => {
+ assert.equal(redirectIsExternal('//evil.com/path'), true);
+ });
+
+ it('does not flag normal paths as external', () => {
+ assert.equal(redirectIsExternal('/about'), false);
+ });
+
+ it('does not flag root path as external', () => {
+ assert.equal(redirectIsExternal('/'), false);
+ });
+
+ it('detects protocol-relative URL in object form', () => {
+ assert.equal(redirectIsExternal({ destination: '//evil.com', status: 301 }), true);
+ });
+ });
+
+ describe('getRouteGenerator with root-level catch-all', () => {
+ it('does not produce protocol-relative URL when catch-all param contains leading slash', () => {
+ // Simulates destination '/[...slug]' — a single root-level catch-all segment
+ const segments = [[{ spread: true, content: '...slug', dynamic: true }]];
+ const generator = getRouteGenerator(segments, 'never');
+
+ // When the request is '/old//evil.com/', the catch-all captures '/evil.com'
+ const result = generator({ slug: '/evil.com' });
+
+ // The result must NOT be '//evil.com' (protocol-relative URL)
+ assert.ok(
+ !result.startsWith('//'),
+ `Expected result to not start with '//', got '${result}'`,
+ );
+ });
+
+ it('does not produce protocol-relative URL with trailing slash config', () => {
+ const segments = [[{ spread: true, content: '...slug', dynamic: true }]];
+ const generator = getRouteGenerator(segments, 'always');
+
+ const result = generator({ slug: '/evil.com' });
+
+ assert.ok(
+ !result.startsWith('//'),
+ `Expected result to not start with '//', got '${result}'`,
+ );
+ });
+
+ it('does not produce protocol-relative URL with subpath', () => {
+ const segments = [[{ spread: true, content: '...slug', dynamic: true }]];
+ const generator = getRouteGenerator(segments, 'never');
+
+ const result = generator({ slug: '/evil.com/phish' });
+
+ assert.ok(
+ !result.startsWith('//'),
+ `Expected result to not start with '//', got '${result}'`,
+ );
+ });
+
+ it('still produces correct paths for normal params', () => {
+ const segments = [[{ spread: true, content: '...slug', dynamic: true }]];
+ const generator = getRouteGenerator(segments, 'never');
+
+ assert.equal(generator({ slug: 'about' }), '/about');
+ assert.equal(generator({ slug: 'docs/getting-started' }), '/docs/getting-started');
+ });
+
+ it('non-root catch-all is not affected', () => {
+ // Simulates destination '/new/[...slug]' — catch-all under a prefix
+ const segments = [
+ [{ spread: false, content: 'new', dynamic: false }],
+ [{ spread: true, content: '...slug', dynamic: true }],
+ ];
+ const generator = getRouteGenerator(segments, 'never');
+
+ // Even with a leading-slash param, the prefix prevents protocol-relative
+ const result = generator({ slug: '/evil.com' });
+ assert.ok(
+ !result.startsWith('//'),
+ `Expected result to not start with '//', got '${result}'`,
+ );
+ });
+ });
+});
diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts
index bfd8927f5774..d10c07887801 100644
--- a/packages/integrations/cloudflare/src/index.ts
+++ b/packages/integrations/cloudflare/src/index.ts
@@ -1,5 +1,5 @@
import { createReadStream, existsSync, readFileSync } from 'node:fs';
-import { appendFile, rm, stat } from 'node:fs/promises';
+import { appendFile, stat } from 'node:fs/promises';
import { createInterface } from 'node:readline/promises';
import { removeLeadingForwardSlash } from '@astrojs/internal-helpers/path';
import { createRedirectsFromAstroRoutes, printAsRedirects } from '@astrojs/underscore-redirects';
@@ -191,6 +191,7 @@ export default function createIntegration({
'astro/app',
'astro/assets',
'astro/compiler-runtime',
+ 'astro/app/entrypoint/dev',
],
exclude: [
'unstorage/drivers/cloudflare-kv-binding',
@@ -278,6 +279,7 @@ export default function createIntegration({
adapterFeatures: {
buildOutput: 'server',
middlewareMode: 'classic',
+ preserveBuildClientDir: true,
},
entrypointResolution: 'auto',
previewEntrypoint: '@astrojs/cloudflare/entrypoints/preview',
@@ -411,10 +413,8 @@ export default function createIntegration({
}
}
- // For fully static sites, remove the worker directory as it's not needed
- if (_isFullyStatic) {
- await rm(_config.build.server, { recursive: true, force: true });
- }
+ // For fully static sites with preserveBuildClientDir, we keep the server directory
+ // to maintain consistent structure for deployment
// Delete this variable so the preview server opens the server build.
delete process.env.CLOUDFLARE_VITE_BUILD;
diff --git a/packages/integrations/node/src/serve-static.ts b/packages/integrations/node/src/serve-static.ts
index 917a24db5c6c..34843e93e2ce 100644
--- a/packages/integrations/node/src/serve-static.ts
+++ b/packages/integrations/node/src/serve-static.ts
@@ -8,6 +8,32 @@ import { resolveClientDir } from './shared.js';
import type { NodeAppHeadersJson, Options } from './types.js';
import { createRequest } from 'astro/app/node';
+/**
+ * Resolves a URL path to a filesystem path within the client directory,
+ * and checks whether it is a directory.
+ *
+ * Returns `isDirectory: false` if the resolved path escapes the client root
+ * (e.g. via `..` path traversal segments).
+ */
+export function resolveStaticPath(client: string, urlPath: string) {
+ const filePath = path.join(client, urlPath);
+ const resolved = path.resolve(filePath);
+ const resolvedClient = path.resolve(client);
+
+ // Prevent path traversal: if the resolved path is outside the client
+ // directory, treat it as non-existent rather than probing the filesystem.
+ if (resolved !== resolvedClient && !resolved.startsWith(resolvedClient + path.sep)) {
+ return { filePath: resolved, isDirectory: false };
+ }
+
+ let isDirectory = false;
+ try {
+ isDirectory = fs.lstatSync(filePath).isDirectory();
+ } catch {}
+
+ return { filePath: resolved, isDirectory };
+}
+
/**
* Creates a Node.js http listener for static files and prerendered pages.
* In standalone mode, the static handler is queried first for the static files.
@@ -32,12 +58,7 @@ export function createStaticHandler(
}
const [urlPath, urlQuery] = fullUrl.split('?');
- const filePath = path.join(client, app.removeBase(urlPath));
-
- let isDirectory = false;
- try {
- isDirectory = fs.lstatSync(filePath).isDirectory();
- } catch {}
+ const { isDirectory } = resolveStaticPath(client, app.removeBase(urlPath));
const hasSlash = urlPath.endsWith('/');
let pathname = urlPath;
diff --git a/packages/integrations/node/test/units/serve-static-path-traversal.test.js b/packages/integrations/node/test/units/serve-static-path-traversal.test.js
new file mode 100644
index 000000000000..6265450330a4
--- /dev/null
+++ b/packages/integrations/node/test/units/serve-static-path-traversal.test.js
@@ -0,0 +1,59 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { describe, it, before, after } from 'node:test';
+import { resolveStaticPath } from '../../dist/serve-static.js';
+
+describe('resolveStaticPath', () => {
+ let tmpRoot;
+ let clientDir;
+
+ before(() => {
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'astro-test-'));
+ clientDir = path.join(tmpRoot, 'client');
+ fs.mkdirSync(clientDir);
+ fs.mkdirSync(path.join(clientDir, 'assets'));
+ fs.writeFileSync(path.join(clientDir, 'index.html'), 'hello
');
+ fs.mkdirSync(path.join(tmpRoot, 'secret'));
+ });
+
+ after(() => {
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+ });
+
+ it('detects a subdirectory within client root', () => {
+ const result = resolveStaticPath(clientDir, '/assets');
+ assert.equal(result.isDirectory, true);
+ });
+
+ it('returns false for a non-existent path', () => {
+ const result = resolveStaticPath(clientDir, '/nope');
+ assert.equal(result.isDirectory, false);
+ });
+
+ it('returns false for a sibling directory via ../', () => {
+ const result = resolveStaticPath(clientDir, '/../secret');
+ assert.equal(result.isDirectory, false);
+ });
+
+ it('returns false for the parent directory', () => {
+ const result = resolveStaticPath(clientDir, '/..');
+ assert.equal(result.isDirectory, false);
+ });
+
+ it('returns false for deep .. traversal', () => {
+ const result = resolveStaticPath(clientDir, '/../../../../../../../usr');
+ assert.equal(result.isDirectory, false);
+ });
+
+ it('returns false for .. traversal with trailing slash', () => {
+ const result = resolveStaticPath(clientDir, '/../secret/');
+ assert.equal(result.isDirectory, false);
+ });
+
+ it('detects the client root itself', () => {
+ const result = resolveStaticPath(clientDir, '/');
+ assert.equal(result.isDirectory, true);
+ });
+});
diff --git a/packages/astro/test/fixtures/astro-attrs/src/components/Span.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/Span.jsx
similarity index 100%
rename from packages/astro/test/fixtures/astro-attrs/src/components/Span.jsx
rename to packages/integrations/react/test/fixtures/react-component/src/components/Span.jsx
diff --git a/packages/integrations/react/test/fixtures/react-component/src/pages/index.astro b/packages/integrations/react/test/fixtures/react-component/src/pages/index.astro
index b3b95c4b3b60..0eea1008a40c 100644
--- a/packages/integrations/react/test/fixtures/react-component/src/pages/index.astro
+++ b/packages/integrations/react/test/fixtures/react-component/src/pages/index.astro
@@ -6,6 +6,7 @@ import Hello from '../components/Hello.jsx';
import PropsSpread from '../components/PropsSpread.jsx';
import Pure from '../components/Pure.jsx';
import {Research2} from '../components/Research.jsx';
+import Span from '../components/Span.jsx';
import TypeScriptComponent from '../components/TypeScriptComponent';
import WithChildren from '../components/WithChildren';
import WithId from '../components/WithId';
@@ -37,5 +38,7 @@ const someProps = {
+
+