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: XSS escaping, server v1 API, division-by-zero, provider reliability' (#234) from fix/audit-security-bugs into main

scott 1d86ed4c 614a6fe8

+57 -43
+2 -2
server/index.ts
··· 741 741 app.get('/api/v1/documents', (req: Request, res: Response) => { 742 742 const { type, limit: lim, offset: off } = req.query; 743 743 const validTypes = ['doc', 'sheet', 'form', 'slide', 'diagram']; 744 - let query = 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE trashed = 0'; 744 + let query = 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE deleted_at IS NULL'; 745 745 const params: unknown[] = []; 746 746 747 747 if (type && validTypes.includes(type as string)) { ··· 757 757 params.push(limit, offset); 758 758 759 759 const rows = db.prepare(query).all(...params); 760 - const total = (db.prepare('SELECT COUNT(*) as count FROM documents WHERE trashed = 0').get() as { count: number }).count; 760 + const total = (db.prepare('SELECT COUNT(*) as count FROM documents WHERE deleted_at IS NULL').get() as { count: number }).count; 761 761 res.json({ data: rows, total, limit, offset }); 762 762 }); 763 763
+12 -12
src/forms/main.ts
··· 145 145 if (isChoice) { 146 146 optionsHtml = '<div class="form-question-options">'; 147 147 for (const opt of q.options) { 148 - optionsHtml += `<div class="form-option-row"><input type="text" value="${opt.label}" data-option-id="${opt.id}" class="form-option-input" placeholder="Option label"><button class="form-option-remove" data-option-id="${opt.id}" title="Remove">✕</button></div>`; 148 + optionsHtml += `<div class="form-option-row"><input type="text" value="${escapeHtml(opt.label)}" data-option-id="${opt.id}" class="form-option-input" placeholder="Option label"><button class="form-option-remove" data-option-id="${opt.id}" title="Remove">✕</button></div>`; 149 149 } 150 150 optionsHtml += `<button class="form-add-option" data-question-id="${q.id}">+ Add option</button></div>`; 151 151 } ··· 160 160 <button class="form-question-move-down" title="Move down" ${i === form.questions.length - 1 ? 'disabled' : ''}>↓</button> 161 161 <button class="form-question-delete" title="Delete">✕</button> 162 162 </div> 163 - <input type="text" class="form-question-label" value="${q.label}" placeholder="Question text"> 164 - <input type="text" class="form-question-desc" value="${q.description}" placeholder="Description (optional)"> 163 + <input type="text" class="form-question-label" value="${escapeHtml(q.label)}" placeholder="Question text"> 164 + <input type="text" class="form-question-desc" value="${escapeHtml(q.description)}" placeholder="Description (optional)"> 165 165 ${optionsHtml} 166 166 `; 167 167 ··· 273 273 274 274 previewPane.innerHTML = ` 275 275 <div class="form-preview-container"> 276 - <h2>${form.title}</h2> 277 - ${form.description ? `<p class="form-preview-desc">${form.description}</p>` : ''} 276 + <h2>${escapeHtml(form.title)}</h2> 277 + ${form.description ? `<p class="form-preview-desc">${escapeHtml(form.description)}</p>` : ''} 278 278 <div class="form-preview-questions" id="preview-questions"></div> 279 279 <div class="form-preview-actions"> 280 280 <button class="btn-primary" id="preview-submit">Submit</button> ··· 307 307 inputHtml = `<input type="date" class="form-preview-input" data-qid="${q.id}">`; 308 308 break; 309 309 case 'single_choice': 310 - inputHtml = q.options.map(o => `<label class="form-preview-radio"><input type="radio" name="q-${q.id}" value="${o.id}" data-qid="${q.id}"> ${o.label}</label>`).join(''); 310 + inputHtml = q.options.map(o => `<label class="form-preview-radio"><input type="radio" name="q-${q.id}" value="${o.id}" data-qid="${q.id}"> ${escapeHtml(o.label)}</label>`).join(''); 311 311 break; 312 312 case 'multiple_choice': 313 - inputHtml = q.options.map(o => `<label class="form-preview-checkbox"><input type="checkbox" value="${o.id}" data-qid="${q.id}"> ${o.label}</label>`).join(''); 313 + inputHtml = q.options.map(o => `<label class="form-preview-checkbox"><input type="checkbox" value="${o.id}" data-qid="${q.id}"> ${escapeHtml(o.label)}</label>`).join(''); 314 314 break; 315 315 case 'dropdown': 316 - inputHtml = `<select class="form-preview-select" data-qid="${q.id}"><option value="">Select...</option>${q.options.map(o => `<option value="${o.id}">${o.label}</option>`).join('')}</select>`; 316 + inputHtml = `<select class="form-preview-select" data-qid="${q.id}"><option value="">Select...</option>${q.options.map(o => `<option value="${o.id}">${escapeHtml(o.label)}</option>`).join('')}</select>`; 317 317 break; 318 318 case 'rating': 319 319 inputHtml = `<div class="form-preview-rating" data-qid="${q.id}">${[1, 2, 3, 4, 5].map(n => `<button class="form-rating-star" data-value="${n}">★</button>`).join('')}</div>`; ··· 327 327 } 328 328 329 329 qEl.innerHTML = ` 330 - <label class="form-preview-label">${q.label}${q.required ? ' <span class="form-required-mark">*</span>' : ''}</label> 331 - ${q.description ? `<p class="form-preview-hint">${q.description}</p>` : ''} 330 + <label class="form-preview-label">${escapeHtml(q.label)}${q.required ? ' <span class="form-required-mark">*</span>' : ''}</label> 331 + ${q.description ? `<p class="form-preview-hint">${escapeHtml(q.description)}</p>` : ''} 332 332 ${inputHtml} 333 333 <div class="form-preview-error" data-error-qid="${q.id}"></div> 334 334 `; ··· 404 404 const rows = responses.map(r => responseToRow(r, config)); 405 405 406 406 let tableHtml = '<table class="pivot-table"><thead><tr>'; 407 - for (const h of headers) tableHtml += `<th>${h}</th>`; 407 + for (const h of headers) tableHtml += `<th>${escapeHtml(String(h))}</th>`; 408 408 tableHtml += '</tr></thead><tbody>'; 409 409 for (const row of rows) { 410 410 tableHtml += '<tr>'; 411 - for (const cell of row) tableHtml += `<td>${cell ?? ''}</td>`; 411 + for (const cell of row) tableHtml += `<td>${escapeHtml(String(cell ?? ''))}</td>`; 412 412 tableHtml += '</tr>'; 413 413 } 414 414 tableHtml += '</tbody></table>';
+5 -2
src/lib/provider.ts
··· 287 287 288 288 try { 289 289 const encrypted = await encrypt(plain, this.cryptoKey); 290 - this.ws.send(encrypted); 290 + if (this.ws && this.ws.readyState === WebSocket.OPEN) { 291 + this.ws.send(encrypted); 292 + } 291 293 } catch (err: unknown) { 292 294 console.error('Encryption failed', err); 293 295 } ··· 592 594 } 593 595 594 596 async destroy(): Promise<void> { 597 + if (this._destroyed) return; 595 598 this._destroyed = true; 596 599 this._resolveReady(); // Ensure whenReady resolves even if destroyed early 597 - clearTimeout(this._saveDebounce!); 600 + if (this._saveDebounce) clearTimeout(this._saveDebounce); 598 601 await this._saveSnapshot(); 599 602 this.disconnect(); 600 603 this.doc.off('update', this._onDocUpdate);
+15 -2
src/sheets/formulas.ts
··· 269 269 while (this.peek().type === TokenType.OPERATOR && (this.peek().value === '*' || this.peek().value === '/')) { 270 270 const op = this.advance().value; 271 271 const right = this.power(); 272 - left = op === '*' ? toNum(left) * toNum(right) : toNum(left) / toNum(right); 272 + if (op === '*') { 273 + left = toNum(left) * toNum(right); 274 + } else { 275 + const divisor = toNum(right); 276 + left = divisor === 0 ? '#DIV/0!' : toNum(left) / divisor; 277 + } 273 278 } 274 279 return left; 275 280 } ··· 800 805 const str = String(args[0]); 801 806 const old = String(args[1]); 802 807 const rep = String(args[2]); 803 - return args[3] != null ? str.replace(new RegExp(escapeRegex(old)), rep) : str.replaceAll(old, rep); 808 + if (args[3] != null) { 809 + const instance = toNum(args[3]); 810 + let count = 0; 811 + return str.replace(new RegExp(escapeRegex(old), 'g'), (match) => { 812 + count++; 813 + return count === instance ? rep : match; 814 + }); 815 + } 816 + return str.replaceAll(old, rep); 804 817 } 805 818 case 'FIND': 806 819 case 'SEARCH': {
+1 -1
src/slides/main.ts
··· 285 285 const slide = deck.slides[presenter.currentSlide]; 286 286 if (slide) { 287 287 presenterCurrent.style.background = slide.background; 288 - presenterCurrent.innerHTML = `<div style="padding:40px;font-size:24px;color:${getTheme(themedDeck.themeId)?.palette.text || '#1a1815'}">${slide.elements.filter(e => e.type === 'text').map(e => e.content).join('<br>') || ''}</div>`; 288 + presenterCurrent.innerHTML = `<div style="padding:40px;font-size:24px;color:${getTheme(themedDeck.themeId)?.palette.text || '#1a1815'}">${slide.elements.filter(e => e.type === 'text').map(e => escapeHtml(e.content)).join('<br>') || ''}</div>`; 289 289 } 290 290 // Next slide preview 291 291 const next = deck.slides[presenter.currentSlide + 1];
+5 -5
tests/formulas-edge-cases.test.ts
··· 49 49 expect(result).toBeNaN(); 50 50 }); 51 51 52 - it('1/0 inside SUM returns Infinity', () => { 52 + it('1/0 returns #DIV/0!', () => { 53 53 const cells = { A1: 10, B1: 0 }; 54 - // SUM(A1/B1) — A1/B1 = Infinity 55 54 const result = evalWith('A1/B1', cells); 56 - expect(result).toBe(Infinity); 55 + expect(result).toBe('#DIV/0!'); 57 56 }); 58 57 59 58 it('AVERAGE of range with zero count still returns 0', () => { ··· 67 66 expect(result).toBe('div/0'); 68 67 }); 69 68 70 - it('ROUND of Infinity returns Infinity', () => { 69 + it('ROUND of #DIV/0! returns 0', () => { 71 70 const result = evalWith('ROUND(1/0,2)'); 72 - expect(result).toBe(Infinity); 71 + // #DIV/0! is a string error, ROUND coerces to 0 72 + expect(result).toBe(0); 73 73 }); 74 74 }); 75 75
+11 -13
tests/formulas-security.test.ts
··· 161 161 // ===================================================================== 162 162 163 163 describe('Formula — division by zero error propagation', () => { 164 - it('SUM containing 1/0 propagates Infinity', () => { 164 + it('SUM containing 1/0 propagates #DIV/0! as string (coerced to 0)', () => { 165 165 const result = evalWith('SUM(1/0,2)'); 166 - // 1/0 = Infinity, SUM(Infinity, 2) = Infinity 167 - expect(result).toBe(Infinity); 166 + // 1/0 = '#DIV/0!', SUM coerces string to 0, so SUM(0, 2) = 2 167 + expect(result).toBe(2); 168 168 }); 169 169 170 - it('AVERAGE with division by zero produces Infinity', () => { 170 + it('AVERAGE with division by zero coerces error to 0', () => { 171 171 const cells = { A1: 1, B1: 0 }; 172 172 const result = evalWith('AVERAGE(A1/B1,10)', cells); 173 - // A1/B1 = Infinity, AVERAGE(Infinity, 10) = Infinity 174 - expect(result).toBe(Infinity); 173 + // A1/B1 = '#DIV/0!', coerced to 0 in AVERAGE → AVERAGE(0, 10) = 5 174 + expect(result).toBe(5); 175 175 }); 176 176 177 177 it('IF can guard against division by zero', () => { ··· 180 180 expect(result).toBe('N/A'); 181 181 }); 182 182 183 - it('nested 0/0 produces NaN', () => { 183 + it('0/0 produces #DIV/0!', () => { 184 184 const result = evalWith('0/0'); 185 - expect(result).toBeNaN(); 185 + expect(result).toBe('#DIV/0!'); 186 186 }); 187 187 188 - it('IFERROR catches division by zero', () => { 189 - // IFERROR(1/0, "safe") — 1/0 is Infinity, which is not an error in JS 190 - // But IFERROR might catch it depending on implementation 188 + it('IFERROR with division by zero returns the error string', () => { 191 189 const result = evalWith('IFERROR(1/0,"safe")'); 192 - // Either "safe" (if Infinity is treated as error) or Infinity 193 - expect(result === 'safe' || result === Infinity).toBe(true); 190 + // 1/0 = '#DIV/0!' string; IFERROR only catches thrown exceptions, not error strings 191 + expect(result).toBe('#DIV/0!'); 194 192 }); 195 193 }); 196 194
+6 -6
tests/formulas.test.ts
··· 282 282 }); 283 283 284 284 describe('division edge cases', () => { 285 - it('division by zero returns Infinity', () => { 285 + it('division by zero returns #DIV/0!', () => { 286 286 const result = evalWith('10/0'); 287 - expect(result).toBe(Infinity); 287 + expect(result).toBe('#DIV/0!'); 288 288 }); 289 289 290 - it('negative division by zero returns -Infinity', () => { 290 + it('negative division by zero returns #DIV/0!', () => { 291 291 const result = evalWith('-10/0'); 292 - expect(result).toBe(-Infinity); 292 + expect(result).toBe('#DIV/0!'); 293 293 }); 294 294 295 - it('zero divided by zero returns NaN', () => { 295 + it('zero divided by zero returns #DIV/0!', () => { 296 296 const result = evalWith('0/0'); 297 - expect(result).toBeNaN(); 297 + expect(result).toBe('#DIV/0!'); 298 298 }); 299 299 }); 300 300