From dfdbff67eb6f01e7c9e266a4fe4976b40bacf924 Mon Sep 17 00:00:00 2001 From: Bernd Strehl Date: Mon, 16 Mar 2026 10:05:13 +0100 Subject: [PATCH 1/4] fix(core): fix skew protection for island hydration URLs --- .../src/core/build/plugins/plugin-manifest.ts | 16 +++- packages/astro/src/core/render/ssr-element.ts | 35 ++++++- .../astro/test/asset-query-params.test.js | 94 +++++++++++++++++++ 3 files changed, 136 insertions(+), 9 deletions(-) diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 758e6f971a2b..932bdf0dde03 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -149,15 +149,21 @@ async function buildManifest( const routes: SerializedRouteInfo[] = []; const domainLookupTable: Record = {}; - const entryModules = Object.fromEntries(internals.entrySpecifierToBundleMap.entries()); - if (settings.scripts.some((script) => script.stage === 'page')) { - staticFiles.push(entryModules[PAGE_SCRIPT_ID]); - } + const rawEntryModules = Object.fromEntries(internals.entrySpecifierToBundleMap.entries()); const assetQueryParams = settings.adapter?.client?.assetQueryParams; const assetQueryString = assetQueryParams ? assetQueryParams.toString() : undefined; const appendAssetQuery = (pth: string) => (assetQueryString ? `${pth}?${assetQueryString}` : pth); + const entryModules = Object.fromEntries( + Object.entries(rawEntryModules).map(([key, value]) => [ + key, + value ? appendAssetQuery(value) : value, + ]), + ); + if (settings.scripts.some((script) => script.stage === 'page')) { + staticFiles.push(rawEntryModules[PAGE_SCRIPT_ID]); + } const prefixAssetPath = (pth: string) => { let result = ''; @@ -195,7 +201,7 @@ async function buildManifest( const scripts: SerializedRouteInfo['scripts'] = []; if (settings.scripts.some((script) => script.stage === 'page')) { - const src = entryModules[PAGE_SCRIPT_ID]; + const src = rawEntryModules[PAGE_SCRIPT_ID]; scripts.push({ type: 'external', diff --git a/packages/astro/src/core/render/ssr-element.ts b/packages/astro/src/core/render/ssr-element.ts index fad347c9f5a8..b8d306f7a08e 100644 --- a/packages/astro/src/core/render/ssr-element.ts +++ b/packages/astro/src/core/render/ssr-element.ts @@ -3,23 +3,50 @@ import { fileExtension, joinPaths, prependForwardSlash, slash } from '../../core import type { SSRElement } from '../../types/public/internal.js'; import type { AssetsPrefix, StylesheetAsset } from '../app/types.js'; +function splitAssetPath(path: string): { pathname: string; suffix: string } { + const queryOrHashIndex = path.search(/[?#]/); + if (queryOrHashIndex === -1) { + return { pathname: path, suffix: '' }; + } + + return { + pathname: path.slice(0, queryOrHashIndex), + suffix: path.slice(queryOrHashIndex), + }; +} + +function appendQueryParams(path: string, queryParams: URLSearchParams): string { + const queryString = queryParams.toString(); + if (!queryString) { + return path; + } + + const hashIndex = path.indexOf('#'); + const basePath = hashIndex === -1 ? path : path.slice(0, hashIndex); + const hash = hashIndex === -1 ? '' : path.slice(hashIndex); + const separator = basePath.includes('?') ? '&' : '?'; + + return `${basePath}${separator}${queryString}${hash}`; +} + export function createAssetLink( href: string, base?: string, assetsPrefix?: AssetsPrefix, queryParams?: URLSearchParams, ): string { + const { pathname, suffix } = splitAssetPath(href); let url = ''; if (assetsPrefix) { - const pf = getAssetsPrefix(fileExtension(href), assetsPrefix); - url = joinPaths(pf, slash(href)); + const pf = getAssetsPrefix(fileExtension(pathname), assetsPrefix); + url = joinPaths(pf, slash(pathname)) + suffix; } else if (base) { - url = prependForwardSlash(joinPaths(base, slash(href))); + url = prependForwardSlash(joinPaths(base, slash(pathname))) + suffix; } else { url = href; } if (queryParams) { - url += '?' + queryParams.toString(); + url = appendQueryParams(url, queryParams); } return url; } diff --git a/packages/astro/test/asset-query-params.test.js b/packages/astro/test/asset-query-params.test.js index ed7fe0f25301..c86ae200bc85 100644 --- a/packages/astro/test/asset-query-params.test.js +++ b/packages/astro/test/asset-query-params.test.js @@ -1,9 +1,17 @@ import assert from 'node:assert/strict'; import { before, describe, it } from 'node:test'; import * as cheerio from 'cheerio'; +import woof from './fixtures/multiple-jsx-renderers/renderers/woof/index.mjs'; +import meow from './fixtures/multiple-jsx-renderers/renderers/meow/index.mjs'; import testAdapter from './test-adapter.js'; import { loadFixture } from './test-utils.js'; +const multiCdnAssetsPrefix = { + js: 'https://js.example.com', + css: 'https://css.example.com', + fallback: 'https://example.com', +}; + describe('Asset Query Parameters (Adapter Client Config)', () => { /** @type {import('./test-utils').Fixture} */ let fixture; @@ -94,3 +102,89 @@ describe('Asset Query Parameters with Fonts', () => { }); }); }); + +describe('Asset Query Parameters with Islands', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/multiple-jsx-renderers/', + output: 'server', + integrations: [woof({ include: '**/*.woof.jsx' }), meow({ include: '**/*.meow.jsx' })], + adapter: testAdapter({ + extendAdapter: { + client: { + assetQueryParams: new URLSearchParams({ dpl: 'test-deploy-id' }), + }, + }, + }), + }); + await fixture.build(); + }); + + it('appends assetQueryParams to astro-island component and renderer URLs', async () => { + const app = await fixture.loadTestAdapterApp(); + const response = await app.render(new Request('http://example.com/client-load')); + assert.equal(response.status, 200); + const html = await response.text(); + const $ = cheerio.load(html); + const island = $('astro-island').first(); + + assert.ok(island.length > 0, 'Should have at least one astro-island'); + assert.match( + island.attr('component-url'), + /\?dpl=test-deploy-id/, + `astro-island component-url should include assetQueryParams: ${island.attr('component-url')}`, + ); + assert.match( + island.attr('renderer-url'), + /\?dpl=test-deploy-id/, + `astro-island renderer-url should include assetQueryParams: ${island.attr('renderer-url')}`, + ); + }); +}); + +describe('Asset Query Parameters with Islands and assetsPrefix map', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-assets-prefix/', + output: 'server', + adapter: testAdapter({ + extendAdapter: { + client: { + assetQueryParams: new URLSearchParams({ dpl: 'test-deploy-id' }), + }, + }, + }), + build: { + assetsPrefix: multiCdnAssetsPrefix, + }, + }); + await fixture.build(); + }); + + it('uses js assetsPrefix for island URLs while appending assetQueryParams', async () => { + const app = await fixture.loadTestAdapterApp(); + const response = await app.render(new Request('http://example.com/custom-base/')); + assert.equal(response.status, 200); + const html = await response.text(); + const $ = cheerio.load(html); + const island = $('astro-island').first(); + + assert.ok(island.length > 0, 'Should have at least one astro-island'); + assert.match( + island.attr('component-url'), + /^https:\/\/js\.example\.com\/_astro\/.*\?dpl=test-deploy-id$/, + `astro-island component-url should use js assetsPrefix and include assetQueryParams: ${island.attr('component-url')}`, + ); + assert.match( + island.attr('renderer-url'), + /^https:\/\/js\.example\.com\/_astro\/.*\?dpl=test-deploy-id$/, + `astro-island renderer-url should use js assetsPrefix and include assetQueryParams: ${island.attr('renderer-url')}`, + ); + }); +}); From 85cf3def82fd7f137e0bbaed3e35ddd1cd914bec Mon Sep 17 00:00:00 2001 From: Bernd Strehl Date: Mon, 16 Mar 2026 14:29:06 +0100 Subject: [PATCH 2/4] refactor(core): parse asset links with URL in createAssetLink --- packages/astro/src/core/render/ssr-element.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/astro/src/core/render/ssr-element.ts b/packages/astro/src/core/render/ssr-element.ts index b8d306f7a08e..4e9f1ba1227a 100644 --- a/packages/astro/src/core/render/ssr-element.ts +++ b/packages/astro/src/core/render/ssr-element.ts @@ -3,15 +3,16 @@ import { fileExtension, joinPaths, prependForwardSlash, slash } from '../../core import type { SSRElement } from '../../types/public/internal.js'; import type { AssetsPrefix, StylesheetAsset } from '../app/types.js'; +const URL_PARSE_BASE = 'https://astro.build'; + function splitAssetPath(path: string): { pathname: string; suffix: string } { - const queryOrHashIndex = path.search(/[?#]/); - if (queryOrHashIndex === -1) { - return { pathname: path, suffix: '' }; - } + const parsed = new URL(path, URL_PARSE_BASE); + const isAbsolute = URL.canParse(path); + const pathname = !isAbsolute && !path.startsWith('/') ? parsed.pathname.slice(1) : parsed.pathname; return { - pathname: path.slice(0, queryOrHashIndex), - suffix: path.slice(queryOrHashIndex), + pathname, + suffix: `${parsed.search}${parsed.hash}`, }; } From 22461e28b27d20cfd6465a63bbe5416acd87793a Mon Sep 17 00:00:00 2001 From: Bernd Strehl Date: Tue, 17 Mar 2026 08:54:12 +0100 Subject: [PATCH 3/4] chore: add changeset for skew protection fix --- .changeset/tiny-forks-yawn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tiny-forks-yawn.md diff --git a/.changeset/tiny-forks-yawn.md b/.changeset/tiny-forks-yawn.md new file mode 100644 index 000000000000..e9c01ab1ca6d --- /dev/null +++ b/.changeset/tiny-forks-yawn.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fix skew protection query params not being applied to island hydration `component-url` and `renderer-url`, and ensure query params are appended safely for asset URLs with existing search/hash parts. From 3fb9be37399426bee5615f72fc3f47ea8e5fd5b9 Mon Sep 17 00:00:00 2001 From: Bernd Strehl Date: Tue, 17 Mar 2026 10:58:26 +0100 Subject: [PATCH 4/4] chore: trigger CI rerun