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/bright-bulldogs-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Ensures server island requests carry an encrypted component export identifier so they do not accidentally resolve to the wrong component.
28 changes: 22 additions & 6 deletions packages/astro/src/core/server-islands/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function injectServerIslandRoute(config: ConfigFields, routeManifest: Rou
}

type RenderOptions = {
componentExport: string;
encryptedComponentExport: string;
encryptedProps: string;
encryptedSlots: string;
};
Expand All @@ -67,22 +67,29 @@ async function getRequestData(request: Request): Promise<Response | RenderOption

const encryptedSlots = params.get('s')!;
return {
componentExport: params.get('e')!,
encryptedComponentExport: params.get('e')!,
encryptedProps: params.get('p')!,
encryptedSlots,
};
}
case 'POST': {
try {
const raw = await request.text();
const data = JSON.parse(raw) as RenderOptions;
const data = JSON.parse(raw);

// Validate that slots is not plaintext
if ('slots' in data && typeof (data as any).slots === 'object') {
if ('slots' in data && typeof data.slots === 'object') {
return badRequest('Plaintext slots are not allowed. Slots must be encrypted.');
}

return data;
// Validate that componentExport is not plaintext
if ('componentExport' in data && typeof data.componentExport === 'string') {
return badRequest(
'Plaintext componentExport is not allowed. componentExport must be encrypted.',
);
}

return data as RenderOptions;
} catch (e) {
if (e instanceof SyntaxError) {
return badRequest('Request format is invalid.');
Expand Down Expand Up @@ -124,6 +131,15 @@ export function createEndpoint(manifest: SSRManifest) {
}

const key = await manifest.key;

// Decrypt componentExport
let componentExport: string;
try {
componentExport = await decryptString(key, data.encryptedComponentExport);
} catch (_e) {
return badRequest('Encrypted componentExport value is invalid.');
}

const encryptedProps = data.encryptedProps;

let props = {};
Expand Down Expand Up @@ -152,7 +168,7 @@ export function createEndpoint(manifest: SSRManifest) {
}

const componentModule = await imp();
let Component = (componentModule as any)[data.componentExport];
let Component = (componentModule as any)[componentExport];

const slots: ComponentSlots = {};
for (const prop in decryptedSlots) {
Expand Down
16 changes: 12 additions & 4 deletions packages/astro/src/runtime/server/render/server-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,13 @@ function safeJsonStringify(obj: any) {
.replace(COMMENT_RE, COMMENT_REPLACER);
}

function createSearchParams(componentExport: string, encryptedProps: string, slots: string) {
function createSearchParams(
encryptedComponentExport: string,
encryptedProps: string,
slots: string,
) {
const params = new URLSearchParams();
params.set('e', componentExport);
params.set('e', encryptedComponentExport);
params.set('p', encryptedProps);
params.set('s', slots);
return params;
Expand Down Expand Up @@ -160,6 +164,10 @@ export class ServerIslandComponent {
}

const key = await this.result.key;

// Encrypt componentExport
const componentExportEncrypted = await encryptString(key, componentExport);

const propsEncrypted =
Object.keys(this.props).length === 0
? ''
Expand All @@ -177,7 +185,7 @@ export class ServerIslandComponent {

// Determine if its safe to use a GET request
const potentialSearchParams = createSearchParams(
componentExport,
componentExportEncrypted,
propsEncrypted,
slotsEncrypted,
);
Expand All @@ -202,7 +210,7 @@ export class ServerIslandComponent {
let response = await fetch('${serverIslandUrl}', { headers });`
: // POST request
`let data = {
componentExport: ${safeJsonStringify(componentExport)},
encryptedComponentExport: ${safeJsonStringify(componentExportEncrypted)},
encryptedProps: ${safeJsonStringify(propsEncrypted)},
encryptedSlots: ${safeJsonStringify(slotsEncrypted)},
};
Expand Down
25 changes: 24 additions & 1 deletion packages/astro/test/csp-server-islands.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import * as cheerio from 'cheerio';
import { encryptString } from '../dist/core/encryption.js';
import testAdapter from './test-adapter.js';
import { loadFixture } from './test-utils.js';

// Helper to create encryption key from test key string
async function createKeyFromString(keyString) {
const binaryString = atob(keyString);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return await crypto.subtle.importKey('raw', bytes, { name: 'AES-GCM' }, false, [
'encrypt',
'decrypt',
]);
}

// Helper to get encrypted componentExport for 'default'
async function getEncryptedComponentExport(
keyString = 'eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=',
) {
const key = await createKeyFromString(keyString);
return encryptString(key, 'default');
}

describe('Server islands', () => {
describe('SSR', () => {
/** @type {import('./test-utils').Fixture} */
Expand Down Expand Up @@ -44,10 +66,11 @@ describe('Server islands', () => {

it('island is not indexed', async () => {
const app = await fixture.loadTestAdapterApp();
const encryptedComponentExport = await getEncryptedComponentExport();
const request = new Request('http://example.com/_server-islands/Island', {
method: 'POST',
body: JSON.stringify({
componentExport: 'default',
encryptedComponentExport,
encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
encryptedSlots: '',
}),
Expand Down
Loading
Loading