11import assert from 'node:assert/strict' ;
22import { after , before , describe , it } from 'node:test' ;
3+
34import * as cheerio from 'cheerio' ;
5+
6+ import { encryptString } from '../dist/core/encryption.js' ;
47import testAdapter from './test-adapter.js' ;
58import { 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+
726describe ( '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