diff --git a/.changeset/server-island-encryption-aad.md b/.changeset/server-island-encryption-aad.md new file mode 100644 index 000000000000..df4d53cb6ce1 --- /dev/null +++ b/.changeset/server-island-encryption-aad.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Hardens server island encryption to prevent encrypted data from one island component being replayed against a different one diff --git a/packages/astro/src/core/encryption.ts b/packages/astro/src/core/encryption.ts index bf8df34fafef..70fce65cd9e2 100644 --- a/packages/astro/src/core/encryption.ts +++ b/packages/astro/src/core/encryption.ts @@ -81,36 +81,34 @@ const IV_LENGTH = 24; /** * Using a CryptoKey, encrypt a string into a base64 string. + * @param additionalData Optional authenticated context (e.g. "props:ComponentName") that is + * verified during decryption but not included in the ciphertext. Both sides must agree on + * the same value or decryption will fail. */ -export async function encryptString(key: CryptoKey, raw: string) { +export async function encryptString(key: CryptoKey, raw: string, additionalData?: string) { const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH / 2)); const data = encoder.encode(raw); - const buffer = await crypto.subtle.encrypt( - { - name: ALGORITHM, - iv, - }, - key, - data, - ); + const params: AesGcmParams = { name: ALGORITHM, iv }; + if (additionalData) { + params.additionalData = encoder.encode(additionalData); + } + const buffer = await crypto.subtle.encrypt(params, key, data); // iv is 12, hex brings it to 24 return encodeHexUpperCase(iv) + encodeBase64(new Uint8Array(buffer)); } /** * Takes a base64 encoded string, decodes it and returns the decrypted text. + * @param additionalData Must match the value used during encryption, or decryption will fail. */ -export async function decryptString(key: CryptoKey, encoded: string) { +export async function decryptString(key: CryptoKey, encoded: string, additionalData?: string) { const iv = decodeHex(encoded.slice(0, IV_LENGTH)) as Uint8Array; const dataArray = decodeBase64(encoded.slice(IV_LENGTH)) as Uint8Array; - const decryptedBuffer = await crypto.subtle.decrypt( - { - name: ALGORITHM, - iv, - }, - key, - dataArray, - ); + const params: AesGcmParams = { name: ALGORITHM, iv }; + if (additionalData) { + params.additionalData = encoder.encode(additionalData); + } + const decryptedBuffer = await crypto.subtle.decrypt(params, key, dataArray); const decryptedString = decoder.decode(decryptedBuffer); return decryptedString; } diff --git a/packages/astro/src/core/server-islands/endpoint.ts b/packages/astro/src/core/server-islands/endpoint.ts index c7f08298c47f..a3435aae3c46 100644 --- a/packages/astro/src/core/server-islands/endpoint.ts +++ b/packages/astro/src/core/server-islands/endpoint.ts @@ -149,7 +149,7 @@ export function createEndpoint(manifest: SSRManifest) { // Decrypt componentExport let componentExport: string; try { - componentExport = await decryptString(key, data.encryptedComponentExport); + componentExport = await decryptString(key, data.encryptedComponentExport, `export:${componentId}`); } catch (_e) { return badRequest('Encrypted componentExport value is invalid.'); } @@ -159,7 +159,7 @@ export function createEndpoint(manifest: SSRManifest) { if (encryptedProps !== '') { try { - const propString = await decryptString(key, encryptedProps); + const propString = await decryptString(key, encryptedProps, `props:${componentId}`); props = JSON.parse(propString); } catch (_e) { return badRequest('Encrypted props value is invalid.'); @@ -173,7 +173,7 @@ export function createEndpoint(manifest: SSRManifest) { if (encryptedSlots !== '') { try { - const slotsString = await decryptString(key, encryptedSlots); + const slotsString = await decryptString(key, encryptedSlots, `slots:${componentId}`); decryptedSlots = JSON.parse(slotsString); } catch (_e) { return badRequest('Encrypted slots value is invalid.'); diff --git a/packages/astro/src/runtime/server/render/server-islands.ts b/packages/astro/src/runtime/server/render/server-islands.ts index 042762ff06b4..3ec99a99a4d0 100644 --- a/packages/astro/src/runtime/server/render/server-islands.ts +++ b/packages/astro/src/runtime/server/render/server-islands.ts @@ -163,18 +163,18 @@ export class ServerIslandComponent { const key = await this.result.key; // Encrypt componentExport - const componentExportEncrypted = await encryptString(key, componentExport); + const componentExportEncrypted = await encryptString(key, componentExport, `export:${componentId}`); const propsEncrypted = Object.keys(this.props).length === 0 ? '' - : await encryptString(key, JSON.stringify(this.props)); + : await encryptString(key, JSON.stringify(this.props), `props:${componentId}`); // Encrypt slots const slotsEncrypted = Object.keys(renderedSlots).length === 0 ? '' - : await encryptString(key, JSON.stringify(renderedSlots)); + : await encryptString(key, JSON.stringify(renderedSlots), `slots:${componentId}`); const hostId = await this.getHostId(); const slash = this.result.base.endsWith('/') ? '' : '/'; diff --git a/packages/astro/test/csp-server-islands.test.ts b/packages/astro/test/csp-server-islands.test.ts index cbade7b22cbe..d08c1c52010b 100644 --- a/packages/astro/test/csp-server-islands.test.ts +++ b/packages/astro/test/csp-server-islands.test.ts @@ -21,9 +21,20 @@ async function createKeyFromString(keyString: string) { // Helper to get encrypted componentExport for 'default' async function getEncryptedComponentExport( keyString = 'eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=', + componentId = 'Island', ) { const key = await createKeyFromString(keyString); - return encryptString(key, 'default'); + return encryptString(key, 'default', `export:${componentId}`); +} + +// Helper to get encrypted props +async function getEncryptedProps( + props: Record = {}, + keyString = 'eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=', + componentId = 'Island', +) { + const key = await createKeyFromString(keyString); + return encryptString(key, JSON.stringify(props), `props:${componentId}`); } describe('Server islands', () => { @@ -62,11 +73,12 @@ describe('Server islands', () => { it('island is not indexed', async () => { const app = await fixture.loadTestAdapterApp(); const encryptedComponentExport = await getEncryptedComponentExport(); + const encryptedProps = await getEncryptedProps(); const request = new Request('http://example.com/_server-islands/Island', { method: 'POST', body: JSON.stringify({ encryptedComponentExport, - encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', + encryptedProps, encryptedSlots: '', }), headers: { diff --git a/packages/astro/test/server-islands.test.ts b/packages/astro/test/server-islands.test.ts index 15baf2d5801d..4f87aabae3fa 100644 --- a/packages/astro/test/server-islands.test.ts +++ b/packages/astro/test/server-islands.test.ts @@ -23,9 +23,20 @@ async function createKeyFromString(keyString: string) { // Helper to get encrypted componentExport for 'default' async function getEncryptedComponentExport( keyString = 'eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=', + componentId = 'Island', ) { const key = await createKeyFromString(keyString); - return encryptString(key, 'default'); + return encryptString(key, 'default', `export:${componentId}`); +} + +// Helper to get encrypted props +async function getEncryptedProps( + props: Record = {}, + keyString = 'eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=', + componentId = 'Island', +) { + const key = await createKeyFromString(keyString); + return encryptString(key, JSON.stringify(props), `props:${componentId}`); } describe('Server islands', () => { @@ -72,11 +83,12 @@ describe('Server islands', () => { it('island is not indexed', async () => { const encryptedComponentExport = await getEncryptedComponentExport(); + const encryptedProps = await getEncryptedProps(); const res = await fixture.fetch('/_server-islands/Island', { method: 'POST', body: JSON.stringify({ encryptedComponentExport, - encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', + encryptedProps, encryptedSlots: '', }), }); @@ -85,11 +97,12 @@ describe('Server islands', () => { it('island can set headers', async () => { const encryptedComponentExport = await getEncryptedComponentExport(); + const encryptedProps = await getEncryptedProps(); const res = await fixture.fetch('/_server-islands/Island', { method: 'POST', body: JSON.stringify({ encryptedComponentExport, - encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', + encryptedProps, encryptedSlots: '', }), }); @@ -112,15 +125,16 @@ describe('Server islands', () => { it('accepts encrypted slots via POST', async () => { const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M='); - const encryptedComponentExport = await encryptString(key, 'default'); + const encryptedComponentExport = await encryptString(key, 'default', 'export:Island'); + const encryptedProps = await getEncryptedProps(); const slotsToEncrypt = { content: '

