Skip to content

Commit 00c579a

Browse files
matthewpematipico
andauthored
server islands - encrypted slots (#14772)
* fix(server-islands): require encrypted slots Encrypt slots client-side and decrypt server-side to prevent injection attacks, matching the security model used for props. * Update packages/astro/src/core/server-islands/endpoint.ts Co-authored-by: Emanuele Stoppa <my.burning@gmail.com> * lame changeset thing * another * linting * update another test --------- Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
1 parent 6f80081 commit 00c579a

5 files changed

Lines changed: 184 additions & 21 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"astro": patch
3+
---
4+
5+
Improves the security of Server Islands slots by encrypting them before transmission to the browser, matching the security model used for props. This improves the integrity of slot content and prevents injection attacks, even when component templates don't explicitly support slots.
6+
7+
Slots continue to work as expected for normal usage—this change has no breaking changes for legitimate requests.

packages/astro/src/core/server-islands/endpoint.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function injectServerIslandRoute(config: ConfigFields, routeManifest: Rou
4545
type RenderOptions = {
4646
componentExport: string;
4747
encryptedProps: string;
48-
slots: Record<string, string>;
48+
encryptedSlots: string;
4949
};
5050

5151
function badRequest(reason: string) {
@@ -65,24 +65,29 @@ async function getRequestData(request: Request): Promise<Response | RenderOption
6565
return badRequest('Missing required query parameters.');
6666
}
6767

68-
const rawSlots = params.get('s')!;
69-
try {
70-
return {
71-
componentExport: params.get('e')!,
72-
encryptedProps: params.get('p')!,
73-
slots: JSON.parse(rawSlots),
74-
};
75-
} catch {
76-
return badRequest('Invalid slots format.');
77-
}
68+
const encryptedSlots = params.get('s')!;
69+
return {
70+
componentExport: params.get('e')!,
71+
encryptedProps: params.get('p')!,
72+
encryptedSlots,
73+
};
7874
}
7975
case 'POST': {
8076
try {
8177
const raw = await request.text();
8278
const data = JSON.parse(raw) as RenderOptions;
79+
80+
// Validate that slots is not plaintext
81+
if ('slots' in data && typeof (data as any).slots === 'object') {
82+
return badRequest('Plaintext slots are not allowed. Slots must be encrypted.');
83+
}
84+
8385
return data;
84-
} catch {
85-
return badRequest('Request format is invalid.');
86+
} catch (e) {
87+
if (e instanceof SyntaxError) {
88+
return badRequest('Request format is invalid.');
89+
}
90+
throw e;
8691
}
8792
}
8893
default: {
@@ -124,12 +129,17 @@ export function createEndpoint(manifest: SSRManifest) {
124129
const propString = encryptedProps === '' ? '{}' : await decryptString(key, encryptedProps);
125130
const props = JSON.parse(propString);
126131

132+
// Decrypt slots
133+
const encryptedSlots = data.encryptedSlots;
134+
const slotsString = encryptedSlots === '' ? '{}' : await decryptString(key, encryptedSlots);
135+
const decryptedSlots = JSON.parse(slotsString);
136+
127137
const componentModule = await imp();
128138
let Component = (componentModule as any)[data.componentExport];
129139

130140
const slots: ComponentSlots = {};
131-
for (const prop in data.slots) {
132-
slots[prop] = createSlotValueFromString(data.slots[prop]);
141+
for (const prop in decryptedSlots) {
142+
slots[prop] = createSlotValueFromString(decryptedSlots[prop]);
133143
}
134144

135145
// Prevent server islands from being indexed

packages/astro/src/runtime/server/render/server-islands.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ export class ServerIslandComponent {
165165
? ''
166166
: await encryptString(key, JSON.stringify(this.props));
167167

168+
// Encrypt slots
169+
const slotsEncrypted =
170+
Object.keys(renderedSlots).length === 0
171+
? ''
172+
: await encryptString(key, JSON.stringify(renderedSlots));
173+
168174
const hostId = await this.getHostId();
169175
const slash = this.result.base.endsWith('/') ? '' : '/';
170176
let serverIslandUrl = `${this.result.base}${slash}_server-islands/${componentId}${this.result.trailingSlash === 'always' ? '/' : ''}`;
@@ -173,7 +179,7 @@ export class ServerIslandComponent {
173179
const potentialSearchParams = createSearchParams(
174180
componentExport,
175181
propsEncrypted,
176-
safeJsonStringify(renderedSlots),
182+
slotsEncrypted,
177183
);
178184
const useGETRequest = isWithinURLLimit(serverIslandUrl, potentialSearchParams);
179185

@@ -198,7 +204,7 @@ let response = await fetch('${serverIslandUrl}', { headers });`
198204
`let data = {
199205
componentExport: ${safeJsonStringify(componentExport)},
200206
encryptedProps: ${safeJsonStringify(propsEncrypted)},
201-
slots: ${safeJsonStringify(renderedSlots)},
207+
encryptedSlots: ${safeJsonStringify(slotsEncrypted)},
202208
};
203209
const headers = new Headers({ 'Content-Type': 'application/json', ...${headersJson} });
204210
let response = await fetch('${serverIslandUrl}', {

packages/astro/test/csp-server-islands.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe('Server islands', () => {
4949
body: JSON.stringify({
5050
componentExport: 'default',
5151
encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
52-
slots: {},
52+
encryptedSlots: '',
5353
}),
5454
headers: {
5555
origin: 'http://example.com',

packages/astro/test/server-islands.test.js

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,28 @@
11
import assert from 'node:assert/strict';
22
import { after, before, describe, it } from 'node:test';
3+
34
import * as cheerio from 'cheerio';
5+
6+
import { encryptString } from '../dist/core/encryption.js';
47
import testAdapter from './test-adapter.js';
58
import { loadFixture } from './test-utils.js';
69

10+
// Helper to create encryption key from test key string
11+
async function createKeyFromString(keyString) {
12+
const binaryString = atob(keyString);
13+
const bytes = new Uint8Array(binaryString.length);
14+
for (let i = 0; i < binaryString.length; i++) {
15+
bytes[i] = binaryString.charCodeAt(i);
16+
}
17+
return await crypto.subtle.importKey(
18+
'raw',
19+
bytes,
20+
{ name: 'AES-GCM' },
21+
false,
22+
['encrypt', 'decrypt']
23+
);
24+
}
25+
726
describe('Server islands', () => {
827
describe('SSR', () => {
928
/** @type {import('./test-utils').Fixture} */
@@ -50,7 +69,7 @@ describe('Server islands', () => {
5069
body: JSON.stringify({
5170
componentExport: 'default',
5271
encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
53-
slots: {},
72+
encryptedSlots: '',
5473
}),
5574
});
5675
assert.equal(res.headers.get('x-robots-tag'), 'noindex');
@@ -62,7 +81,7 @@ describe('Server islands', () => {
6281
body: JSON.stringify({
6382
componentExport: 'default',
6483
encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
65-
slots: {},
84+
encryptedSlots: '',
6685
}),
6786
});
6887
const works = res.headers.get('X-Works');
@@ -98,6 +117,56 @@ describe('Server islands', () => {
98117
'should re-encrypt props on each request with a different IV',
99118
);
100119
});
120+
121+
it('rejects plaintext slots', async () => {
122+
const res = await fixture.fetch('/_server-islands/Island', {
123+
method: 'POST',
124+
body: JSON.stringify({
125+
componentExport: 'default',
126+
encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
127+
slots: { xss: '<img src=x onerror=alert(0)>' },
128+
}),
129+
});
130+
assert.equal(res.status, 400, 'should reject unencrypted slots');
131+
});
132+
133+
it('rejects plaintext slots with XSS payload via GET', async () => {
134+
const res = await fixture.fetch('/_server-islands/Island?e=file&s=%7B%22xss%22%3A%22%3Cimg%20src%3Dx%20onerror%3Dalert(0)%3E%22%7D');
135+
assert.equal(res.status, 400, 'should reject plaintext slots with XSS');
136+
});
137+
138+
it('accepts encrypted slots via POST', async () => {
139+
const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=');
140+
const slotsToEncrypt = { content: '<p>Safe slot content</p>' };
141+
const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt));
142+
143+
const res = await fixture.fetch('/_server-islands/Island', {
144+
method: 'POST',
145+
body: JSON.stringify({
146+
componentExport: 'default',
147+
encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
148+
encryptedSlots: encryptedSlots,
149+
}),
150+
});
151+
assert.equal(res.status, 200, 'should accept encrypted slots');
152+
});
153+
154+
it('accepts encrypted slots with XSS payload via POST', async () => {
155+
const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=');
156+
const slotsToEncrypt = { xss: '<img src=x onerror=alert(0)>' };
157+
const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt));
158+
159+
const res = await fixture.fetch('/_server-islands/Island', {
160+
method: 'POST',
161+
body: JSON.stringify({
162+
componentExport: 'default',
163+
encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
164+
encryptedSlots: encryptedSlots,
165+
}),
166+
});
167+
assert.equal(res.status, 200, 'should accept even XSS in encrypted slots (safe when encrypted)');
168+
});
169+
101170
it('supports mdx', async () => {
102171
const res = await fixture.fetch('/test');
103172
assert.equal(res.status, 200);
@@ -157,7 +226,7 @@ describe('Server islands', () => {
157226
body: JSON.stringify({
158227
componentExport: 'default',
159228
encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
160-
slots: {},
229+
encryptedSlots: '',
161230
}),
162231
headers: {
163232
origin: 'http://example.com',
@@ -201,6 +270,77 @@ describe('Server islands', () => {
201270
'should re-encrypt props on each request with a different IV',
202271
);
203272
});
273+
274+
it('rejects plaintext slots', async () => {
275+
const app = await fixture.loadTestAdapterApp();
276+
const request = new Request('http://example.com/_server-islands/Island', {
277+
method: 'POST',
278+
body: JSON.stringify({
279+
componentExport: 'default',
280+
encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
281+
slots: { xss: '<img src=x onerror=alert(0)>' },
282+
}),
283+
headers: {
284+
origin: 'http://example.com',
285+
},
286+
});
287+
const response = await app.render(request);
288+
assert.equal(response.status, 400, 'should reject unencrypted slots');
289+
});
290+
291+
it('rejects plaintext slots with XSS payload via GET', async () => {
292+
const app = await fixture.loadTestAdapterApp();
293+
const request = new Request('http://example.com/_server-islands/Island?e=file&s=%7B%22xss%22%3A%22%3Cimg%20src%3Dx%20onerror%3Dalert(0)%3E%22%7D', {
294+
headers: {
295+
origin: 'http://example.com',
296+
},
297+
});
298+
const response = await app.render(request);
299+
assert.equal(response.status, 400, 'should reject plaintext slots with XSS');
300+
});
301+
302+
it('accepts encrypted slots via POST', async () => {
303+
const app = await fixture.loadTestAdapterApp();
304+
const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=');
305+
const slotsToEncrypt = { content: '<p>Safe slot content</p>' };
306+
const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt));
307+
308+
const request = new Request('http://example.com/_server-islands/Island', {
309+
method: 'POST',
310+
body: JSON.stringify({
311+
componentExport: 'default',
312+
encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
313+
encryptedSlots: encryptedSlots,
314+
}),
315+
headers: {
316+
origin: 'http://example.com',
317+
},
318+
});
319+
const response = await app.render(request);
320+
assert.equal(response.status, 200, 'should accept encrypted slots');
321+
});
322+
323+
it('accepts encrypted slots with XSS payload via POST', async () => {
324+
const app = await fixture.loadTestAdapterApp();
325+
const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=');
326+
const slotsToEncrypt = { xss: '<img src=x onerror=alert(0)>' };
327+
const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt));
328+
329+
const request = new Request('http://example.com/_server-islands/Island', {
330+
method: 'POST',
331+
body: JSON.stringify({
332+
componentExport: 'default',
333+
encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
334+
encryptedSlots: encryptedSlots,
335+
}),
336+
headers: {
337+
origin: 'http://example.com',
338+
},
339+
});
340+
const response = await app.render(request);
341+
assert.equal(response.status, 200, 'should accept even XSS in encrypted slots (safe when encrypted)');
342+
});
343+
204344
it('supports mdx', async () => {
205345
const app = await fixture.loadTestAdapterApp();
206346
const request = new Request('http://example.com/test/');

0 commit comments

Comments
 (0)