diff --git a/.changeset/bright-bulldogs-heal.md b/.changeset/bright-bulldogs-heal.md new file mode 100644 index 000000000000..2757980a0512 --- /dev/null +++ b/.changeset/bright-bulldogs-heal.md @@ -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. diff --git a/packages/astro/src/core/server-islands/endpoint.ts b/packages/astro/src/core/server-islands/endpoint.ts index 0a1a76a0f3ec..bfc1945fe2b5 100644 --- a/packages/astro/src/core/server-islands/endpoint.ts +++ b/packages/astro/src/core/server-islands/endpoint.ts @@ -43,7 +43,7 @@ export function injectServerIslandRoute(config: ConfigFields, routeManifest: Rou } type RenderOptions = { - componentExport: string; + encryptedComponentExport: string; encryptedProps: string; encryptedSlots: string; }; @@ -67,7 +67,7 @@ async function getRequestData(request: Request): Promise { describe('SSR', () => { /** @type {import('./test-utils').Fixture} */ @@ -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: '', }), diff --git a/packages/astro/test/server-islands.test.js b/packages/astro/test/server-islands.test.js index 4a380da466b8..72a946958cc4 100644 --- a/packages/astro/test/server-islands.test.js +++ b/packages/astro/test/server-islands.test.js @@ -20,6 +20,14 @@ async function createKeyFromString(keyString) { ]); } +// 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} */ @@ -61,10 +69,11 @@ describe('Server islands', () => { }); it('island is not indexed', async () => { + const encryptedComponentExport = await getEncryptedComponentExport(); const res = await fixture.fetch('/_server-islands/Island', { method: 'POST', body: JSON.stringify({ - componentExport: 'default', + encryptedComponentExport, encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', encryptedSlots: '', }), @@ -73,10 +82,11 @@ describe('Server islands', () => { }); it('island can set headers', async () => { + const encryptedComponentExport = await getEncryptedComponentExport(); const res = await fixture.fetch('/_server-islands/Island', { method: 'POST', body: JSON.stringify({ - componentExport: 'default', + encryptedComponentExport, encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', encryptedSlots: '', }), @@ -116,10 +126,11 @@ describe('Server islands', () => { }); it('rejects invalid props', async () => { + const encryptedComponentExport = await getEncryptedComponentExport(); const res = await fixture.fetch('/_server-islands/Island', { method: 'POST', body: JSON.stringify({ - componentExport: 'default', + encryptedComponentExport, // not the expected value: encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLE', encryptedSlots: '', @@ -128,11 +139,24 @@ describe('Server islands', () => { assert.equal(res.status, 400); }); - it('rejects plaintext slots', async () => { + it('rejects plaintext componentExport', async () => { const res = await fixture.fetch('/_server-islands/Island', { method: 'POST', body: JSON.stringify({ componentExport: 'default', + encryptedProps: '', + encryptedSlots: '', + }), + }); + assert.equal(res.status, 400, 'should reject plaintext componentExport'); + }); + + it('rejects plaintext slots', async () => { + const encryptedComponentExport = await getEncryptedComponentExport(); + const res = await fixture.fetch('/_server-islands/Island', { + method: 'POST', + body: JSON.stringify({ + encryptedComponentExport, encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', slots: { xss: '' }, }), @@ -140,22 +164,16 @@ describe('Server islands', () => { assert.equal(res.status, 400, 'should reject unencrypted slots'); }); - it('rejects plaintext slots with XSS payload via GET', async () => { - 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', - ); - assert.equal(res.status, 400, 'should reject plaintext slots with XSS'); - }); - it('accepts encrypted slots via POST', async () => { const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M='); + const encryptedComponentExport = await encryptString(key, 'default'); const slotsToEncrypt = { content: '

Safe slot content

' }; const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt)); const res = await fixture.fetch('/_server-islands/Island', { method: 'POST', body: JSON.stringify({ - componentExport: 'default', + encryptedComponentExport, encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', encryptedSlots: encryptedSlots, }), @@ -164,10 +182,11 @@ describe('Server islands', () => { }); it('rejects invalid encrypted slots via POST', async () => { + const encryptedComponentExport = await getEncryptedComponentExport(); const res = await fixture.fetch('/_server-islands/Island', { method: 'POST', body: JSON.stringify({ - componentExport: 'default', + encryptedComponentExport, encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', // hard-coded invalid encrypted slot value: encryptedSlots: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLE', @@ -178,13 +197,14 @@ describe('Server islands', () => { it('accepts encrypted slots with XSS payload via POST', async () => { const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M='); + const encryptedComponentExport = await encryptString(key, 'default'); const slotsToEncrypt = { xss: '' }; const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt)); const res = await fixture.fetch('/_server-islands/Island', { method: 'POST', body: JSON.stringify({ - componentExport: 'default', + encryptedComponentExport, encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', encryptedSlots: encryptedSlots, }), @@ -250,10 +270,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: '', }), @@ -302,10 +323,11 @@ describe('Server islands', () => { it('rejects invalid props', 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, // not the expected value: encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLE', encryptedSlots: '', @@ -318,13 +340,31 @@ describe('Server islands', () => { assert.equal(response.status, 400); }); - it('rejects plaintext slots', async () => { + it('rejects plaintext componentExport', async () => { const app = await fixture.loadTestAdapterApp(); const request = new Request('http://example.com/_server-islands/Island', { method: 'POST', body: JSON.stringify({ componentExport: 'default', encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', + encryptedSlots: '', + }), + headers: { + origin: 'http://example.com', + }, + }); + const response = await app.render(request); + assert.equal(response.status, 400, 'should reject plaintext componentExport'); + }); + + it('rejects plaintext slots', 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({ + encryptedComponentExport, + encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', slots: { xss: '' }, }), headers: { @@ -352,13 +392,14 @@ describe('Server islands', () => { it('accepts encrypted slots via POST', async () => { const app = await fixture.loadTestAdapterApp(); const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M='); + const encryptedComponentExport = await encryptString(key, 'default'); const slotsToEncrypt = { content: '

Safe slot content

' }; const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt)); const request = new Request('http://example.com/_server-islands/Island', { method: 'POST', body: JSON.stringify({ - componentExport: 'default', + encryptedComponentExport, encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', encryptedSlots: encryptedSlots, }), @@ -372,11 +413,12 @@ describe('Server islands', () => { it('rejects invalid encrypted slots via POST', 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', // hard-coded invalid encrypted slot value: encryptedSlots: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLE', @@ -392,13 +434,14 @@ describe('Server islands', () => { it('accepts encrypted slots with XSS payload via POST', async () => { const app = await fixture.loadTestAdapterApp(); const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M='); + const encryptedComponentExport = await encryptString(key, 'default'); const slotsToEncrypt = { xss: '' }; const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt)); const request = new Request('http://example.com/_server-islands/Island', { method: 'POST', body: JSON.stringify({ - componentExport: 'default', + encryptedComponentExport, encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', encryptedSlots: encryptedSlots, }),