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
19 changes: 19 additions & 0 deletions .changeset/fix-middleware-createcontext-ip-spoofing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'astro': minor
'@astrojs/vercel': patch
'@astrojs/netlify': patch
---

Adds a new `clientAddress` option to the `createContext()` function

Providing this value gives adapter and middleware authors explicit control over the client IP address. When not provided, accessing `clientAddress` throws an error consistent with other contexts where it is not set by the adapter.

Additionally, both of the official Netlify and Vercel adapters have been updated to provide this information in their edge middleware.

```js
import { createContext } from "astro/middleware";

createContext({
clientAddress: context.headers.get("x-real-ip")
})
```
17 changes: 17 additions & 0 deletions .changeset/harden-server-islands-body-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'astro': minor
---

Adds a new `security.serverIslandBodySizeLimit` configuration option

Server island POST endpoints now enforce a body size limit, similar to the existing `security.actionBodySizeLimit` for Actions. The new option defaults to `1048576` (1 MB) and can be configured independently.

Requests exceeding the limit are rejected with a 413 response. You can customize the limit in your Astro config:

```js
export default defineConfig({
security: {
serverIslandBodySizeLimit: 2097152, // 2 MB
},
})
```
5 changes: 5 additions & 0 deletions .changeset/hardened-cookie-parsing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Hardens internal cookie parsing to use a null-prototype object consistently for the fallback path, aligning with how the cookie library handles parsed values
5 changes: 5 additions & 0 deletions .changeset/warm-worms-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Reverts changes made to TSConfig templates
8 changes: 4 additions & 4 deletions examples/basics/src/components/Welcome.astro
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,16 @@ import background from '../assets/background.svg';
</section>
</main>

<a href="https://astro.build/blog/astro-5/" id="news" class="box">
<a href="https://astro.build/blog/astro-6-beta/" id="news" class="box">
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
><path
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
fill="#111827"></path></svg
>
<h2>What's New in Astro 5.0?</h2>
<h2>What's New in Astro 6.0?</h2>
<p>
From content layers to server islands, click to learn more about the new features and
improvements in Astro 5.0
Redesigned dev server, fonts, live collections, built-in CSP support, and more! Click to
explore Astro 6.0's new features.
</p>
</a>
</div>
Expand Down
75 changes: 29 additions & 46 deletions packages/astro/src/actions/runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '../../core/errors/errors-data.js';
import { AstroError } from '../../core/errors/errors.js';
import { removeTrailingForwardSlash } from '../../core/path.js';
import { BodySizeLimitError, readBodyWithLimit } from '../../core/request-body.js';
import type { APIContext } from '../../types/public/index.js';
import { ACTION_QUERY_PARAMS, ACTION_RPC_ROUTE_PATTERN } from '../consts.js';
import {
Expand Down Expand Up @@ -267,26 +268,36 @@ async function parseRequestBody(request: Request, bodySizeLimit: number) {
message: `Request body exceeds ${bodySizeLimit} bytes`,
});
}
if (hasContentType(contentType, formContentTypes)) {
if (!hasContentLength) {
const body = await readRequestBodyWithLimit(request.clone(), bodySizeLimit);
const formRequest = new Request(request.url, {
method: request.method,
headers: request.headers,
body: toArrayBuffer(body),
});
return await formRequest.formData();
try {
if (hasContentType(contentType, formContentTypes)) {
if (!hasContentLength) {
const body = await readBodyWithLimit(request.clone(), bodySizeLimit);
const formRequest = new Request(request.url, {
method: request.method,
headers: request.headers,
body: toArrayBuffer(body),
});
return await formRequest.formData();
}
return await request.clone().formData();
}
return await request.clone().formData();
}
if (hasContentType(contentType, ['application/json'])) {
if (contentLength === 0) return undefined;
if (!hasContentLength) {
const body = await readRequestBodyWithLimit(request.clone(), bodySizeLimit);
if (body.byteLength === 0) return undefined;
return JSON.parse(new TextDecoder().decode(body));
if (hasContentType(contentType, ['application/json'])) {
if (contentLength === 0) return undefined;
if (!hasContentLength) {
const body = await readBodyWithLimit(request.clone(), bodySizeLimit);
if (body.byteLength === 0) return undefined;
return JSON.parse(new TextDecoder().decode(body));
}
return await request.clone().json();
}
return await request.clone().json();
} catch (e) {
if (e instanceof BodySizeLimitError) {
throw new ActionError({
code: 'CONTENT_TOO_LARGE',
message: `Request body exceeds ${bodySizeLimit} bytes`,
});
}
throw e;
}
throw new TypeError('Unsupported content type');
}
Expand Down Expand Up @@ -471,34 +482,6 @@ export function serializeActionResult(res: SafeResult<any, any>): SerializedActi
body,
};
}
async function readRequestBodyWithLimit(request: Request, limit: number): Promise<Uint8Array> {
if (!request.body) return new Uint8Array();
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
received += value.byteLength;
if (received > limit) {
throw new ActionError({
code: 'CONTENT_TOO_LARGE',
message: `Request body exceeds ${limit} bytes`,
});
}
chunks.push(value);
}
}
const buffer = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
buffer.set(chunk, offset);
offset += chunk.byteLength;
}
return buffer;
}

