diff --git a/.changeset/fix-middleware-createcontext-ip-spoofing.md b/.changeset/fix-middleware-createcontext-ip-spoofing.md new file mode 100644 index 000000000000..edfeed408efd --- /dev/null +++ b/.changeset/fix-middleware-createcontext-ip-spoofing.md @@ -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") +}) +``` diff --git a/.changeset/harden-server-islands-body-limit.md b/.changeset/harden-server-islands-body-limit.md new file mode 100644 index 000000000000..5fa80df610ec --- /dev/null +++ b/.changeset/harden-server-islands-body-limit.md @@ -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 + }, +}) +``` diff --git a/.changeset/hardened-cookie-parsing.md b/.changeset/hardened-cookie-parsing.md new file mode 100644 index 000000000000..442c0bd602f9 --- /dev/null +++ b/.changeset/hardened-cookie-parsing.md @@ -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 diff --git a/.changeset/warm-worms-talk.md b/.changeset/warm-worms-talk.md new file mode 100644 index 000000000000..e9ee37aaecac --- /dev/null +++ b/.changeset/warm-worms-talk.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Reverts changes made to TSConfig templates diff --git a/examples/basics/src/components/Welcome.astro b/examples/basics/src/components/Welcome.astro index 52e03334146b..1b2cf9c416d0 100644 --- a/examples/basics/src/components/Welcome.astro +++ b/examples/basics/src/components/Welcome.astro @@ -27,16 +27,16 @@ import background from '../assets/background.svg'; - + -

What's New in Astro 5.0?

+

What's New in Astro 6.0?

- 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.

