diff --git a/.changeset/fix-demo-template-security.md b/.changeset/fix-demo-template-security.md new file mode 100644 index 00000000..fdda8af5 --- /dev/null +++ b/.changeset/fix-demo-template-security.md @@ -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 `'`. diff --git a/packages/create-blocks-app/src/cookie-crlf.test.ts b/packages/create-blocks-app/src/cookie-crlf.test.ts new file mode 100644 index 00000000..d66c3511 --- /dev/null +++ b/packages/create-blocks-app/src/cookie-crlf.test.ts @@ -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/); +}); diff --git a/packages/create-blocks-app/src/escape-html.test.ts b/packages/create-blocks-app/src/escape-html.test.ts index 1fc36045..08012ebe 100644 --- a/packages/create-blocks-app/src/escape-html.test.ts +++ b/packages/create-blocks-app/src/escape-html.test.ts @@ -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, '&').replace(//g, '>').replace(/"/g, '"'); + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } test('escapeHtml - escapes script tags', () => { @@ -41,5 +41,17 @@ test('escapeHtml - handles empty string', () => { }); test('escapeHtml - handles string with all special chars', () => { - assert.strictEqual(escapeHtml('&<>"'), '&<>"'); + assert.strictEqual(escapeHtml(`&<>"'`), '&<>"''); +}); + +test('escapeHtml - escapes single quotes', () => { + assert.strictEqual(escapeHtml("it's a 'test'"), 'it's a 'test''); +}); + +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, '');alert(1)//'); }); diff --git a/packages/create-blocks-app/templates/demo/aws-blocks/index.ts b/packages/create-blocks-app/templates/demo/aws-blocks/index.ts index 3ef4f817..033e23e8 100644 --- a/packages/create-blocks-app/templates/demo/aws-blocks/index.ts +++ b/packages/create-blocks-app/templates/demo/aws-blocks/index.ts @@ -2,6 +2,16 @@ import { ApiNamespace, Scope, KVStore, AuthBasic, DistributedTable } from '@aws- 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 @@ -80,6 +90,8 @@ export const api = new ApiNamespace(scope, 'api', (context) => ({ // ── 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 }; }, @@ -91,6 +103,7 @@ export const api = new ApiNamespace(scope, 'api', (context) => ({ }, async deleteCookie(name: string) { + assertNoCrlf(name, 'name'); context.response.headers.set('set-cookie', `${name}=; Max-Age=0; Secure; SameSite=None; Partitioned`); return { success: true }; }, diff --git a/packages/create-blocks-app/templates/demo/src/index.ts b/packages/create-blocks-app/templates/demo/src/index.ts index 26a71d18..762d78ce 100644 --- a/packages/create-blocks-app/templates/demo/src/index.ts +++ b/packages/create-blocks-app/templates/demo/src/index.ts @@ -7,7 +7,7 @@ import { Authenticator, onAuthChange } from '@aws-blocks/blocks/ui'; /** Escape user-controlled strings before interpolating into innerHTML */ function escapeHtml(str: string): string { - return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } let currentUser: { username: string } | null = null;