Skip to content

Commit 3c365e3

Browse files
authored
fix: sanitize user-controlled values in demo template innerHTML assignments (#178)
1 parent a1de3b8 commit 3c365e3

3 files changed

Lines changed: 72 additions & 17 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@aws-blocks/create-blocks-app": patch
3+
---
4+
5+
Sanitize user-controlled values in demo template innerHTML assignments
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert';
3+
4+
/**
5+
* Mirror of the escapeHtml helper added to the demo template (src/index.ts).
6+
* We test it here so the unit test runs without needing a browser or dev server.
7+
*/
8+
function escapeHtml(str: string): string {
9+
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
10+
}
11+
12+
test('escapeHtml - escapes script tags', () => {
13+
const input = '<script>alert(1)</script>';
14+
const result = escapeHtml(input);
15+
assert.strictEqual(result, '&lt;script&gt;alert(1)&lt;/script&gt;');
16+
assert.ok(!result.includes('<script>'));
17+
});
18+
19+
test('escapeHtml - escapes ampersands', () => {
20+
assert.strictEqual(escapeHtml('a & b'), 'a &amp; b');
21+
});
22+
23+
test('escapeHtml - escapes double quotes', () => {
24+
assert.strictEqual(escapeHtml('value="xss"'), 'value=&quot;xss&quot;');
25+
});
26+
27+
test('escapeHtml - escapes angle brackets in attribute injection', () => {
28+
const input = '"><img src=x onerror=alert(1)>';
29+
const result = escapeHtml(input);
30+
assert.strictEqual(result, '&quot;&gt;&lt;img src=x onerror=alert(1)&gt;');
31+
assert.ok(!result.includes('<img'));
32+
});
33+
34+
test('escapeHtml - preserves safe strings unchanged', () => {
35+
assert.strictEqual(escapeHtml('hello world'), 'hello world');
36+
assert.strictEqual(escapeHtml('key-123_abc'), 'key-123_abc');
37+
});
38+
39+
test('escapeHtml - handles empty string', () => {
40+
assert.strictEqual(escapeHtml(''), '');
41+
});
42+
43+
test('escapeHtml - handles string with all special chars', () => {
44+
assert.strictEqual(escapeHtml('&<>"'), '&amp;&lt;&gt;&quot;');
45+
});

packages/create-blocks-app/templates/demo/src/index.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import { Authenticator, onAuthChange } from '@aws-blocks/blocks/ui';
55
// For coding agents: Leave this comment in place for future reference.
66
// Read node_modules/@aws-blocks/blocks/README.md for Building Block documentation
77

8+
/** Escape user-controlled strings before interpolating into innerHTML */
9+
function escapeHtml(str: string): string {
10+
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
11+
}
12+
813
let currentUser: { username: string } | null = null;
914
let currentSort: 'priority' | 'title' | 'createdAt' | undefined ;
1015

@@ -33,20 +38,20 @@ async function refreshTodos() {
3338
todoList.innerHTML = todos.map(todo => `
3439
<div class="todo-item">
3540
<input type="checkbox" ${todo.completed ? 'checked' : ''}
36-
onchange="toggleTodo('${todo.todoId}', this.checked)">
37-
<input type="text" class="todo-title" value="${todo.title}"
38-
onblur="updateTitle('${todo.todoId}', this.value)"
41+
onchange="toggleTodo('${escapeHtml(todo.todoId)}', this.checked)">
42+
<input type="text" class="todo-title" value="${escapeHtml(todo.title)}"
43+
onblur="updateTitle('${escapeHtml(todo.todoId)}', this.value)"
3944
onkeypress="if(event.key==='Enter') this.blur()">
40-
<select onchange="changePriority('${todo.todoId}', parseInt(this.value))" style="margin-left: auto;">
45+
<select onchange="changePriority('${escapeHtml(todo.todoId)}', parseInt(this.value))" style="margin-left: auto;">
4146
<option value="1" ${todo.priority === 1 ? 'selected' : ''}>🔴 High</option>
4247
<option value="2" ${todo.priority === 2 ? 'selected' : ''}>🟡 Medium</option>
4348
<option value="3" ${todo.priority === 3 ? 'selected' : ''}>🟢 Low</option>
4449
</select>
45-
<button onclick="deleteTodo('${todo.todoId}')">Delete</button>
50+
<button onclick="deleteTodo('${escapeHtml(todo.todoId)}')">Delete</button>
4651
</div>
4752
`).join('');
4853
} catch (error: any) {
49-
if (errorDiv) errorDiv.innerHTML = `<span class="error">${error.message}</span>`;
54+
if (errorDiv) errorDiv.innerHTML = `<span class="error">${escapeHtml(error.message)}</span>`;
5055
}
5156
}
5257

