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
7 changes: 7 additions & 0 deletions .changeset/fix-cloudflare-static-output.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/harden-merge-responses-cookies.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/harden-xff-allowed-domains.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions .changeset/preserve-directory-structure.md
Original file line number Diff line number Diff line change
@@ -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
}
});
}
}
};
}
```
5 changes: 5 additions & 0 deletions .changeset/red-quail-bite.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/redirect-catch-all-hardening.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/soft-vans-ask.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/node': patch
---

Hardens static file handler path resolution to ensure resolved paths stay within the client directory
49 changes: 40 additions & 9 deletions packages/astro/src/core/app/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -726,16 +731,24 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
// 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<string>();
// 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.
Expand All @@ -745,6 +758,24 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
// 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 {
Expand Down
8 changes: 7 additions & 1 deletion packages/astro/src/core/app/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion packages/astro/src/core/build/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion packages/astro/src/core/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 5 additions & 3 deletions packages/astro/src/core/build/static-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 6 additions & 4 deletions packages/astro/src/core/redirects/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/core/routing/generator.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -41,7 +42,7 @@ function getParameter(part: RoutePart, params: Record<string, string | number>):
function getSegment(segment: RoutePart[], params: Record<string, string | number>): string {
const segmentPath = segment.map((part) => getParameter(part, params)).join('');

return segmentPath ? '/' + segmentPath : '';
return segmentPath ? collapseDuplicateLeadingSlashes('/' + segmentPath) : '';
}

type RouteGenerator = (data?: any) => string;
Expand Down
23 changes: 22 additions & 1 deletion packages/astro/src/core/session/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<any[]>(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;
Expand Down Expand Up @@ -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;
}

Expand Down
5 changes: 4 additions & 1 deletion packages/astro/src/integrations/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
7 changes: 6 additions & 1 deletion packages/astro/src/prerender/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
9 changes: 9 additions & 0 deletions packages/astro/src/types/public/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
Loading