Safe slot content

' }; - const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt)); + const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt), 'slots:Island'); const res = await fixture.fetch('/_server-islands/Island', { method: 'POST', body: JSON.stringify({ encryptedComponentExport, - encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', + encryptedProps, encryptedSlots: encryptedSlots, }), }); @@ -129,11 +143,12 @@ describe('Server islands', () => { it('rejects invalid encrypted slots via POST', async () => { const encryptedComponentExport = await getEncryptedComponentExport(); + const encryptedProps = await getEncryptedProps(); const res = await fixture.fetch('/_server-islands/Island', { method: 'POST', body: JSON.stringify({ encryptedComponentExport, - encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', + encryptedProps, // hard-coded invalid encrypted slot value: encryptedSlots: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLE', }), @@ -143,15 +158,16 @@ 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 encryptedComponentExport = await encryptString(key, 'default', 'export:Island'); + const encryptedProps = await getEncryptedProps(); const slotsToEncrypt = { xss: '' }; - const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt)); + const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt), 'slots:Island'); const res = await fixture.fetch('/_server-islands/Island', { method: 'POST', body: JSON.stringify({ encryptedComponentExport, - encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', + encryptedProps, encryptedSlots: encryptedSlots, }), }); @@ -237,11 +253,12 @@ describe('Server islands', () => { it('island is not indexed', async () => { const app = await fixture.loadTestAdapterApp(); const encryptedComponentExport = await getEncryptedComponentExport(); + const encryptedProps = await getEncryptedProps(); const request = new Request('http://example.com/_server-islands/Island', { method: 'POST', body: JSON.stringify({ encryptedComponentExport, - encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', + encryptedProps, encryptedSlots: '', }), headers: { @@ -273,15 +290,16 @@ 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 encryptedComponentExport = await encryptString(key, 'default', 'export:Island'); + const encryptedProps = await getEncryptedProps(); const slotsToEncrypt = { content: '

Safe slot content

' }; - const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt)); + const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt), 'slots:Island'); const request = new Request('http://example.com/_server-islands/Island', { method: 'POST', body: JSON.stringify({ encryptedComponentExport, - encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', + encryptedProps, encryptedSlots: encryptedSlots, }), headers: { @@ -295,12 +313,13 @@ describe('Server islands', () => { it('rejects invalid encrypted slots via POST', async () => { const app = await fixture.loadTestAdapterApp(); const encryptedComponentExport = await getEncryptedComponentExport(); + const encryptedProps = await getEncryptedProps(); const request = new Request('http://example.com/_server-islands/Island', { method: 'POST', body: JSON.stringify({ encryptedComponentExport, - encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', + encryptedProps, // hard-coded invalid encrypted slot value: encryptedSlots: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLE', }), @@ -315,15 +334,16 @@ 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 encryptedComponentExport = await encryptString(key, 'default', 'export:Island'); + const encryptedProps = await getEncryptedProps(); const slotsToEncrypt = { xss: '' }; - const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt)); + const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt), 'slots:Island'); const request = new Request('http://example.com/_server-islands/Island', { method: 'POST', body: JSON.stringify({ encryptedComponentExport, - encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', + encryptedProps, encryptedSlots: encryptedSlots, }), headers: { diff --git a/packages/astro/test/units/server-islands/encryption.test.ts b/packages/astro/test/units/server-islands/encryption.test.ts index 8d7514af4a56..185a671a8b10 100644 --- a/packages/astro/test/units/server-islands/encryption.test.ts +++ b/packages/astro/test/units/server-islands/encryption.test.ts @@ -15,31 +15,31 @@ describe('encryption', () => { it('round-trips correctly', async () => { const key = await createKey(); const original = 'hello world'; - const encrypted = await encryptString(key, original); - const decrypted = await decryptString(key, encrypted); + const encrypted = await encryptString(key, original, 'props:TestComponent'); + const decrypted = await decryptString(key, encrypted, 'props:TestComponent'); assert.equal(decrypted, original); }); it('round-trips an empty string', async () => { const key = await createKey(); - const encrypted = await encryptString(key, ''); - const decrypted = await decryptString(key, encrypted); + const encrypted = await encryptString(key, '', 'props:TestComponent'); + const decrypted = await decryptString(key, encrypted, 'props:TestComponent'); assert.equal(decrypted, ''); }); it('round-trips a JSON payload', async () => { const key = await createKey(); const original = JSON.stringify({ foo: 'bar', num: 42, nested: { a: [1, 2] } }); - const encrypted = await encryptString(key, original); - const decrypted = await decryptString(key, encrypted); + const encrypted = await encryptString(key, original, 'props:TestComponent'); + const decrypted = await decryptString(key, encrypted, 'props:TestComponent'); assert.equal(decrypted, original); }); it('produces a different ciphertext on each call (IV randomness)', async () => { const key = await createKey(); const plain = 'same input'; - const first = await encryptString(key, plain); - const second = await encryptString(key, plain); + const first = await encryptString(key, plain, 'props:TestComponent'); + const second = await encryptString(key, plain, 'props:TestComponent'); // Same plaintext — different ciphertext because each call uses a fresh IV assert.notEqual(first, second); }); @@ -47,25 +47,35 @@ describe('encryption', () => { it('both distinct ciphertexts decrypt to the same plaintext', async () => { const key = await createKey(); const plain = 'same input'; - const first = await encryptString(key, plain); - const second = await encryptString(key, plain); - assert.equal(await decryptString(key, first), plain); - assert.equal(await decryptString(key, second), plain); + const first = await encryptString(key, plain, 'props:TestComponent'); + const second = await encryptString(key, plain, 'props:TestComponent'); + assert.equal(await decryptString(key, first, 'props:TestComponent'), plain); + assert.equal(await decryptString(key, second, 'props:TestComponent'), plain); }); it('throws when decrypting a tampered ciphertext', async () => { const key = await createKey(); - const encrypted = await encryptString(key, 'secret'); + const encrypted = await encryptString(key, 'secret', 'props:TestComponent'); // Flip the last character to corrupt the ciphertext const tampered = encrypted.slice(0, -1) + (encrypted.endsWith('A') ? 'B' : 'A'); - await assert.rejects(() => decryptString(key, tampered)); + await assert.rejects(() => decryptString(key, tampered, 'props:TestComponent')); }); it('throws when decrypting with the wrong key', async () => { const keyA = await createKey(); const keyB = await createKey(); - const encrypted = await encryptString(keyA, 'secret'); - await assert.rejects(() => decryptString(keyB, encrypted)); + const encrypted = await encryptString(keyA, 'secret', 'props:TestComponent'); + await assert.rejects(() => decryptString(keyB, encrypted, 'props:TestComponent')); + }); + + it('throws when decrypting with mismatched additionalData', async () => { + const key = await createKey(); + // Encrypt props for ComponentA, try to decrypt as slots for ComponentB + const encrypted = await encryptString(key, '{"post":"hello"}', 'props:ComponentA'); + await assert.rejects( + () => decryptString(key, encrypted, 'slots:ComponentB'), + 'ciphertext bound to one component/purpose must not decrypt with a different one', + ); }); }); // #endregion @@ -78,8 +88,9 @@ describe('encryption', () => { const decoded = await decodeKey(encoded); // Verify the decoded key works for encrypt/decrypt const plain = 'verify key works'; - const encrypted = await encryptString(decoded, plain); - const decrypted = await decryptString(decoded, encrypted); + const aad = 'props:TestComponent'; + const encrypted = await encryptString(decoded, plain, aad); + const decrypted = await decryptString(decoded, encrypted, aad); assert.equal(decrypted, plain); }); @@ -93,10 +104,11 @@ describe('encryption', () => { it('a key encoded then decoded can decrypt ciphertexts made with the original key', async () => { const key = await createKey(); const plain = 'cross-key decrypt'; - const encrypted = await encryptString(key, plain); + const aad = 'props:TestComponent'; + const encrypted = await encryptString(key, plain, aad); const encoded = await encodeKey(key); const decoded = await decodeKey(encoded); - const decrypted = await decryptString(decoded, encrypted); + const decrypted = await decryptString(decoded, encrypted, aad); assert.equal(decrypted, plain); }); });