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
8 changes: 8 additions & 0 deletions .changeset/fix-demo-template-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@aws-blocks/create-blocks-app": patch
---

Fix two security defects in the `demo` template:

- **Set-Cookie header (CRLF) injection**: the public `setCookie` / `deleteCookie` API methods wrote a user-controlled cookie name/value directly into the `Set-Cookie` response header. Cookie name/value components containing CR (`\r`) or LF (`\n`) are now rejected, preventing HTTP response-header injection / response splitting.
- **Incomplete HTML escaping (XSS)**: the frontend `escapeHtml` helper escaped `&`, `<`, `>`, and `"` but not the single quote (`'`), leaving an XSS gap when interpolating user-controlled values into single-quoted JS/HTML attribute contexts (e.g. `onchange="toggleTodo('...')"`). Single quotes are now escaped to `&#39;`.
60 changes: 60 additions & 0 deletions packages/create-blocks-app/src/cookie-crlf.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { test } from 'node:test';
import assert from 'node:assert';

/**
* Mirror of the cookie helpers in the demo template (aws-blocks/index.ts).
* The template's public setCookie/deleteCookie API methods write a user-controlled
* name/value into the Set-Cookie response header. Without rejecting CR/LF, an
* attacker could inject additional headers (HTTP response splitting). We mirror the
* guard + header builder here so the unit test runs without a browser or dev server.
*/
function assertNoCrlf(value: string, field: string): void {
if (/[\r\n]/.test(value)) {
throw new Error(`Invalid cookie ${field}: must not contain CR or LF characters`);
}
}

function buildSetCookie(name: string, value: string): string {
assertNoCrlf(name, 'name');
assertNoCrlf(value, 'value');
return `${name}=${value}; Max-Age=3600; Secure; SameSite=None; Partitioned`;
}

function buildDeleteCookie(name: string): string {
assertNoCrlf(name, 'name');
return `${name}=; Max-Age=0; Secure; SameSite=None; Partitioned`;
}

test('setCookie - builds a valid Set-Cookie header for clean input', () => {
assert.strictEqual(
buildSetCookie('session', 'abc123'),
'session=abc123; Max-Age=3600; Secure; SameSite=None; Partitioned'
);
});

test('deleteCookie - builds a valid expiry Set-Cookie header for clean input', () => {
assert.strictEqual(
buildDeleteCookie('session'),
'session=; Max-Age=0; Secure; SameSite=None; Partitioned'
);
});

test('setCookie - rejects CRLF injection in the cookie value', () => {
const malicious = 'x\r\nSet-Cookie: admin=true';
assert.throws(() => buildSetCookie('session', malicious), /must not contain CR or LF/);
});

test('setCookie - rejects CRLF injection in the cookie name', () => {
const malicious = 'session\r\nLocation: https://evil.example';
assert.throws(() => buildSetCookie(malicious, 'abc123'), /must not contain CR or LF/);
});

test('setCookie - rejects a bare LF and a bare CR', () => {
assert.throws(() => buildSetCookie('a', 'b\nc'), /must not contain CR or LF/);
assert.throws(() => buildSetCookie('a', 'b\rc'), /must not contain CR or LF/);
});

test('deleteCookie - rejects CRLF injection in the cookie name', () => {
const malicious = 'session\r\nSet-Cookie: admin=true';
assert.throws(() => buildDeleteCookie(malicious), /must not contain CR or LF/);
});
16 changes: 14 additions & 2 deletions packages/create-blocks-app/src/escape-html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import assert from 'node:assert';
* We test it here so the unit test runs without needing a browser or dev server.
*/
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

test('escapeHtml - escapes script tags', () => {
Expand Down Expand Up @@ -41,5 +41,17 @@ test('escapeHtml - handles empty string', () => {
});

test('escapeHtml - handles string with all special chars', () => {
assert.strictEqual(escapeHtml('&<>"'), '&amp;&lt;&gt;&quot;');
assert.strictEqual(escapeHtml(`&<>"'`), '&amp;&lt;&gt;&quot;&#39;');
});

test('escapeHtml - escapes single quotes', () => {
assert.strictEqual(escapeHtml("it's a 'test'"), 'it&#39;s a &#39;test&#39;');
});

test('escapeHtml - prevents single-quote attribute breakout', () => {
// Mirrors demo usage: onchange="toggleTodo('${escapeHtml(id)}', ...)" — a raw
// single quote would break out of the JS string literal and enable XSS.
const result = escapeHtml("');alert(1)//");
assert.ok(!result.includes("'"), 'raw single quote must not survive escaping');
assert.strictEqual(result, '&#39;);alert(1)//');
});
13 changes: 13 additions & 0 deletions packages/create-blocks-app/templates/demo/aws-blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
import crypto from 'node:crypto';
import { z } from 'zod';

/**
* Reject cookie name/value components that contain CR or LF characters.
* Prevents HTTP response-header (Set-Cookie) injection from user-controlled input.
*/
function assertNoCrlf(value: string, field: string): void {
if (/[\r\n]/.test(value)) {
throw new Error(`Invalid cookie ${field}: must not contain CR or LF characters`);
}
}

// For coding agents: Leave these comments in place for future reference.
// Read node_modules/@aws-blocks/blocks/README.md for all available Building Blocks
// Hover over Building Blocks to see docstrings with usage, best practices, and performance characteristics
Expand Down Expand Up @@ -59,7 +69,7 @@
});