function toArrayBuffer(buffer: Uint8Array): ArrayBuffer {
const copy = new Uint8Array(buffer.byteLength);
copy.set(buffer);
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/container/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ function createManifest(
checkOrigin: false,
allowedDomains: manifest?.allowedDomains ?? [],
actionBodySizeLimit: 1024 * 1024,
serverIslandBodySizeLimit: 1024 * 1024,
middleware: manifest?.middleware ?? middlewareInstance,
key: createKey(),
csp: manifest?.csp,
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export type SSRManifest = {
checkOrigin: boolean;
allowedDomains?: Partial<RemotePattern>[];
actionBodySizeLimit: number;
serverIslandBodySizeLimit: number;
sessionConfig?: SSRManifestSession;
cacheConfig?: SSRManifestCache;
cacheDir: URL;
Expand Down
4 changes: 4 additions & 0 deletions packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,10 @@ async function buildManifest(
settings.config.security?.actionBodySizeLimit && settings.buildOutput === 'server'
? settings.config.security.actionBodySizeLimit
: 1024 * 1024,
serverIslandBodySizeLimit:
settings.config.security?.serverIslandBodySizeLimit && settings.buildOutput === 'server'
? settings.config.security.serverIslandBodySizeLimit
: 1024 * 1024,
allowedDomains: settings.config.security?.allowedDomains,
key: encodedKey,
sessionConfig: sessionConfigToManifest(settings.config.session),
Expand Down
5 changes: 5 additions & 0 deletions packages/astro/src/core/config/schemas/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export const ASTRO_CONFIG_DEFAULTS = {
allowedDomains: [],
csp: false,
actionBodySizeLimit: 1024 * 1024,
serverIslandBodySizeLimit: 1024 * 1024,
},
env: {
schema: {},
Expand Down Expand Up @@ -445,6 +446,10 @@ export const AstroConfigSchema = z.object({
.number()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.security.actionBodySizeLimit),
serverIslandBodySizeLimit: z
.number()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.security.serverIslandBodySizeLimit),
csp: z
.union([
z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.security.csp),
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/cookies/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ class AstroCookies implements AstroCookiesInterface {
this.#parse();
}
if (!this.#requestValues) {
this.#requestValues = {};
this.#requestValues = Object.create(null) as Record<string, string | undefined>;
}
return this.#requestValues;
}
Expand Down
21 changes: 12 additions & 9 deletions packages/astro/src/core/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { DisabledAstroCache } from '../cache/runtime/noop.js';
import { ASTRO_GENERATOR } from '../constants.js';
import { AstroCookies } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { getClientIpAddress } from '@astrojs/internal-helpers/request';
import { getOriginPathname } from '../routing/rewrite.js';
import { sequence } from './sequence.js';

Expand Down Expand Up @@ -41,6 +40,14 @@ export type CreateContext = {
* Initial value of the locals
*/
locals?: App.Locals;

/**
* The client IP address. Must be provided by the adapter or platform from a
* trusted source (e.g. socket address, platform-provided header).
*
* If not provided, accessing `context.clientAddress` will throw an error.
*/
clientAddress?: string;
};

/**
Expand All @@ -52,11 +59,11 @@ function createContext({
userDefinedLocales = [],
defaultLocale = '',
locals = {},
clientAddress,
}: CreateContext): APIContext {
let preferredLocale: string | undefined = undefined;
let preferredLocaleList: string[] | undefined = undefined;
let currentLocale: string | undefined = undefined;
let clientIpAddress: string | undefined;
const url = new URL(request.url);
const route = url.pathname;

Expand Down Expand Up @@ -97,14 +104,10 @@ function createContext({
return getOriginPathname(request);
},
get clientAddress() {
if (clientIpAddress) {
return clientIpAddress;
}
clientIpAddress = getClientIpAddress(request);
if (!clientIpAddress) {
throw new AstroError(AstroErrorData.StaticClientAddressNotAvailable);
if (clientAddress) {
return clientAddress;
}
return clientIpAddress;
throw new AstroError(AstroErrorData.StaticClientAddressNotAvailable);
},
get locals() {
if (typeof locals !== 'object') {
Expand Down
54 changes: 54 additions & 0 deletions packages/astro/src/core/request-body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Shared utility for reading request bodies with a size limit.
* Used by both Actions and Server Islands to enforce `security.actionBodySizeLimit`
* and `security.serverIslandBodySizeLimit` respectively.
*/

/**
* Read the request body as a `Uint8Array`, enforcing a maximum size limit.
* Checks the `Content-Length` header for early rejection, then streams the body
* and tracks bytes received.
*
* @throws {BodySizeLimitError} if the body exceeds the configured limit
*/
export async function readBodyWithLimit(request: Request, limit: number): Promise<Uint8Array> {
const contentLengthHeader = request.headers.get('content-length');
if (contentLengthHeader) {
const contentLength = Number.parseInt(contentLengthHeader, 10);
if (Number.isFinite(contentLength) && contentLength > limit) {
throw new BodySizeLimitError(limit);
}
}

if (!request.body) return new Uint8Array();
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
received += value.byteLength;
if (received > limit) {
throw new BodySizeLimitError(limit);
}
chunks.push(value);
}
}
const buffer = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
buffer.set(chunk, offset);
offset += chunk.byteLength;
}
return buffer;
}

export class BodySizeLimitError extends Error {
limit: number;
constructor(limit: number) {
super(`Request body exceeds the configured limit of ${limit} bytes`);
this.name = 'BodySizeLimitError';
this.limit = limit;
}
}
19 changes: 16 additions & 3 deletions packages/astro/src/core/server-islands/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createSlotValueFromString } from '../../runtime/server/render/slot.js';
import type { ComponentInstance, RoutesList } from '../../types/astro.js';
import type { RouteData, SSRManifest } from '../../types/public/internal.js';
import { decryptString } from '../encryption.js';
import { BodySizeLimitError, readBodyWithLimit } from '../request-body.js';
import { getPattern } from '../routing/pattern.js';

export const SERVER_ISLAND_ROUTE = '/_server-islands/[name]';
Expand Down Expand Up @@ -54,7 +55,12 @@ function badRequest(reason: string) {
});
}

