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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-solid-svelte5-renderer-prop-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/solid-js': patch
---

Fix `@astrojs/solid-js` incorrectly claiming Svelte 5 components compiled with the newer `$$renderer` prop (instead of the legacy `$$payload`). Projects mixing Solid and Svelte could see Svelte components silently rendered as empty strings by the Solid renderer.
5 changes: 5 additions & 0 deletions .changeset/jolly-clocks-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes the `security.checkOrigin` check so it is applied consistently to Astro Actions and on-demand endpoints, regardless of how the request pipeline is composed. Previously, the origin check could be skipped in the composable `astro/hono` pipeline depending on the order of the `middleware()` primitive (or when it was omitted).
10 changes: 10 additions & 0 deletions .changeset/node-backslash-trailing-slash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@astrojs/internal-helpers': patch
'@astrojs/node': patch
---

Fixes trailing-slash handling for request paths that begin with a backslash

With `trailingSlash: 'always'`, the standalone Node server could append a trailing slash to a request path that begins with a backslash (for example `/\example.com/foo`) and echo that path back in the `Location` header of a `301` response. Because browsers resolve a leading `\` the same way as `/`, the resulting `Location` could point off-site.

Such paths are now recognized as internal paths, matching the existing handling for paths that begin with `//`, so they are no longer rewritten with a trailing slash.
5 changes: 5 additions & 0 deletions .changeset/quick-otters-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Hardens the handling of attribute rendering when using with custom elements.
15 changes: 15 additions & 0 deletions packages/astro/src/actions/handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { APIContext } from '../types/public/context.js';
import {
createCrossOriginForbiddenResponse,
isForbiddenCrossOriginRequest,
} from '../core/app/origin-check.js';
import { PipelineFeatures } from '../core/base-pipeline.js';
import type { FetchState } from '../core/fetch/fetch-state.js';
import { getActionContext, serializeActionResult } from './runtime/server.js';
Expand Down Expand Up @@ -41,6 +45,17 @@ export class ActionHandler {
return undefined;
}

// The origin check normally runs in the origin-check middleware, but the
// action dispatch can run before that middleware depending on how the
// pipeline is composed. Apply the same check here so it holds regardless
// of ordering.
if (
state.pipeline.manifest.checkOrigin &&
isForbiddenCrossOriginRequest(apiContext.request, apiContext.url, apiContext.isPrerendered)
) {
return Promise.resolve(createCrossOriginForbiddenResponse(apiContext.request));
}

return this.#executeAction(action, setActionResult);
}

Expand Down
66 changes: 0 additions & 66 deletions packages/astro/src/core/app/middlewares.ts

This file was deleted.

94 changes: 94 additions & 0 deletions packages/astro/src/core/app/origin-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Home of the `security.checkOrigin` logic. Exposes the shared predicate and
* response used to reject cross-site submissions, plus the middleware factory
* that installs the check in the request pipeline. Consumers (the pipeline
* middleware and the Astro Actions dispatch) import from here so the check
* stays consistent regardless of where it runs.
*/
import type { MiddlewareHandler } from '../../types/public/common.js';
import { defineMiddleware } from '../middleware/defineMiddleware.js';

/**
* Content types that can be passed when sending a request via a form
*
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/enctype
* @private
*/
const FORM_CONTENT_TYPES = [
'application/x-www-form-urlencoded',
'multipart/form-data',
'text/plain',
];

// Note: TRACE is unsupported by undici/Node.js
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];

/**
* Determines whether a request should be rejected because it is a cross-site
* submission for a route rendered on demand.
*
* This encapsulates the shared logic used by both the origin-check middleware
* and the Astro Actions dispatch, so the check is applied consistently
* regardless of where the request is handled.
*
* @private
*/
export function isForbiddenCrossOriginRequest(
request: Request,
url: URL,
isPrerendered: boolean,
): boolean {
// Prerendered pages should be excluded
if (isPrerendered) {
return false;
}
// Safe methods don't require origin check
if (SAFE_METHODS.includes(request.method)) {
return false;
}
const isSameOrigin = request.headers.get('origin') === url.origin;

const hasContentType = request.headers.has('content-type');
if (hasContentType) {
const formLikeHeader = hasFormLikeHeader(request.headers.get('content-type'));
return formLikeHeader && !isSameOrigin;
}
return !isSameOrigin;
}

