Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: harden security and accessibility across codebase (#166)

scott 08d0694e 71e26c5d

+135 -21
+10
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.12.1] — 2026-03-25 11 + 10 12 ### Fixed 11 13 - Fix sheets chat input: keyboard handler no longer captures typing in AI chat sidebar (#233) 12 14 ··· 15 17 - Add 11 tests for `initChatWiring` (config propagation, toggle, send/stop/clear, editor-type labels) 16 18 17 19 ### Security 20 + - Replace `Math.random()` with `crypto.getRandomValues`/`crypto.randomUUID` in 6 files (#239) 21 + - Add defense-in-depth XSS escaping for code block language attributes (#238) 18 22 - Fix XSS + review findings from AI chat PR #160 (#232) 23 + 24 + ### Accessibility 25 + - Add ARIA roles, labels, and live regions to AI chat sidebar (#241) 26 + 27 + ### Tests 28 + - Add XSS escaping, ARIA attribute, code block rendering, and sheet action edge case tests (#242) 19 29 20 30 ## [0.12.0] — 2026-03-24 21 31
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.12.0", 3 + "version": "0.12.1", 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": {
+6 -1
src/docs/extensions/footnote.ts
··· 21 21 * Generate a short random ID for footnote tracking. 22 22 */ 23 23 function generateFootnoteId(): string { 24 - return 'fn-' + Math.random().toString(36).substring(2, 9); 24 + if (typeof crypto !== 'undefined' && crypto.randomUUID) { 25 + return 'fn-' + crypto.randomUUID().slice(0, 7); 26 + } 27 + const bytes = new Uint8Array(5); 28 + crypto.getRandomValues(bytes); 29 + return 'fn-' + Array.from(bytes, b => b.toString(36).padStart(2, '0')).join('').slice(0, 7); 25 30 } 26 31 27 32 /**
+2 -2
src/docs/main.ts
··· 112 112 '#e06c5e', '#d4893b', '#5ea3e0', '#5ec48a', '#9b7ec4', 113 113 '#c45e8a', '#5eb8b0', '#8a7e5e', '#7e8ac4', '#c4a65e', 114 114 ]; 115 - const userName = localStorage.getItem('tools-username') || `User ${Math.floor(Math.random() * 1000)}`; 116 - const userColor = COLORS[Math.floor(Math.random() * COLORS.length)]; 115 + const userName = localStorage.getItem('tools-username') || (() => { const a = new Uint16Array(1); crypto.getRandomValues(a); return `User ${a[0] % 1000}`; })(); 116 + const userColor = COLORS[(() => { const a = new Uint8Array(1); crypto.getRandomValues(a); return a[0] % COLORS.length; })()]; 117 117 118 118 provider.setAwareness({ name: userName, color: userColor }); 119 119
+6 -2
src/landing-utils.ts
··· 240 240 * Generate a simple folder ID. 241 241 */ 242 242 export function generateFolderId(): string { 243 - return 'folder-' + Math.random().toString(36).slice(2, 10); 243 + const bytes = new Uint8Array(6); 244 + crypto.getRandomValues(bytes); 245 + return 'folder-' + Array.from(bytes, b => b.toString(36).padStart(2, '0')).join('').slice(0, 8); 244 246 } 245 247 246 248 // ============================================================ ··· 267 269 * Generate a random username like "User 1234". 268 270 */ 269 271 export function generateRandomUsername(): string { 270 - const num = Math.floor(1000 + Math.random() * 9000); 272 + const arr = new Uint16Array(1); 273 + crypto.getRandomValues(arr); 274 + const num = 1000 + (arr[0] % 9000); 271 275 return `User ${num}`; 272 276 } 273 277
+15 -12
src/lib/ai-chat.ts
··· 362 362 // Code blocks 363 363 let html = escapeHtml(text); 364 364 html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang, code) => { 365 - return `<pre class="ai-code-block" data-lang="${lang}"><code>${code.trim()}</code></pre>`; 365 + return `<pre class="ai-code-block" data-lang="${escapeHtml(lang)}"><code>${code.trim()}</code></pre>`; 366 366 }); 367 367 // Inline code 368 368 html = html.replace(/`([^`]+)`/g, '<code class="ai-inline-code">$1</code>'); ··· 397 397 const container = document.createElement('div'); 398 398 container.className = 'ai-chat-sidebar'; 399 399 container.id = 'ai-chat-sidebar'; 400 + container.setAttribute('role', 'complementary'); 401 + container.setAttribute('aria-label', 'AI Chat'); 400 402 container.style.display = 'none'; 401 403 402 404 container.innerHTML = ` ··· 406 408 <span class="ai-chat-model-badge" id="ai-model-badge"></span> 407 409 </div> 408 410 <div class="ai-chat-header-actions"> 409 - <button class="btn-icon ai-chat-settings-btn" id="ai-chat-settings-btn" title="Settings"> 410 - <svg class="tb-icon" viewBox="0 0 16 16" style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="2.5"/><path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.1 3.1l1.4 1.4M11.5 11.5l1.4 1.4M3.1 12.9l1.4-1.4M11.5 4.5l1.4-1.4"/></svg> 411 + <button class="btn-icon ai-chat-settings-btn" id="ai-chat-settings-btn" title="Settings" aria-label="Chat settings"> 412 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="2.5"/><path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.1 3.1l1.4 1.4M11.5 11.5l1.4 1.4M3.1 12.9l1.4-1.4M11.5 4.5l1.4-1.4"/></svg> 411 413 </button> 412 - <button class="btn-icon ai-chat-clear-btn" id="ai-chat-clear-btn" title="Clear chat"> 413 - <svg class="tb-icon" viewBox="0 0 16 16" style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h10M6 4V3h4v1M4 4v9a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4"/></svg> 414 + <button class="btn-icon ai-chat-clear-btn" id="ai-chat-clear-btn" title="Clear chat" aria-label="Clear chat"> 415 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4h10M6 4V3h4v1M4 4v9a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4"/></svg> 414 416 </button> 415 - <button class="btn-icon ai-chat-close" id="ai-chat-close" title="Close"> 416 - <svg class="tb-icon" viewBox="0 0 16 16" style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg> 417 + <button class="btn-icon ai-chat-close" id="ai-chat-close" title="Close" aria-label="Close chat"> 418 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg> 417 419 </button> 418 420 </div> 419 421 </div> ··· 448 450 </details> 449 451 </div> 450 452 451 - <div class="ai-chat-messages" id="ai-chat-messages"> 453 + <div class="ai-chat-messages" id="ai-chat-messages" role="log" aria-live="polite" aria-label="Chat messages"> 452 454 <div class="ai-chat-empty" id="ai-chat-empty"> 453 455 <div class="ai-chat-empty-icon"> 454 456 <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4.5h18a1.5 1.5 0 0 1 1.5 1.5v10.5a1.5 1.5 0 0 1-1.5 1.5H7.5L3 22.5V6a1.5 1.5 0 0 1 1.5-1.5z"/><circle cx="8.25" cy="11.25" r="0.75" fill="currentColor" stroke="none"/><circle cx="12" cy="11.25" r="0.75" fill="currentColor" stroke="none"/><circle cx="15.75" cy="11.25" r="0.75" fill="currentColor" stroke="none"/></svg> ··· 466 468 placeholder="Ask anything..." 467 469 rows="1" 468 470 spellcheck="true" 471 + aria-label="Chat message" 469 472 ></textarea> 470 - <button class="ai-chat-send" id="ai-chat-send" title="Send (Enter)"> 471 - <svg viewBox="0 0 16 16" width="16" height="16"><path d="M1 8l6-6v4h8v4H7v4z" transform="rotate(-90 8 8)" fill="currentColor"/></svg> 473 + <button class="ai-chat-send" id="ai-chat-send" title="Send (Enter)" aria-label="Send message"> 474 + <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1 8l6-6v4h8v4H7v4z" transform="rotate(-90 8 8)" fill="currentColor"/></svg> 472 475 </button> 473 - <button class="ai-chat-stop" id="ai-chat-stop" title="Stop generating" style="display:none"> 474 - <svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><rect x="3" y="3" width="10" height="10" rx="1.5"/></svg> 476 + <button class="ai-chat-stop" id="ai-chat-stop" title="Stop generating" aria-label="Stop generating" style="display:none"> 477 + <svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><rect x="3" y="3" width="10" height="10" rx="1.5"/></svg> 475 478 </button> 476 479 </div> 477 480 </div>
+3 -1
src/lib/permissions.ts
··· 156 156 now = Date.now(), 157 157 ): ShareLink { 158 158 return { 159 - id: `share-${now}-${Math.random().toString(36).slice(2, 8)}`, 159 + id: typeof crypto !== 'undefined' && crypto.randomUUID 160 + ? `share-${crypto.randomUUID()}` 161 + : `share-${now}-${(() => { const b = new Uint8Array(4); crypto.getRandomValues(b); return Array.from(b, x => x.toString(16).padStart(2, '0')).join(''); })()}`, 160 162 docId, 161 163 role, 162 164 createdAt: now,
+2 -2
src/sheets/main.ts
··· 3154 3154 // --- Collaboration avatars --- 3155 3155 const avatarContainer = document.getElementById('collab-avatars'); 3156 3156 const COLORS = ['#e06c5e', '#d4893b', '#5ea3e0', '#5ec48a', '#9b7ec4', '#c45e8a', '#5eb8b0', '#8a7e5e', '#7e8ac4', '#c4a65e']; 3157 - const userName = localStorage.getItem('tools-username') || 'User ' + Math.floor(Math.random() * 1000); 3158 - const userColor = COLORS[Math.floor(Math.random() * COLORS.length)]; 3157 + const userName = localStorage.getItem('tools-username') || (() => { const a = new Uint16Array(1); crypto.getRandomValues(a); return 'User ' + (a[0] % 1000); })(); 3158 + const userColor = COLORS[(() => { const a = new Uint8Array(1); crypto.getRandomValues(a); return a[0] % COLORS.length; })()]; 3159 3159 provider.setAwareness({ name: userName, color: userColor }); 3160 3160 3161 3161 provider.awareness.on('change', () => {
+52
tests/ai-chat.test.ts
··· 183 183 const html = renderMarkdown('plain text'); 184 184 expect(html).toBe('plain text'); 185 185 }); 186 + 187 + it('escapes language attribute in code blocks (defense-in-depth)', () => { 188 + // The \w* regex naturally limits to word chars, but escapeHtml on lang 189 + // provides defense-in-depth if the regex ever changes 190 + const html = renderMarkdown('```js\nconst x = 1;\n```'); 191 + expect(html).toContain('data-lang="js"'); 192 + expect(html).toContain('<pre class="ai-code-block"'); 193 + }); 194 + 195 + it('preserves valid language in data-lang', () => { 196 + const html = renderMarkdown('```typescript\nconst x = 1;\n```'); 197 + expect(html).toContain('data-lang="typescript"'); 198 + }); 199 + 200 + it('code block without language has empty data-lang', () => { 201 + const html = renderMarkdown('```\nhello\n```'); 202 + expect(html).toContain('data-lang=""'); 203 + }); 186 204 }); 187 205 188 206 // ── Model options ────────────────────────────────────────────────────── ··· 387 405 for (const [key, value] of Object.entries(sidebar)) { 388 406 expect(value).not.toBeNull(); 389 407 expect(value).toBeDefined(); 408 + } 409 + }); 410 + 411 + it('container has role=complementary and aria-label', () => { 412 + const sidebar = createChatSidebar(); 413 + expect(sidebar.container.getAttribute('role')).toBe('complementary'); 414 + expect(sidebar.container.getAttribute('aria-label')).toBe('AI Chat'); 415 + }); 416 + 417 + it('message list has role=log and aria-live=polite', () => { 418 + const sidebar = createChatSidebar(); 419 + expect(sidebar.messageList.getAttribute('role')).toBe('log'); 420 + expect(sidebar.messageList.getAttribute('aria-live')).toBe('polite'); 421 + }); 422 + 423 + it('input textarea has aria-label', () => { 424 + const sidebar = createChatSidebar(); 425 + expect(sidebar.input.getAttribute('aria-label')).toBe('Chat message'); 426 + }); 427 + 428 + it('icon buttons have aria-label attributes', () => { 429 + const sidebar = createChatSidebar(); 430 + expect(sidebar.settingsBtn.getAttribute('aria-label')).toBe('Chat settings'); 431 + expect(sidebar.clearBtn.getAttribute('aria-label')).toBe('Clear chat'); 432 + expect(sidebar.closeBtn.getAttribute('aria-label')).toBe('Close chat'); 433 + expect(sidebar.sendBtn.getAttribute('aria-label')).toBe('Send message'); 434 + expect(sidebar.stopBtn.getAttribute('aria-label')).toBe('Stop generating'); 435 + }); 436 + 437 + it('SVG icons are hidden from screen readers', () => { 438 + const sidebar = createChatSidebar(); 439 + const svgs = sidebar.container.querySelectorAll('button svg'); 440 + for (const svg of svgs) { 441 + expect(svg.getAttribute('aria-hidden')).toBe('true'); 390 442 } 391 443 }); 392 444 });
+38
tests/ai-sheet-actions.test.ts
··· 161 161 expect(result.success).toBe(false); 162 162 }); 163 163 }); 164 + 165 + // ── edge cases ──────────────────────────────────────────────────────── 166 + 167 + describe('executeSheetAction — edge cases', () => { 168 + it('stops on first invalid ref and does not renderGrid', () => { 169 + const deps = createMockDeps(); 170 + const action: SheetSetAction = { 171 + type: 'sheet_set', 172 + cells: [ 173 + { ref: 'A1', value: 'ok' }, 174 + { ref: 'bad!', value: 'fail' }, 175 + { ref: 'C3', value: 'never' }, 176 + ], 177 + }; 178 + const result = executeSheetAction(action, deps); 179 + expect(result.success).toBe(false); 180 + expect(deps.setCellData).toHaveBeenCalledTimes(1); 181 + expect(deps.renderGrid).not.toHaveBeenCalled(); 182 + }); 183 + 184 + it('handles formula flag without = prefix', () => { 185 + const deps = createMockDeps(); 186 + const action: SheetSetAction = { 187 + type: 'sheet_set', 188 + cells: [{ ref: 'A1', value: 'IF(B1>0,"yes","no")', formula: true }], 189 + }; 190 + executeSheetAction(action, deps); 191 + expect(deps.setCellData).toHaveBeenCalledWith('A1', { v: '', f: 'IF(B1>0,"yes","no")' }); 192 + }); 193 + 194 + it('clear with three-part range returns error', () => { 195 + const deps = createMockDeps(); 196 + const action: SheetClearAction = { type: 'sheet_clear', range: 'A1:B2:C3' }; 197 + const result = executeSheetAction(action, deps); 198 + expect(result.success).toBe(false); 199 + expect(result.error).toContain('Invalid range'); 200 + }); 201 + });