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

Configure Feed

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

Merge pull request 'fix: slides XSS, server input validation, key-sync error handling' (#237) from fix/fit-finish-round5 into main

scott d86725e7 9b711dd8

+42 -8
+11
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [0.23.2] — 2026-04-06 9 + 10 + ### Fixed 11 + - Slides XSS: use textContent instead of innerHTML for user text elements (#363) 12 + - Slides: save textContent on blur instead of innerHTML to prevent stored XSS (#363) 13 + - Slides: use DOM API for image elements instead of innerHTML (#363) 14 + - Server: validate name_encrypted and tags type/length on PUT endpoints (#363) 15 + - Server: sanitize Content-Disposition filename to prevent header injection (#363) 16 + - Server: validate and length-limit MIME type on blob upload (#363) 17 + - Key-sync: handle pushKeysToServer rejection to prevent unhandled promise (#363) 18 + 8 19 ## [0.23.1] — 2026-04-06 9 20 10 21 ### Fixed
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.23.1", 3 + "version": "0.23.2", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+17 -3
server/index.ts
··· 463 463 464 464 app.put('/api/documents/:id/name', (req: Request<{ id: string }, unknown, UpdateNameBody>, res: Response) => { 465 465 const { name_encrypted } = req.body; 466 + if (name_encrypted != null && typeof name_encrypted !== 'string') { 467 + return res.status(400).json({ error: 'name_encrypted must be a string or null' }); 468 + } 469 + if (typeof name_encrypted === 'string' && name_encrypted.length > 10000) { 470 + return res.status(400).json({ error: 'name_encrypted too long' }); 471 + } 466 472 stmts.putName.run(name_encrypted, req.params.id); 467 473 res.json({ ok: true }); 468 474 }); 469 475 470 476 app.put('/api/documents/:id/tags', (req: Request<{ id: string }, unknown, { tags: string }>, res: Response) => { 471 477 const { tags } = req.body; 478 + if (tags != null && typeof tags !== 'string') { 479 + return res.status(400).json({ error: 'tags must be a string or null' }); 480 + } 481 + if (typeof tags === 'string' && tags.length > 10000) { 482 + return res.status(400).json({ error: 'tags too long' }); 483 + } 472 484 stmts.putTags.run(tags ?? null, req.params.id); 473 485 res.json({ ok: true }); 474 486 }); ··· 704 716 705 717 app.post('/api/blobs', express.raw({ limit: '10mb', type: '*/*' }), (req: Request<Record<string, string>, unknown, Buffer>, res: Response) => { 706 718 const docId = req.headers['x-document-id'] as string; 707 - const fileName = req.headers['x-file-name'] as string || 'file'; 708 - const mimeType = req.headers['x-mime-type'] as string || 'application/octet-stream'; 719 + const fileName = (req.headers['x-file-name'] as string || 'file').slice(0, 255); 720 + const rawMime = (req.headers['x-mime-type'] as string || 'application/octet-stream').slice(0, 255); 721 + const mimeType = /^[\w.+-]+\/[\w.+-]+$/.test(rawMime) ? rawMime : 'application/octet-stream'; 709 722 if (!docId) return res.status(400).json({ error: 'x-document-id header required' }); 710 723 const data = req.body; 711 724 if (!data || !data.length) return res.status(400).json({ error: 'No data' }); ··· 720 733 if (!row) return res.status(404).json({ error: 'Not found' }); 721 734 res.set('Content-Type', row.mime_type); 722 735 res.set('Content-Length', String(row.size)); 723 - res.set('Content-Disposition', `inline; filename="${row.file_name}"`); 736 + const safeName = row.file_name.replace(/["\\\r\n]/g, '_'); 737 + res.set('Content-Disposition', `inline; filename="${safeName}"`); 724 738 res.send(row.data); 725 739 }); 726 740
+1 -1
src/lib/key-sync.ts
··· 90 90 const serverChanged = Object.keys(merged).length !== Object.keys(server).length 91 91 || Object.keys(merged).some(k => server[k] !== merged[k]); 92 92 if (serverChanged) { 93 - pushKeysToServer(merged); // fire-and-forget 93 + pushKeysToServer(merged).catch(() => { /* key push failed — will retry on next sync */ }); 94 94 } 95 95 96 96 return merged;
+12 -3
src/slides/main.ts
··· 193 193 + (el.rotation ? `transform:rotate(${el.rotation}deg);` : ''); 194 194 195 195 if (el.type === 'text') { 196 - div.innerHTML = `<div class="slide-el-text" contenteditable="true" style="width:100%;height:100%;font-family:${theme?.fonts.body || 'system-ui'};color:${theme?.palette.text || '#1a1815'};padding:8px;outline:none;">${el.content || 'Text'}</div>`; 196 + const textDiv = document.createElement('div'); 197 + textDiv.className = 'slide-el-text'; 198 + textDiv.contentEditable = 'true'; 199 + textDiv.style.cssText = `width:100%;height:100%;font-family:${theme?.fonts.body || 'system-ui'};color:${theme?.palette.text || '#1a1815'};padding:8px;outline:none;`; 200 + textDiv.textContent = el.content || 'Text'; 201 + div.appendChild(textDiv); 197 202 } else if (el.type === 'shape') { 198 203 const fill = el.style?.fill || theme?.palette.primary || '#3a8a7a'; 199 204 div.innerHTML = renderShapeSVG(el.shapeType || 'rectangle', el.width, el.height, fill); 200 205 } else if (el.type === 'image') { 201 - div.innerHTML = `<img src="${el.content}" style="width:100%;height:100%;object-fit:contain;" alt="">`; 206 + const img = document.createElement('img'); 207 + img.src = el.content || ''; 208 + img.style.cssText = 'width:100%;height:100%;object-fit:contain;'; 209 + img.alt = ''; 210 + div.appendChild(img); 202 211 } 203 212 204 213 // Click to select ··· 220 229 const slide = currentSlide(deck); 221 230 const elIdx = slide.elements.findIndex(e => e.id === el.id); 222 231 if (elIdx >= 0) { 223 - slide.elements[elIdx] = { ...slide.elements[elIdx], content: (textEl as HTMLElement).innerHTML }; 232 + slide.elements[elIdx] = { ...slide.elements[elIdx], content: (textEl as HTMLElement).textContent || '' }; 224 233 syncDeckToYjs(); 225 234 } 226 235 });