/**
* Builds the 403 response returned when a cross-site submission is rejected.
*
* @private
*/
export function createCrossOriginForbiddenResponse(request: Request): Response {
return new Response(`Cross-site ${request.method} form submissions are forbidden`, {
status: 403,
});
}

/**
* Returns a middleware function in charge to check the `origin` header.
*
* @private
*/
export function createOriginCheckMiddleware(): MiddlewareHandler {
return defineMiddleware((context, next) => {
const { request, url, isPrerendered } = context;
if (isForbiddenCrossOriginRequest(request, url, isPrerendered)) {
return createCrossOriginForbiddenResponse(request);
}
return next();
});
}

export function hasFormLikeHeader(contentType: string | null): boolean {
if (contentType) {
for (const FORM_CONTENT_TYPE of FORM_CONTENT_TYPES) {
if (contentType.toLowerCase().includes(FORM_CONTENT_TYPE)) {
return true;
}
}
}
return false;
}
2 changes: 1 addition & 1 deletion packages/astro/src/core/base-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
SSRManifest,
SSRResult,
} from '../types/public/internal.js';
import { createOriginCheckMiddleware } from './app/middlewares.js';
import { createOriginCheckMiddleware } from './app/origin-check.js';
import type { ServerIslandMappings } from './app/types.js';
import type { SinglePageBuiltModule } from './build/types.js';
import { ActionNotFoundError } from './errors/errors-data.js';
Expand Down
17 changes: 16 additions & 1 deletion packages/astro/src/core/pages/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import type { BaseApp } from '../app/base.js';
import type { FetchState } from '../fetch/fetch-state.js';
import type { Pipeline } from '../base-pipeline.js';
import { ASTRO_ERROR_HEADER } from '../constants.js';
import {
createCrossOriginForbiddenResponse,
isForbiddenCrossOriginRequest,
} from '../app/origin-check.js';
import { getCookiesFromResponse } from '../cookies/response.js';

// Shared empty-slots object so we don't allocate `{}` on every render for
Expand Down Expand Up @@ -116,8 +120,19 @@ export class PagesHandler {
if (!state.routeData) {
return new Response(null, { status: 404, headers: { [ASTRO_ERROR_HEADER]: 'true' } });
}
const ctx = state.getAPIContext();
// The origin check normally runs in the origin-check middleware, but a
// composable pipeline can dispatch here without running `middleware()`
// first (or at all). Apply the same check so it holds regardless of how
// the pipeline is composed.
if (
this.#pipeline.manifest.checkOrigin &&
isForbiddenCrossOriginRequest(ctx.request, ctx.url, ctx.isPrerendered)
) {
return createCrossOriginForbiddenResponse(ctx.request);
}
try {
return await this.handle(state, state.getAPIContext());
return await this.handle(state, ctx);
} catch (err: any) {
// The header marker can't carry the error object, so render the
// 500 page directly to preserve `error` and the logged stack.
Expand Down
8 changes: 7 additions & 1 deletion packages/astro/src/runtime/server/render/dom.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { SSRResult } from '../../../types/public/internal.js';
import { markHTMLString } from '../escape.js';
import { renderSlotToString } from './slot.js';
import { toAttributeString } from './util.js';
import { INVALID_ATTR_NAME_CHAR, toAttributeString } from './util.js';

export function componentIsHTMLElement(Component: unknown) {
return typeof HTMLElement !== 'undefined' && HTMLElement.isPrototypeOf(Component as object);
Expand All @@ -18,6 +18,12 @@ export async function renderHTMLElement(
let attrHTML = '';

for (const attr in props) {
// Reject attribute names with characters that could break out of the attribute context.
// Without this guard, untrusted prop keys spread onto the element could inject arbitrary
// markup or event-handler attributes (XSS). Mirrors the guard in `addAttribute`.
if (INVALID_ATTR_NAME_CHAR.test(attr)) {
continue;
}
attrHTML += ` ${attr}="${toAttributeString(await props[attr])}"`;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/runtime/server/render/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const DOUBLE_QUOTE_REGEX = /"/g;
const STATIC_DIRECTIVES = new Set(['set:html', 'set:text']);

// Per the HTML spec, attribute names must not contain ASCII whitespace, ", ', >, /, or =.
const INVALID_ATTR_NAME_CHAR = /[\s"'>/=]/;
export const INVALID_ATTR_NAME_CHAR = /[\s"'>/=]/;

// converts (most) arbitrary strings to valid JS identifiers
const toIdent = (k: string) =>
Expand Down
Loading
Loading