export async function getRequestData(request: Request): Promise<Response | RenderOptions> {
const DEFAULT_BODY_SIZE_LIMIT = 1024 * 1024; // 1MB

export async function getRequestData(
request: Request,
bodySizeLimit: number = DEFAULT_BODY_SIZE_LIMIT,
): Promise<Response | RenderOptions> {
switch (request.method) {
case 'GET': {
const url = new URL(request.url);
Expand All @@ -73,7 +79,8 @@ export async function getRequestData(request: Request): Promise<Response | Rende
}
case 'POST': {
try {
const raw = await request.text();
const body = await readBodyWithLimit(request, bodySizeLimit);
const raw = new TextDecoder().decode(body);
const data = JSON.parse(raw);

// Validate that slots is not plaintext
Expand All @@ -90,6 +97,12 @@ export async function getRequestData(request: Request): Promise<Response | Rende

return data as RenderOptions;
} catch (e) {
if (e instanceof BodySizeLimitError) {
return new Response(null, {
status: 413,
statusText: e.message,
});
}
if (e instanceof SyntaxError) {
return badRequest('Request format is invalid.');
}
Expand All @@ -115,7 +128,7 @@ export function createEndpoint(manifest: SSRManifest) {
const componentId = params.name;

// Get the request data from the body or search params
const data = await getRequestData(result.request);
const data = await getRequestData(result.request, manifest.serverIslandBodySizeLimit);
// probably error
if (data instanceof Response) {
return data;
Expand Down
3 changes: 3 additions & 0 deletions packages/astro/src/manifest/serialized.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ async function createSerializedManifest(settings: AstroSettings): Promise<Serial
actionBodySizeLimit: settings.config.security?.actionBodySizeLimit
? settings.config.security.actionBodySizeLimit
: 1024 * 1024, // 1mb default
serverIslandBodySizeLimit: settings.config.security?.serverIslandBodySizeLimit
? settings.config.security.serverIslandBodySizeLimit
: 1024 * 1024, // 1mb default
key: await encodeKey(hasEnvironmentKey() ? await getEnvironmentKey() : await createKey()),
sessionConfig: sessionConfigToManifest(settings.config.session),
cacheConfig: cacheConfigToManifest(
Expand Down
Loading
Loading