diff --git a/packages/astro/src/actions/runtime/server.ts b/packages/astro/src/actions/runtime/server.ts index 5daeaf803dcc..635a76c348d7 100644 --- a/packages/astro/src/actions/runtime/server.ts +++ b/packages/astro/src/actions/runtime/server.ts @@ -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 { @@ -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'); } @@ -471,34 +482,6 @@ export function serializeActionResult(res: SafeResult): SerializedActi body, }; } -async function readRequestBodyWithLimit(request: Request, limit: number): Promise { - 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); diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts index 23c95fe85dfb..6e1eb38025dd 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -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, diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index b96f65f28365..692fac82ea4b 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -117,6 +117,7 @@ export type SSRManifest = { checkOrigin: boolean; allowedDomains?: Partial[]; actionBodySizeLimit: number; + serverIslandBodySizeLimit: number; sessionConfig?: SSRManifestSession; cacheConfig?: SSRManifestCache; cacheDir: URL; diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 63488435ace7..758e6f971a2b 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -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), diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index 041860ce08b6..9bc4d2133fb0 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -95,6 +95,7 @@ export const ASTRO_CONFIG_DEFAULTS = { allowedDomains: [], csp: false, actionBodySizeLimit: 1024 * 1024, + serverIslandBodySizeLimit: 1024 * 1024, }, env: { schema: {}, @@ -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), diff --git a/packages/astro/src/core/cookies/cookies.ts b/packages/astro/src/core/cookies/cookies.ts index 5ef4988c7ae5..e99d69443452 100644 --- a/packages/astro/src/core/cookies/cookies.ts +++ b/packages/astro/src/core/cookies/cookies.ts @@ -253,7 +253,7 @@ class AstroCookies implements AstroCookiesInterface { this.#parse(); } if (!this.#requestValues) { - this.#requestValues = {}; + this.#requestValues = Object.create(null) as Record; } return this.#requestValues; } diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts index e882ce58cb89..d4919ed53ae8 100644 --- a/packages/astro/src/core/middleware/index.ts +++ b/packages/astro/src/core/middleware/index.ts @@ -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'; @@ -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; }; /** @@ -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; @@ -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') { diff --git a/packages/astro/src/core/request-body.ts b/packages/astro/src/core/request-body.ts new file mode 100644 index 000000000000..ef2e118066a0 --- /dev/null +++ b/packages/astro/src/core/request-body.ts @@ -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 { + 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; + } +} diff --git a/packages/astro/src/core/server-islands/endpoint.ts b/packages/astro/src/core/server-islands/endpoint.ts index 96ba58f8fadd..7b2c4d9bd58b 100644 --- a/packages/astro/src/core/server-islands/endpoint.ts +++ b/packages/astro/src/core/server-islands/endpoint.ts @@ -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]'; @@ -54,7 +55,12 @@ function badRequest(reason: string) { }); } -export async function getRequestData(request: Request): Promise { +const DEFAULT_BODY_SIZE_LIMIT = 1024 * 1024; // 1MB + +export async function getRequestData( + request: Request, + bodySizeLimit: number = DEFAULT_BODY_SIZE_LIMIT, +): Promise { switch (request.method) { case 'GET': { const url = new URL(request.url); @@ -73,7 +79,8 @@ export async function getRequestData(request: Request): Promise ({ - name: 'vite-plugin-param-type', - resolveId: { - filter: { - id: /^virtual:my:param:type$/, - }, - handler() { - return '\0virtual:my:param:type'; - }, - }, - load: { - filter: { - id: /^\0virtual:my:param:type$/, - }, - handler() { - return `export const paramType = ${JSON.stringify(type)}`; - }, - }, -}); - -function resetFlags() { - // reset the flag used by [...calledTwiceTest].astro between each test - globalThis.isCalledOnce = false; - - // reset the flag used by [...invalidParamsTypeTest].astro between each test - globalThis.getStaticPathsParamsType = undefined; -} - describe('getStaticPaths - build calls', () => { /** @type {import('./test-utils').Fixture} */ let fixture; @@ -44,22 +16,10 @@ describe('getStaticPaths - build calls', () => { site: 'https://mysite.dev/', trailingSlash: 'never', base: '/blog', - vite: { - plugins: [paramsTypePlugin()], - }, }); await fixture.build({}); }); - afterEach(() => { - resetFlags(); - }); - - it('is only called once during build', () => { - // useless expect; if build() throws in setup then this test fails - assert.equal(true, true); - }); - it('Astro.url sets the current pathname', async () => { const html = await fixture.readFile('/food/tacos/index.html'); const $ = cheerio.load(html); @@ -78,31 +38,16 @@ describe('getStaticPaths - dev calls', () => { fixture = await loadFixture({ root, site: 'https://mysite.dev/', - vite: { - plugins: [paramsTypePlugin()], - }, }); devServer = await fixture.startDevServer(); }); - afterEach(() => { - resetFlags(); - }); - after(async () => { await devServer.stop(); }); - it('only calls getStaticPaths once', async function () { - let res = await fixture.fetch('/a'); - assert.equal(res.status, 200); - - res = await fixture.fetch('/b'); - assert.equal(res.status, 200); - - res = await fixture.fetch('/c'); - assert.equal(res.status, 200); - }); + // Caching behavior has been moved to unit tests + // See: test/units/routing/getstaticpaths-cache.test.js describe('404 behavior', () => { it('resolves 200 on matching static path - named params', async () => { @@ -206,9 +151,6 @@ describe('throws if an invalid Astro property is accessed', () => { fixture = await loadFixture({ root, site: 'https://mysite.dev/', - vite: { - plugins: [paramsTypePlugin()], - }, }); await fixture.editFile( '/src/pages/food/[name].astro', @@ -232,87 +174,3 @@ describe('throws if an invalid Astro property is accessed', () => { } }); }); - -describe('throws if an invalid params type is returned', () => { - /** - * @param type {string} - */ - const build = async (type) => { - try { - globalThis.getStaticPathsParamsType = type; - const fixture = await loadFixture({ - root, - site: 'https://mysite.dev/', - vite: { - plugins: [paramsTypePlugin(type)], - }, - }); - await fixture.build({}); - } catch (err) { - return err; - } finally { - resetFlags(); - } - }; - - // Valid types - it('does build for param type string', async () => { - const err = await build('string'); - assert.equal(err, undefined); - }); - - it('does build for param type undefined', async () => { - const err = await build('undefined'); - assert.equal(err, undefined); - }); - - // Invalid types - it('does not build for param type number', async () => { - const err = await build('number'); - assert.equal(err instanceof Error, true); - // @ts-ignore - assert.equal(err.title, 'Invalid route parameter returned by `getStaticPaths()`.'); - }); - - it('does not build for param type boolean', async () => { - const err = await build('boolean'); - assert.equal(err instanceof Error, true); - // @ts-ignore - assert.equal(err.title, 'Invalid route parameter returned by `getStaticPaths()`.'); - }); - - it('does not build for param type array', async () => { - const err = await build('array'); - assert.equal(err instanceof Error, true); - // @ts-ignore - assert.equal(err.title, 'Invalid route parameter returned by `getStaticPaths()`.'); - }); - - it('does not build for param type null', async () => { - const err = await build('null'); - assert.equal(err instanceof Error, true); - // @ts-ignore - assert.equal(err.title, 'Invalid route parameter returned by `getStaticPaths()`.'); - }); - - it('does not build for param type object', async () => { - const err = await build('object'); - assert.equal(err instanceof Error, true); - // @ts-ignore - assert.equal(err.title, 'Invalid route parameter returned by `getStaticPaths()`.'); - }); - - it('does not build for param type bigint', async () => { - const err = await build('bigint'); - assert.equal(err instanceof Error, true); - // @ts-ignore - assert.equal(err.title, 'Invalid route parameter returned by `getStaticPaths()`.'); - }); - - it('does not build for param type function', async () => { - const err = await build('function'); - assert.equal(err instanceof Error, true); - // @ts-ignore - assert.equal(err.title, 'Invalid route parameter returned by `getStaticPaths()`.'); - }); -}); diff --git a/packages/astro/test/fixtures/astro-get-static-paths/src/pages/[...calledTwiceTest].astro b/packages/astro/test/fixtures/astro-get-static-paths/src/pages/[...calledTwiceTest].astro deleted file mode 100644 index 08b6af30c085..000000000000 --- a/packages/astro/test/fixtures/astro-get-static-paths/src/pages/[...calledTwiceTest].astro +++ /dev/null @@ -1,21 +0,0 @@ ---- -export function getStaticPaths({ paginate }) { - if (globalThis.isCalledOnce) { - throw new Error("Can only be called once!"); - } - globalThis.isCalledOnce = true; - return [ - {params: {calledTwiceTest: 'a'}}, - {params: {calledTwiceTest: 'b'}}, - {params: {calledTwiceTest: 'c'}}, - ]; -} -const { params } = Astro; ---- - - - - Page {params.calledTwiceTest} - - - diff --git a/packages/astro/test/fixtures/astro-get-static-paths/src/pages/[...invalidParamsTypeTest].astro b/packages/astro/test/fixtures/astro-get-static-paths/src/pages/[...invalidParamsTypeTest].astro deleted file mode 100644 index 0861c0a032f1..000000000000 --- a/packages/astro/test/fixtures/astro-get-static-paths/src/pages/[...invalidParamsTypeTest].astro +++ /dev/null @@ -1,34 +0,0 @@ ---- -// @ts-expect-error virtual mod -import { paramType } from 'virtual:my:param:type'; - -export function getStaticPaths () { - const map = { - // Valid types - string: "foo", - undefined: undefined, - - // Invalid types - number: 123, - boolean: false, - array: [1, 2, 3], - null: null, - object: {a: 1}, - bigint: BigInt(123), - function: setTimeout - } - if (!map.hasOwnProperty(paramType)) { - throw new Error(`Invalid type: ${paramType}`); - } - - return [{ - params: { - invalidParamsTypeTest: map[paramType] - } - }]; -}; ---- - -
-

Invalid Params Type Test

-
diff --git a/packages/astro/test/redirects.test.js b/packages/astro/test/redirects.test.js index 46592114eb09..bf33fd79493e 100644 --- a/packages/astro/test/redirects.test.js +++ b/packages/astro/test/redirects.test.js @@ -30,22 +30,6 @@ describe('Astro.redirect', () => { await fixture.build(); }); - it('Returns a 302 status', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/secret'); - const response = await app.render(request); - assert.equal(response.status, 302); - assert.equal(response.headers.get('location'), '/login'); - }); - - it('Allows external redirect', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/external/redirect'); - const response = await app.render(request); - assert.equal(response.status, 301); - assert.equal(response.headers.get('location'), 'https://example.com/'); - }); - it('Warns when used inside a component', async () => { const app = await fixture.loadTestAdapterApp(); const request = new Request('http://example.com/late'); @@ -60,56 +44,6 @@ describe('Astro.redirect', () => { ); } }); - - describe('Redirects config', () => { - it('Returns the redirect', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/api/redirect'); - const response = await app.render(request); - assert.equal(response.status, 301); - assert.equal(response.headers.get('Location'), '/test'); - }); - - it('Uses 308 for non-GET methods', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/api/redirect', { - method: 'POST', - }); - const response = await app.render(request); - assert.equal(response.status, 308); - }); - - it('Forwards params to the target path - single param', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/source/x'); - const response = await app.render(request); - assert.equal(response.headers.get('Location'), '/not-verbatim/target1/x'); - }); - - it('Forwards params to the target path - multiple params', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/source/x/y'); - const response = await app.render(request); - assert.equal(response.headers.get('Location'), '/not-verbatim/target2/x/y'); - }); - - it('Forwards params to the target path - spread param', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/source/x/y/z'); - const response = await app.render(request); - assert.equal(response.headers.get('Location'), '/not-verbatim/target3/x/y/z'); - }); - - it('Forwards params to the target path - special characters', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/source/Las Vegas’'); - const response = await app.render(request); - assert.equal( - response.headers.get('Location'), - '/not-verbatim/target1/Las%20Vegas%E2%80%99', - ); - }); - }); }); describe('config.build.redirects = false', () => { @@ -194,39 +128,6 @@ describe('Astro.redirect output: "static"', () => { await fixture.build(); }); - it("Minifies the HTML emitted when a page that doesn't exist is emitted", async () => { - const html = await fixture.readFile('/old/index.html'); - assert.equal(html.includes('\n'), false); - }); - - it('Includes the meta refresh tag in Astro.redirect pages', async () => { - const html = await fixture.readFile('/secret/index.html'); - assert.equal(html.includes('http-equiv="refresh'), true); - assert.equal(html.includes('url=/login'), true); - }); - - it('Includes the meta noindex tag', async () => { - const html = await fixture.readFile('/secret/index.html'); - assert.equal(html.includes('name="robots'), true); - assert.equal(html.includes('content="noindex'), true); - }); - - it('Includes a link to the new pages for bots to follow', async () => { - const html = await fixture.readFile('/secret/index.html'); - assert.equal(html.includes(''), true); - }); - - it('Includes a canonical link', async () => { - const html = await fixture.readFile('/secret/index.html'); - assert.equal(html.includes(''), true); - }); - - it('A 302 status generates a "temporary redirect" through a short delay', async () => { - // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh - const html = await fixture.readFile('/secret/index.html'); - assert.equal(html.includes('content="2;url=/login"'), true); - }); - it('Includes the meta refresh tag in `redirect` config pages', async () => { let html = await fixture.readFile('/one/index.html'); assert.equal(html.includes('http-equiv="refresh'), true); diff --git a/packages/astro/test/units/app/test-helpers.js b/packages/astro/test/units/app/test-helpers.js index 9a48dd887dd7..dad316d1f328 100644 --- a/packages/astro/test/units/app/test-helpers.js +++ b/packages/astro/test/units/app/test-helpers.js @@ -47,6 +47,7 @@ export function createManifest({ checkOrigin: false, allowedDomains: undefined, actionBodySizeLimit: 0, + serverIslandBodySizeLimit: 1024 * 1024, sessionConfig: undefined, cacheDir: rootDir, srcDir: rootDir, diff --git a/packages/astro/test/units/cookies/get.test.js b/packages/astro/test/units/cookies/get.test.js index 2312f1f3fe02..6fb0b06bd875 100644 --- a/packages/astro/test/units/cookies/get.test.js +++ b/packages/astro/test/units/cookies/get.test.js @@ -56,6 +56,16 @@ describe('astro/src/core/cookies', () => { assert.equal(cookie, undefined); }); + it('does not return values from Object.prototype when no cookie header is present', () => { + const req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + // These are properties that exist on Object.prototype + assert.equal(cookies.get('toString'), undefined); + assert.equal(cookies.get('constructor'), undefined); + assert.equal(cookies.get('hasOwnProperty'), undefined); + assert.equal(cookies.get('valueOf'), undefined); + }); + it('handles malformed cookie values gracefully', () => { // Test with invalid URI sequence (e.g., incomplete percent encoding) const req = new Request('http://example.com/', { diff --git a/packages/astro/test/units/cookies/has.test.js b/packages/astro/test/units/cookies/has.test.js index 36df2a1c46a2..03460e11e575 100644 --- a/packages/astro/test/units/cookies/has.test.js +++ b/packages/astro/test/units/cookies/has.test.js @@ -26,5 +26,14 @@ describe('astro/src/core/cookies', () => { cookies.set('foo', 'bar'); assert.equal(cookies.has('foo'), true); }); + + it('returns false for Object.prototype properties when no cookie header is present', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + assert.equal(cookies.has('toString'), false); + assert.equal(cookies.has('constructor'), false); + assert.equal(cookies.has('hasOwnProperty'), false); + assert.equal(cookies.has('valueOf'), false); + }); }); }); diff --git a/packages/astro/test/units/i18n/i18n-middleware.test.js b/packages/astro/test/units/i18n/i18n-middleware.test.js index 11667ac25a6c..7b0f57c18340 100644 --- a/packages/astro/test/units/i18n/i18n-middleware.test.js +++ b/packages/astro/test/units/i18n/i18n-middleware.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, it } from 'node:test'; import { createI18nMiddleware } from '../../../dist/i18n/middleware.js'; -import { createMockAPIContext } from '../middleware/test-helpers.js'; +import { createMockAPIContext } from '../mocks.js'; /** * Creates a "page" response that mimics what the render pipeline returns. diff --git a/packages/astro/test/units/middleware/call-middleware.test.js b/packages/astro/test/units/middleware/call-middleware.test.js index b83692ad9346..f73d92962bfc 100644 --- a/packages/astro/test/units/middleware/call-middleware.test.js +++ b/packages/astro/test/units/middleware/call-middleware.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { describe, it, beforeEach } from 'node:test'; import { callMiddleware } from '../../../dist/core/middleware/callMiddleware.js'; -import { createMockAPIContext, createResponseFunction } from './test-helpers.js'; +import { createMockAPIContext, createResponseFunction } from '../mocks.js'; describe('callMiddleware', () => { /** @type {import('astro').APIContext} */ diff --git a/packages/astro/test/units/middleware/middleware-app.test.js b/packages/astro/test/units/middleware/middleware-app.test.js index f8de122ab568..02b5d968d30e 100644 --- a/packages/astro/test/units/middleware/middleware-app.test.js +++ b/packages/astro/test/units/middleware/middleware-app.test.js @@ -3,7 +3,8 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { App } from '../../../dist/core/app/app.js'; import { createComponent, render } from '../../../dist/runtime/server/index.js'; -import { createManifest, createRouteData } from './test-helpers.js'; +import { createRouteData } from '../mocks.js'; +import { createManifest } from '../app/test-helpers.js'; /** * Helper: creates an App with the given middleware and routes. diff --git a/packages/astro/test/units/middleware/sequence.test.js b/packages/astro/test/units/middleware/sequence.test.js index ac8a88909478..452881c1273d 100644 --- a/packages/astro/test/units/middleware/sequence.test.js +++ b/packages/astro/test/units/middleware/sequence.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, it } from 'node:test'; import { callMiddleware } from '../../../dist/core/middleware/callMiddleware.js'; import { sequence } from '../../../dist/core/middleware/sequence.js'; -import { createMockAPIContext, createResponseFunction } from './test-helpers.js'; +import { createMockAPIContext, createResponseFunction } from '../mocks.js'; describe('sequence', () => { /** @type {import('astro').APIContext} */ diff --git a/packages/astro/test/units/middleware/test-helpers.js b/packages/astro/test/units/mocks.js similarity index 65% rename from packages/astro/test/units/middleware/test-helpers.js rename to packages/astro/test/units/mocks.js index ddc9c5dfa753..d36df4cbadcb 100644 --- a/packages/astro/test/units/middleware/test-helpers.js +++ b/packages/astro/test/units/mocks.js @@ -1,8 +1,54 @@ -// @ts-check -import { AstroCookies } from '../../../dist/core/cookies/index.js'; -import { makeRoute, staticPart } from '../routing/test-helpers.js'; +import { createBasicPipeline } from './test-utils.js'; +import { makeRoute, staticPart } from './routing/test-helpers.js'; +import { AstroCookies } from '../../dist/core/cookies/index.js'; -export { createManifest } from '../app/test-helpers.js'; +/** + * Mock utilities for unit tests. + * + * This file contains lightweight mock functions for unit testing Astro internals. + * For integration tests that need full structures, use the test-helpers.js files + * in their respective directories. + */ + +/** + * Creates a minimal RenderContext mock for unit testing redirect functions. + * + * This is a lightweight mock that provides only what renderRedirect() needs, + * without the overhead of creating a full RenderContext instance. + * + * @param {object} overrides - Properties to override + * @param {Request} [overrides.request] - The request object + * @param {object} [overrides.routeData] - Route data including redirect config + * @param {Record} [overrides.params] - Route parameters + * @param {object} [overrides.pipeline] - Pipeline instance + * @returns {object} A mock render context suitable for testing renderRedirect + * + * @example + * const context = createMockRenderContext({ + * request: new Request('http://localhost/source'), + * routeData: { type: 'redirect', redirect: '/target' }, + * params: { slug: 'my-post' } + * }); + */ +export function createMockRenderContext(overrides = {}) { + const pipeline = + overrides.pipeline || + createBasicPipeline({ + manifest: { + rootDir: import.meta.url, + experimentalQueuedRendering: { enabled: true }, + trailingSlash: 'never', + }, + }); + + return { + request: overrides.request || new Request('http://localhost/'), + routeData: overrides.routeData || {}, + params: overrides.params || {}, + pipeline, + ...overrides, + }; +} /** * Creates a mock APIContext suitable for calling middleware directly via `callMiddleware()`. diff --git a/packages/astro/test/units/redirects/render.test.js b/packages/astro/test/units/redirects/render.test.js new file mode 100644 index 000000000000..dd75982c2a86 --- /dev/null +++ b/packages/astro/test/units/redirects/render.test.js @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { redirectIsExternal, renderRedirect } from '../../../dist/core/redirects/render.js'; +import { createMockRenderContext } from '../mocks.js'; + +describe('redirects/render', () => { + describe('redirectIsExternal', () => { + it('returns true for http:// URLs', () => { + assert.equal(redirectIsExternal('http://example.com'), true); + assert.equal(redirectIsExternal('http://example.com/path'), true); + }); + + it('returns true for https:// URLs', () => { + assert.equal(redirectIsExternal('https://example.com'), true); + assert.equal(redirectIsExternal('https://example.com/path'), true); + }); + + it('returns false for relative URLs', () => { + assert.equal(redirectIsExternal('/path'), false); + assert.equal(redirectIsExternal('./path'), false); + assert.equal(redirectIsExternal('../path'), false); + assert.equal(redirectIsExternal('path'), false); + }); + + it('handles redirect objects with external destinations', () => { + assert.equal(redirectIsExternal({ destination: 'https://example.com', status: 301 }), true); + assert.equal(redirectIsExternal({ destination: 'http://example.com', status: 302 }), true); + }); + + it('handles redirect objects with relative destinations', () => { + assert.equal(redirectIsExternal({ destination: '/path', status: 301 }), false); + assert.equal(redirectIsExternal({ destination: './path', status: 302 }), false); + }); + }); + + describe('renderRedirect', () => { + it('returns 301 for GET requests', async () => { + const renderContext = createMockRenderContext({ + request: new Request('http://localhost/source'), + routeData: { + type: 'redirect', + redirect: '/target', + }, + }); + + const response = await renderRedirect(renderContext); + + assert.equal(response.status, 301); + assert.equal(response.headers.get('location'), '/target'); + }); + + it('returns 308 for non-GET requests', async () => { + const renderContext = createMockRenderContext({ + request: new Request('http://localhost/source', { method: 'POST' }), + routeData: { + type: 'redirect', + redirect: '/target', + }, + }); + + const response = await renderRedirect(renderContext); + + assert.equal(response.status, 308); + assert.equal(response.headers.get('location'), '/target'); + }); + + it('handles redirect object with custom status', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: { destination: '/target', status: 302 }, + redirectRoute: { + segments: [[{ content: 'target', dynamic: false, spread: false }]], + }, + }, + }); + + const response = await renderRedirect(renderContext); + + assert.equal(response.status, 302); + }); + + it('encodes URIs properly', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: '/target with spaces', + }, + }); + + const response = await renderRedirect(renderContext); + + assert.equal(response.headers.get('location'), '/target%20with%20spaces'); + }); + + it('handles external redirects', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: 'https://example.com', + }, + }); + + const response = await renderRedirect(renderContext); + + assert.equal(response.status, 301); + // External redirects use Response.redirect which sets the Location header differently + assert.equal(response.headers.get('location'), 'https://example.com/'); + }); + + it('substitutes single dynamic parameter', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: '/articles/[slug]', + }, + params: { slug: 'my-post' }, + }); + + const response = await renderRedirect(renderContext); + + assert.equal(response.headers.get('location'), '/articles/my-post'); + }); + + it('substitutes multiple dynamic parameters', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: '/new/[param1]/[param2]', + }, + params: { param1: 'foo', param2: 'bar' }, + }); + + const response = await renderRedirect(renderContext); + + assert.equal(response.headers.get('location'), '/new/foo/bar'); + }); + + it('substitutes spread parameters', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: '/new/[...rest]', + }, + params: { rest: 'a/b/c' }, + }); + + const response = await renderRedirect(renderContext); + + assert.equal(response.headers.get('location'), '/new/a/b/c'); + }); + + it('encodes special characters in parameters', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: '/new/[city]', + }, + params: { city: 'Las Vegas\u2019' }, + }); + + const response = await renderRedirect(renderContext); + + assert.equal(response.headers.get('location'), '/new/Las%20Vegas%E2%80%99'); + }); + + it('uses redirectRoute when available', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: '/not-used', + redirectRoute: { + segments: [[{ content: 'target', dynamic: false, spread: false }]], + pathname: '/target', + }, + }, + }); + + const response = await renderRedirect(renderContext); + + assert.equal(response.headers.get('location'), '/target'); + }); + + it('falls back to "/" when no redirect is defined', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: undefined, + }, + }); + + const response = await renderRedirect(renderContext); + + assert.equal(response.headers.get('location'), '/'); + }); + }); +}); diff --git a/packages/astro/test/units/redirects/template.test.js b/packages/astro/test/units/redirects/template.test.js new file mode 100644 index 000000000000..26da2255a157 --- /dev/null +++ b/packages/astro/test/units/redirects/template.test.js @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { redirectTemplate } from '../../../dist/core/routing/3xx.js'; + +describe('redirects/template', () => { + it('generates correct HTML structure', () => { + const html = redirectTemplate({ + status: 301, + absoluteLocation: 'https://example.com/new-page', + relativeLocation: '/new-page', + }); + + const $ = cheerio.load(html); + + // Check DOCTYPE + assert.ok(html.startsWith('')); + + // Check title + assert.equal($('title').text(), 'Redirecting to: /new-page'); + + // Check meta refresh tag + const metaRefresh = $('meta[http-equiv="refresh"]'); + assert.equal(metaRefresh.length, 1); + assert.equal(metaRefresh.attr('content'), '0;url=/new-page'); + + // Check robots meta tag + const metaRobots = $('meta[name="robots"]'); + assert.equal(metaRobots.length, 1); + assert.equal(metaRobots.attr('content'), 'noindex'); + + // Check canonical link + const canonical = $('link[rel="canonical"]'); + assert.equal(canonical.length, 1); + assert.equal(canonical.attr('href'), 'https://example.com/new-page'); + + // Check body content + const link = $('body a'); + assert.equal(link.length, 1); + assert.equal(link.attr('href'), '/new-page'); + assert.ok(link.html().includes('Redirecting')); + assert.ok(link.html().includes('/new-page')); + }); + + it('uses 2 second delay for 302 redirects', () => { + const html = redirectTemplate({ + status: 302, + absoluteLocation: 'https://example.com/temp', + relativeLocation: '/temp', + }); + + const $ = cheerio.load(html); + const metaRefresh = $('meta[http-equiv="refresh"]'); + assert.equal(metaRefresh.attr('content'), '2;url=/temp'); + }); + + it('uses 0 second delay for non-302 redirects', () => { + // Test 301 + let html = redirectTemplate({ + status: 301, + absoluteLocation: 'https://example.com/perm', + relativeLocation: '/perm', + }); + + let $ = cheerio.load(html); + let metaRefresh = $('meta[http-equiv="refresh"]'); + assert.equal(metaRefresh.attr('content'), '0;url=/perm'); + + // Test 308 + html = redirectTemplate({ + status: 308, + absoluteLocation: 'https://example.com/perm', + relativeLocation: '/perm', + }); + + $ = cheerio.load(html); + metaRefresh = $('meta[http-equiv="refresh"]'); + assert.equal(metaRefresh.attr('content'), '0;url=/perm'); + }); + + it('includes "from" information when provided', () => { + const html = redirectTemplate({ + status: 301, + absoluteLocation: 'https://example.com/new', + relativeLocation: '/new', + from: '/old', + }); + + const $ = cheerio.load(html); + const bodyText = $('body').html(); + assert.ok(bodyText.includes('from /old')); + assert.ok(bodyText.includes('to /new')); + }); + + it('omits "from" text when not provided', () => { + const html = redirectTemplate({ + status: 301, + absoluteLocation: 'https://example.com/new', + relativeLocation: '/new', + }); + + const $ = cheerio.load(html); + const bodyText = $('body').html(); + assert.ok(!bodyText.includes('from ')); + assert.ok(bodyText.includes('to /new')); + }); + + it('handles special characters in URLs', () => { + const html = redirectTemplate({ + status: 301, + absoluteLocation: 'https://example.com/page?foo=bar&baz=qux', + relativeLocation: '/page?foo=bar&baz=qux', + }); + + const $ = cheerio.load(html); + + // Title should show the URL as-is + assert.equal($('title').text(), 'Redirecting to: /page?foo=bar&baz=qux'); + + // Meta refresh should preserve the URL structure + const metaRefresh = $('meta[http-equiv="refresh"]'); + assert.equal(metaRefresh.attr('content'), '0;url=/page?foo=bar&baz=qux'); + + // Link href should be properly escaped + const link = $('body a'); + assert.equal(link.attr('href'), '/page?foo=bar&baz=qux'); + }); + + it('handles external URLs in relative location', () => { + const html = redirectTemplate({ + status: 301, + absoluteLocation: 'https://external.com/', + relativeLocation: 'https://external.com/', + }); + + const $ = cheerio.load(html); + + // Should use the external URL in all places + assert.equal($('title').text(), 'Redirecting to: https://external.com/'); + assert.equal($('meta[http-equiv="refresh"]').attr('content'), '0;url=https://external.com/'); + assert.equal($('link[rel="canonical"]').attr('href'), 'https://external.com/'); + assert.equal($('body a').attr('href'), 'https://external.com/'); + }); + + it('handles URL object for absoluteLocation', () => { + const html = redirectTemplate({ + status: 301, + absoluteLocation: new URL('https://example.com/page'), + relativeLocation: '/page', + }); + + const $ = cheerio.load(html); + + // Should convert URL object to string + assert.equal($('link[rel="canonical"]').attr('href'), 'https://example.com/page'); + }); +}); diff --git a/packages/astro/test/units/routing/api-context.test.js b/packages/astro/test/units/routing/api-context.test.js index 027a70f8bf13..7a7077c5d1f7 100644 --- a/packages/astro/test/units/routing/api-context.test.js +++ b/packages/astro/test/units/routing/api-context.test.js @@ -3,27 +3,57 @@ import { describe, it } from 'node:test'; import { createContext } from '../../../dist/core/middleware/index.js'; describe('createAPIContext', () => { - it('should return the clientAddress', () => { - const request = new Request('http://example.com', { - headers: { - 'x-forwarded-for': '192.0.2.43, 172.16.58.3', - }, - }); + it('should return the clientAddress when explicitly provided', () => { + const request = new Request('http://example.com'); const context = createContext({ request, + clientAddress: '192.0.2.43', }); assert.equal(context.clientAddress, '192.0.2.43'); }); - it('should return the correct locals', () => { + it('should throw when clientAddress is not provided', () => { + const request = new Request('http://example.com'); + + const context = createContext({ + request, + }); + + assert.throws( + () => context.clientAddress, + (err) => { + assert.equal(err.name, 'StaticClientAddressNotAvailable'); + return true; + }, + ); + }); + + it('should not read clientAddress from x-forwarded-for header', () => { const request = new Request('http://example.com', { headers: { 'x-forwarded-for': '192.0.2.43, 172.16.58.3', }, }); + const context = createContext({ + request, + }); + + // Should throw instead of reading from the header + assert.throws( + () => context.clientAddress, + (err) => { + assert.equal(err.name, 'StaticClientAddressNotAvailable'); + return true; + }, + ); + }); + + it('should return the correct locals', () => { + const request = new Request('http://example.com'); + const context = createContext({ request, locals: { diff --git a/packages/astro/test/units/routing/getstaticpaths-cache.test.js b/packages/astro/test/units/routing/getstaticpaths-cache.test.js new file mode 100644 index 000000000000..7b747328f708 --- /dev/null +++ b/packages/astro/test/units/routing/getstaticpaths-cache.test.js @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { describe, it, before, beforeEach } from 'node:test'; +import { Logger } from '../../../dist/core/logger/core.js'; +import { RouteCache, callGetStaticPaths } from '../../../dist/core/render/route-cache.js'; +import { dynamicPart, makeRoute } from './test-helpers.js'; + +describe('getStaticPaths caching behavior', () => { + let routeCache; + let logger; + let callCount; + + before(() => { + logger = new Logger({ dest: 'memory', level: 'error' }); + }); + + beforeEach(() => { + routeCache = new RouteCache(logger, 'production'); + callCount = 0; + }); + + it('only calls getStaticPaths once and caches the result', async () => { + const route = makeRoute({ + segments: [[dynamicPart('param')]], + trailingSlash: 'never', + route: '/[param]', + pathname: undefined, + type: 'page', + prerender: true, + }); + + const mod = { + default: () => {}, + getStaticPaths: async () => { + callCount++; + return [{ params: { param: 'a' } }, { params: { param: 'b' } }, { params: { param: 'c' } }]; + }, + }; + + // First call should execute getStaticPaths + const result1 = await callGetStaticPaths({ + mod, + route, + routeCache, + ssr: false, + base: '/', + trailingSlash: 'never', + }); + + assert.equal(callCount, 1, 'getStaticPaths should be called once'); + assert.equal(result1.length, 3, 'should return all paths'); + + // Second call should use cache + const result2 = await callGetStaticPaths({ + mod, + route, + routeCache, + ssr: false, + base: '/', + trailingSlash: 'never', + }); + + assert.equal(callCount, 1, 'getStaticPaths should not be called again'); + assert.equal(result2.length, 3, 'should return cached paths'); + assert.deepEqual(result1, result2, 'cached result should match original'); + + // Third call should also use cache + const result3 = await callGetStaticPaths({ + mod, + route, + routeCache, + ssr: false, + base: '/', + trailingSlash: 'never', + }); + + assert.equal(callCount, 1, 'getStaticPaths should still not be called'); + assert.equal(result3.length, 3, 'should return cached paths'); + }); + + it('clears cache when clearAll is called', async () => { + const route = makeRoute({ + segments: [[dynamicPart('test')]], + trailingSlash: 'never', + route: '/[test]', + pathname: undefined, + type: 'page', + prerender: true, + }); + + const mod = { + default: () => {}, + getStaticPaths: async () => { + callCount++; + return [{ params: { test: 'value' } }]; + }, + }; + + // First call + await callGetStaticPaths({ + mod, + route, + routeCache, + ssr: false, + base: '/', + trailingSlash: 'never', + }); + + assert.equal(callCount, 1, 'getStaticPaths called once'); + + // Clear cache + routeCache.clearAll(); + + // Second call after clearing should call getStaticPaths again + await callGetStaticPaths({ + mod, + route, + routeCache, + ssr: false, + base: '/', + trailingSlash: 'never', + }); + + assert.equal(callCount, 2, 'getStaticPaths called again after cache clear'); + }); +}); diff --git a/packages/astro/test/units/routing/params-validation.test.js b/packages/astro/test/units/routing/params-validation.test.js new file mode 100644 index 000000000000..83567961e2df --- /dev/null +++ b/packages/astro/test/units/routing/params-validation.test.js @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { describe, it, before } from 'node:test'; +import { Logger } from '../../../dist/core/logger/core.js'; +import { RouteCache, callGetStaticPaths } from '../../../dist/core/render/route-cache.js'; +import { makeRoute } from './test-helpers.js'; + +describe('getStaticPaths param validation', () => { + let routeCache; + let logger; + + before(() => { + logger = new Logger({ dest: 'memory', level: 'error' }); + routeCache = new RouteCache(logger, 'production'); + }); + + // Create a route that uses rest params (spread) to allow undefined + const route = makeRoute({ + segments: [[{ dynamic: true, content: 'testParam', spread: true }]], + trailingSlash: 'never', + route: '/[...testParam]', + pathname: undefined, + type: 'page', + prerender: true, + }); + + const paramTestCases = [ + // Valid types + { type: 'string', value: 'foo', shouldPass: true }, + { type: 'undefined', value: undefined, shouldPass: true }, + + // Invalid types + { type: 'number', value: 123, shouldPass: false }, + { type: 'boolean', value: false, shouldPass: false }, + { type: 'array', value: [1, 2, 3], shouldPass: false }, + { type: 'null', value: null, shouldPass: false }, + { type: 'object', value: { a: 1 }, shouldPass: false }, + { type: 'bigint', value: BigInt(123), shouldPass: false }, + { type: 'function', value: setTimeout, shouldPass: false }, + ]; + + for (const { type, value, shouldPass } of paramTestCases) { + it(`${shouldPass ? 'accepts' : 'rejects'} param type ${type}`, async () => { + // Clear route cache before each test to ensure isolation + routeCache.clearAll(); + + const mod = { + default: () => {}, + getStaticPaths: async () => [ + { + params: { testParam: value }, + }, + ], + }; + + try { + await callGetStaticPaths({ + mod, + route, + routeCache, + ssr: false, + base: '/', + trailingSlash: 'never', + }); + + if (!shouldPass) { + assert.fail(`Expected validation error for param type ${type}`); + } + } catch (err) { + if (shouldPass) { + throw err; + } + + assert.equal(err.name, 'GetStaticPathsInvalidRouteParam'); + // Arrays report as 'object' in typeof, so adjust the check + const expectedType = type === 'array' ? 'object' : type; + assert.match(err.message, new RegExp(expectedType)); + } + }); + } +}); diff --git a/packages/astro/test/units/server-islands/endpoint.test.js b/packages/astro/test/units/server-islands/endpoint.test.js index a4868a77ce41..63e9d9f4dbd4 100644 --- a/packages/astro/test/units/server-islands/endpoint.test.js +++ b/packages/astro/test/units/server-islands/endpoint.test.js @@ -149,6 +149,78 @@ describe('getRequestData', () => { }); // #endregion + // #region Body size limiting + describe('POST body size limiting', () => { + it('returns 413 when POST body exceeds the configured limit', async () => { + const limit = 100; // 100 bytes + // Create a body larger than the limit + const largeBody = JSON.stringify({ + encryptedComponentExport: 'x'.repeat(200), + encryptedProps: '', + encryptedSlots: '', + }); + const req = new Request('http://localhost/_server-islands/Island', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: largeBody, + }); + const result = await getRequestData(req, limit); + assert.ok(result instanceof Response, 'should return a Response'); + assert.equal(result.status, 413); + }); + + it('returns 413 when Content-Length header exceeds the configured limit', async () => { + const limit = 100; // 100 bytes + const smallBody = JSON.stringify({ + encryptedComponentExport: 'enc', + encryptedProps: '', + encryptedSlots: '', + }); + const req = new Request('http://localhost/_server-islands/Island', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': '999999', + }, + body: smallBody, + }); + const result = await getRequestData(req, limit); + assert.ok(result instanceof Response, 'should return a Response'); + assert.equal(result.status, 413); + }); + + it('accepts POST body within the configured limit', async () => { + const limit = 10000; // 10KB + const body = { + encryptedComponentExport: 'encExport', + encryptedProps: 'encProps', + encryptedSlots: 'encSlots', + }; + const req = new Request('http://localhost/_server-islands/Island', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const result = await getRequestData(req, limit); + assert.ok(!(result instanceof Response), 'should not return a Response'); + assert.equal(result.encryptedComponentExport, 'encExport'); + }); + + it('uses default limit when no limit is specified', async () => { + // This should work fine with the default 1MB limit + const body = { + encryptedComponentExport: 'encExport', + encryptedProps: 'encProps', + encryptedSlots: 'encSlots', + }; + const req = makePostRequest(body); + const result = await getRequestData(req); + assert.ok(!(result instanceof Response), 'should not return a Response'); + assert.equal(result.encryptedComponentExport, 'encExport'); + }); + }); + // #endregion + // #region Unsupported HTTP methods describe('unsupported HTTP methods', () => { for (const method of ['PUT', 'DELETE', 'PATCH', 'HEAD']) { diff --git a/packages/astro/test/units/test-utils.js b/packages/astro/test/units/test-utils.js index bc7b72b721ad..b1b42dd0d898 100644 --- a/packages/astro/test/units/test-utils.js +++ b/packages/astro/test/units/test-utils.js @@ -94,6 +94,9 @@ function buffersToString(buffers) { } /** + * Creates a basic Pipeline instance for testing. + * For mock utilities like createMockRenderContext, see mocks.js + * * @param {Partial} options * @returns {Pipeline} */ diff --git a/packages/astro/tsconfigs/base.json b/packages/astro/tsconfigs/base.json index 1c667f933a8b..55adf57bb5e4 100644 --- a/packages/astro/tsconfigs/base.json +++ b/packages/astro/tsconfigs/base.json @@ -26,9 +26,7 @@ // Allow JavaScript files to be imported "allowJs": true, // Allow JSX files (or files that are internally considered JSX, like Astro files) to be imported inside `.js` and `.ts` files. - "jsx": "preserve", - "types": ["node"], - "libReplacement": false + "jsx": "preserve" }, "exclude": ["${configDir}/dist"], "include": ["${configDir}/.astro/types.d.ts", "${configDir}/**/*"] diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts index 652d45fc4107..947adf62c2a8 100644 --- a/packages/integrations/netlify/src/index.ts +++ b/packages/integrations/netlify/src/index.ts @@ -456,7 +456,8 @@ export default function netlifyIntegration( const ctx = createContext({ request, params: {}, - locals: { netlify: { context } } + locals: { netlify: { context } }, + clientAddress: context.ip, }); // https://docs.netlify.com/edge-functions/api/#return-a-rewrite ctx.rewrite = (target) => { diff --git a/packages/integrations/vercel/src/serverless/middleware.ts b/packages/integrations/vercel/src/serverless/middleware.ts index 46ccd9d74743..0f215ee84aa4 100644 --- a/packages/integrations/vercel/src/serverless/middleware.ts +++ b/packages/integrations/vercel/src/serverless/middleware.ts @@ -119,7 +119,8 @@ import { createContext, trySerializeLocals } from 'astro/middleware'; export default async function middleware(request, context) { const ctx = createContext({ request, - params: {} + params: {}, + clientAddress: request.headers.get('x-real-ip') || undefined, }); Object.assign(ctx.locals, { vercel: { edge: context }, ...${handlerTemplateCall} }); const { origin } = new URL(request.url);