// Simple hello world API for testing CDK deployment
export const hello = new ApiNamespace(scope, 'hello', (context) => ({

Check warning on line 72 in packages/create-blocks-app/templates/demo/aws-blocks/index.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/correctness/noUnusedFunctionParameters

This parameter context is unused.
async greet(name: string) {
return { message: `Hello, ${name}!`, timestamp: Date.now() };
}
Expand All @@ -80,6 +90,8 @@

// ── Public — cookie round-trip demo ──────────────────────────────────
async setCookie(name: string, value: string) {
assertNoCrlf(name, 'name');
assertNoCrlf(value, 'value');
context.response.headers.set('set-cookie', `${name}=${value}; Max-Age=3600; Secure; SameSite=None; Partitioned`);
return { success: true };
},
Expand All @@ -91,6 +103,7 @@
},

async deleteCookie(name: string) {
assertNoCrlf(name, 'name');
context.response.headers.set('set-cookie', `${name}=; Max-Age=0; Secure; SameSite=None; Partitioned`);
return { success: true };
},
Expand Down
2 changes: 1 addition & 1 deletion packages/create-blocks-app/templates/demo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

/** Escape user-controlled strings before interpolating into innerHTML */
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

let currentUser: { username: string } | null = null;
Expand All @@ -33,7 +33,7 @@
return;
}

const priorityLabels = { 1: '🔴 High', 2: '🟡 Medium', 3: '🟢 Low' };

Check warning on line 36 in packages/create-blocks-app/templates/demo/src/index.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/correctness/noUnusedVariables

This variable priorityLabels is unused.

todoList.innerHTML = todos.map(todo => `
<div class="todo-item">
Expand Down Expand Up @@ -66,7 +66,7 @@
if (errorDiv) errorDiv.innerHTML = '';

try {
await api.createTodo(title, parseInt(prioritySelect.value));

Check notice on line 69 in packages/create-blocks-app/templates/demo/src/index.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/correctness/useParseIntRadix

Missing radix parameter
input.value = '';
await refreshTodos();
} catch (error: any) {
Expand Down Expand Up @@ -165,7 +165,7 @@
const value = (document.getElementById('cookieValue') as HTMLInputElement).value;

await api.setCookie(name, value);
document.getElementById('cookie-result')!.innerHTML =

Check warning on line 168 in packages/create-blocks-app/templates/demo/src/index.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
`<span class="success">✓ Set cookie ${escapeHtml(name)} = ${escapeHtml(value)}</span>`;
};

Expand All @@ -173,7 +173,7 @@
const name = (document.getElementById('cookieName') as HTMLInputElement).value;

const value = await api.getCookie(name);
document.getElementById('cookie-result')!.innerHTML =

Check warning on line 176 in packages/create-blocks-app/templates/demo/src/index.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
value ? `<span class="success">✓ Got cookie: ${escapeHtml(value)}</span>`
: `<span class="error">✗ Cookie not found</span>`;
};
Expand All @@ -182,7 +182,7 @@
const name = (document.getElementById('cookieName') as HTMLInputElement).value;

await api.deleteCookie(name);
document.getElementById('cookie-result')!.innerHTML =

Check warning on line 185 in packages/create-blocks-app/templates/demo/src/index.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
`<span class="success">✓ Deleted cookie ${escapeHtml(name)}</span>`;
};

Expand All @@ -191,9 +191,9 @@
const key = (document.getElementById('key') as HTMLInputElement).value;
const value = (document.getElementById('value') as HTMLInputElement).value;

const result = await api.setValue(key, value);

Check warning on line 194 in packages/create-blocks-app/templates/demo/src/index.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/correctness/noUnusedVariables

This variable result is unused.

document.getElementById('kv-result')!.innerHTML =

Check warning on line 196 in packages/create-blocks-app/templates/demo/src/index.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
`<span class="success">✓ Set ${escapeHtml(key)} = ${escapeHtml(value)}</span>`;
};

Expand All @@ -202,7 +202,7 @@

const value = await api.getValue(key);

document.getElementById('kv-result')!.innerHTML =

Check warning on line 205 in packages/create-blocks-app/templates/demo/src/index.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
value ? `<span class="success">✓ Got value: ${escapeHtml(value)}</span>`
: `<span class="error">✗ Key not found</span>`;
};
Expand All @@ -223,9 +223,9 @@
const deleted = await api.getCookie('testCookie');
results.push(!deleted ? '✓ Cookie delete works' : '✗ Cookie delete failed');

document.getElementById('test-results')!.innerHTML = results.join('<br>');

Check warning on line 226 in packages/create-blocks-app/templates/demo/src/index.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
} catch (error: any) {
document.getElementById('test-results')!.innerHTML =

Check warning on line 228 in packages/create-blocks-app/templates/demo/src/index.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
`<span class="error">✗ Tests failed: ${escapeHtml(error.message)}</span>`;
}
};
Expand Down
Loading