CVE-2026-45028
Description
Astro is a web framework. Astro versions prior to 6.1.10 used AES-GCM encryption to protect the confidentiality and integrity of server island props and slots parameters, but did not bind the ciphertext to its intended component or parameter type. An attacker could replay one component's encrypted props (p) value as another component's slots (s) value, or vice versa. Since slots contain raw unescaped HTML while props may contain user-controlled values, this could lead to XSS in applications. This occurs when the application uses server islands, two different server island components share the same key name for a prop and a slot, and an attacker has full control over the value of the overlapping prop (requires a dynamically rendered page). This vulnerability is fixed in 6.1.10.
Affected products
1Patches
13d82220a1549Add AEAD context binding to server island encryption (#16457)
7 files changed · +111 −64
.changeset/server-island-encryption-aad.md+5 −0 added@@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Hardens server island encryption to prevent encrypted data from one island component being replayed against a different one
packages/astro/src/core/encryption.ts+16 −18 modified@@ -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<ArrayBuffer>; const dataArray = decodeBase64(encoded.slice(IV_LENGTH)) as Uint8Array<ArrayBuffer>; - 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; }
packages/astro/src/core/server-islands/endpoint.ts+3 −3 modified@@ -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.');
packages/astro/src/runtime/server/render/server-islands.ts+3 −3 modified@@ -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('/') ? '' : '/';
packages/astro/test/csp-server-islands.test.ts+14 −2 modified@@ -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<string, unknown> = {}, + 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: {
packages/astro/test/server-islands.test.ts+38 −18 modified@@ -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<string, unknown> = {}, + 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: '<p>Safe slot content</p>' }; - 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: '<img src=x onerror=alert(0)>' }; - 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: '<p>Safe slot content</p>' }; - 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: '<img src=x onerror=alert(0)>' }; - 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: {
packages/astro/test/units/server-islands/encryption.test.ts+32 −20 modified@@ -15,57 +15,67 @@ 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); }); 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); }); });
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
5- github.com/withastro/astro/commit/3d82220a1549e699e34ed433f3846a919f4c02bdnvdPatch
- github.com/withastro/astro/pull/16457nvdIssue TrackingPatch
- github.com/advisories/GHSA-xr5h-phrj-8vxvghsaADVISORY
- github.com/withastro/astro/security/advisories/GHSA-xr5h-phrj-8vxvnvdVendor Advisory
- nvd.nist.gov/vuln/detail/CVE-2026-45028ghsa
News mentions
0No linked articles in our index yet.