@@ -65,7 +70,7 @@ async function refreshTodos() {
6570
input.value = '';
6671
await refreshTodos();
6772
} catch (error: any) {
68-
if (errorDiv) errorDiv.innerHTML = `<span class="error">${error.message}</span>`;
73+
if (errorDiv) errorDiv.innerHTML = `<span class="error">${escapeHtml(error.message)}</span>`;
6974
}
7075
};
7176

@@ -84,7 +89,7 @@ async function refreshTodos() {
8489
await api.updateTodo(todoId, { completed });
8590
await refreshTodos();
8691
} catch (error: any) {
87-
if (errorDiv) errorDiv.innerHTML = `<span class="error">${error.message}</span>`;
92+
if (errorDiv) errorDiv.innerHTML = `<span class="error">${escapeHtml(error.message)}</span>`;
8893
await refreshTodos();
8994
}
9095
};
@@ -98,7 +103,7 @@ async function refreshTodos() {
98103
await api.updateTodo(todoId, { priority });
99104
await refreshTodos();
100105
} catch (error: any) {
101-
if (errorDiv) errorDiv.innerHTML = `<span class="error">${error.message}</span>`;
106+
if (errorDiv) errorDiv.innerHTML = `<span class="error">${escapeHtml(error.message)}</span>`;
102107
await refreshTodos();
103108
}
104109
};
@@ -118,7 +123,7 @@ async function refreshTodos() {
118123
await api.updateTodo(todoId, { title: trimmedTitle });
119124
await refreshTodos();
120125
} catch (error: any) {
121-
if (errorDiv) errorDiv.innerHTML = `<span class="error">${error.message}</span>`;
126+
if (errorDiv) errorDiv.innerHTML = `<span class="error">${escapeHtml(error.message)}</span>`;
122127
await refreshTodos();
123128
}
124129
};
@@ -132,7 +137,7 @@ async function refreshTodos() {
132137
await api.deleteTodo(todoId);
133138
await refreshTodos();
134139
} catch (error: any) {
135-
if (errorDiv) errorDiv.innerHTML = `<span class="error">${error.message}</span>`;
140+
if (errorDiv) errorDiv.innerHTML = `<span class="error">${escapeHtml(error.message)}</span>`;
136141
}
137142
};
138143

@@ -161,15 +166,15 @@ document.addEventListener('DOMContentLoaded', async () => {
161166

162167
await api.setCookie(name, value);
163168
document.getElementById('cookie-result')!.innerHTML =
164-
`<span class="success">✓ Set cookie ${name} = ${value}</span>`;
169+
`<span class="success">✓ Set cookie ${escapeHtml(name)} = ${escapeHtml(value)}</span>`;
165170
};
166171

167172
(window as any).testGetCookie = async () => {
168173
const name = (document.getElementById('cookieName') as HTMLInputElement).value;
169174

170175
const value = await api.getCookie(name);
171176
document.getElementById('cookie-result')!.innerHTML =
172-
value ? `<span class="success">✓ Got cookie: ${value}</span>`
177+
value ? `<span class="success">✓ Got cookie: ${escapeHtml(value)}</span>`
173178
: `<span class="error">✗ Cookie not found</span>`;
174179
};
175180

@@ -178,7 +183,7 @@ document.addEventListener('DOMContentLoaded', async () => {
178183

179184
await api.deleteCookie(name);
180185
document.getElementById('cookie-result')!.innerHTML =
181-
`<span class="success">✓ Deleted cookie ${name}</span>`;
186+
`<span class="success">✓ Deleted cookie ${escapeHtml(name)}</span>`;
182187
};
183188

184189
// KV Store test functions
@@ -189,7 +194,7 @@ document.addEventListener('DOMContentLoaded', async () => {
189194
const result = await api.setValue(key, value);
190195

191196
document.getElementById('kv-result')!.innerHTML =
192-
`<span class="success">✓ Set ${key} = ${value}</span>`;
197+
`<span class="success">✓ Set ${escapeHtml(key)} = ${escapeHtml(value)}</span>`;
193198
};
194199

195200
(window as any).testGetValue = async () => {
@@ -198,7 +203,7 @@ document.addEventListener('DOMContentLoaded', async () => {
198203
const value = await api.getValue(key);
199204

200205
document.getElementById('kv-result')!.innerHTML =
201-
value ? `<span class="success">✓ Got value: ${value}</span>`
206+
value ? `<span class="success">✓ Got value: ${escapeHtml(value)}</span>`
202207
: `<span class="error">✗ Key not found</span>`;
203208
};
204209

@@ -221,7 +226,7 @@ document.addEventListener('DOMContentLoaded', async () => {
221226
document.getElementById('test-results')!.innerHTML = results.join('<br>');
222227
} catch (error: any) {
223228
document.getElementById('test-results')!.innerHTML =
224-
`<span class="error">✗ Tests failed: ${error.message}</span>`;
229+
`<span class="error">✗ Tests failed: ${escapeHtml(error.message)}</span>`;
225230
}
226231
};
227232

0 commit comments

Comments
 (0)