Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/server-island-encryption-aad.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Hardens server island encryption to prevent encrypted data from one island component being replayed against a different one
34 changes: 16 additions & 18 deletions packages/astro/src/core/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 3 additions & 3 deletions packages/astro/src/core/server-islands/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
Expand All @@ -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.');
Expand All @@ -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.');
Expand Down
6 changes: 3 additions & 3 deletions packages/astro/src/runtime/server/render/server-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('/') ? '' : '/';
Expand Down
16 changes: 14 additions & 2 deletions packages/astro/test/csp-server-islands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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: {
Expand Down
56 changes: 38 additions & 18 deletions packages/astro/test/server-islands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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: '',
}),
});
Expand All @@ -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: '',
}),
});
Expand All @@ -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,
}),
});
Expand All @@ -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',
}),
Expand All @@ -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,
}),
});
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand All @@ -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',
}),
Expand All @@ -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: {
Expand Down
Loading
Loading