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(security): XSS, CSS injection, escaping, cache limits, stale positions (0.42.1)' (#378) from fix/qa-review-round into main

scott 8a2f8774 9ac50d26

+68 -48
+18
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.42.1] — 2026-04-14 11 + 12 + ### Security 13 + - fix: XSS in chart dialog — escape all user-derived values in HTML attribute contexts (#632) 14 + - fix: CSS injection via `barColor`/`bgColor`/`textColor` — validate hex color format before rendering in styles (#633) 15 + - fix: KaTeX CSS loaded from bundled Vite asset instead of CDN (eliminates supply-chain risk) (#635) 16 + 17 + ### Fixed 18 + - fix: `escapeAttr` replacement ordering in footnote-footer (was double-escaping `&`) (#634) 19 + - fix: `escapeAttr` in presence-sidebar now escapes `&` (was missing) (#634) 20 + - fix: suggestions panel re-extracts positions before accept/reject to handle collaborative edits (#637) 21 + - fix: content search cache capped at 50 entries to prevent unbounded memory growth (#637) 22 + - fix: math-block debounce timer cleared when closing editor (#629) 23 + - fix: removed dead `fromIdx` variable in slide thumbnail dragover handler (#620) 24 + 25 + ### Changed 26 + - perf: deduplicated cell iteration in grid CF rendering — single pass for color scale + data bars + icon sets (#636) 27 + 10 28 ## [0.42.0] — 2026-04-14 11 29 12 30 ### Added
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.42.0", 3 + "version": "0.42.1", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+5 -3
src/docs/extensions/math-block.ts
··· 31 31 async function ensureKaTeX(): Promise<typeof import('katex')> { 32 32 if (katexModule) return katexModule; 33 33 katexModule = await import('katex'); 34 - // Load KaTeX CSS if not already loaded 34 + // Load KaTeX CSS from bundled asset (not CDN) to avoid SRI/supply-chain risk 35 35 if (!document.querySelector('link[href*="katex"]')) { 36 + // @ts-ignore — Vite ?url suffix resolves to bundled asset path 37 + const { default: cssUrl } = await import('katex/dist/katex.min.css?url'); 36 38 const link = document.createElement('link'); 37 39 link.rel = 'stylesheet'; 38 - link.href = 'https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css'; 39 - link.crossOrigin = 'anonymous'; 40 + link.href = cssUrl as string; 40 41 document.head.appendChild(link); 41 42 } 42 43 return katexModule; ··· 156 157 if (editing) { 157 158 // Save and close 158 159 editing = false; 160 + if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } 159 161 codeEditor.style.display = 'none'; 160 162 editBtn.textContent = 'Edit'; 161 163 const pos = typeof getPos === 'function' ? getPos() : null;
+1 -1
src/docs/footnote-footer.ts
··· 87 87 } 88 88 89 89 function escapeAttr(s: string): string { 90 - return s.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/&/g, '&amp;'); 90 + return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); 91 91 }
+3 -1
src/docs/suggestions-panel.ts
··· 174 174 btn.addEventListener('click', () => { 175 175 const sid = (btn as HTMLElement).dataset.sid; 176 176 const action = (btn as HTMLElement).dataset.action; 177 - const s = suggestions.find(s => s.id === sid); 177 + // Re-extract from editor to get fresh positions (collaborative edits may have shifted them) 178 + const freshSuggestions = extractSuggestions(editor); 179 + const s = freshSuggestions.find(s => s.id === sid); 178 180 if (!s) return; 179 181 if (action === 'accept') acceptSuggestion(editor, s); 180 182 else rejectSuggestion(editor, s);
+6
src/landing-content-search.ts
··· 19 19 matchCount: number; 20 20 } 21 21 22 + const MAX_CACHE_SIZE = 50; 22 23 let searchCache = new Map<string, string>(); // docId → plaintext 23 24 let lastFetchTime = 0; 24 25 ··· 57 58 // Extract plain text from Yjs update (best-effort: strip HTML-like content) 58 59 const plain = stripHtml(text); 59 60 searchCache.set(doc.id, plain); 61 + // Evict oldest entries if cache exceeds limit 62 + if (searchCache.size > MAX_CACHE_SIZE) { 63 + const firstKey = searchCache.keys().next().value; 64 + if (firstKey !== undefined) searchCache.delete(firstKey); 65 + } 60 66 } catch { 61 67 // Skip docs that fail to decrypt 62 68 }
+1 -1
src/lib/presence-sidebar.ts
··· 120 120 } 121 121 122 122 function escapeAttr(s: string): string { 123 - return s.replace(/"/g, '&quot;').replace(/'/g, '&#39;'); 123 + return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); 124 124 }
+10 -4
src/sheets/charts-ui.ts
··· 39 39 return ChartJS; 40 40 } 41 41 42 + // ── HTML Escaping ────────────────────────────────────────── 43 + 44 + function escapeAttr(s: string): string { 45 + return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 46 + } 47 + 42 48 // ── Chart Type Labels ────────────────────────────────────── 43 49 44 50 const CHART_TYPE_LABELS: Record<string, string> = { ··· 85 91 ${CHART_TYPES.map(t => `<option value="${t}" ${t === cfg.type ? 'selected' : ''}>${chartTypeLabel(t)}</option>`).join('')} 86 92 </select> 87 93 <label>Data Range (e.g. A1:D10)</label> 88 - <input id="chart-range" value="${cfg.range}" placeholder="A1:D10"> 94 + <input id="chart-range" value="${escapeAttr(cfg.range)}" placeholder="A1:D10"> 89 95 <label>Title</label> 90 - <input id="chart-title" value="${cfg.title || ''}" placeholder="Chart title"> 96 + <input id="chart-title" value="${escapeAttr(cfg.title || '')}" placeholder="Chart title"> 91 97 <label>X Axis Label</label> 92 - <input id="chart-x-label" value="${cfg.xAxisLabel || ''}"> 98 + <input id="chart-x-label" value="${escapeAttr(cfg.xAxisLabel || '')}"> 93 99 <label>Y Axis Label</label> 94 - <input id="chart-y-label" value="${cfg.yAxisLabel || ''}"> 100 + <input id="chart-y-label" value="${escapeAttr(cfg.yAxisLabel || '')}"> 95 101 <div class="sheet-dialog-actions"> 96 102 <button id="chart-cancel">Cancel</button> 97 103 <button id="chart-ok" class="btn-primary">${isEdit ? 'Update' : 'Insert'}</button>
+14 -3
src/sheets/conditional-format.ts
··· 97 97 export function buildCfStyle(cfResult: CfStyleResult | null): string { 98 98 if (!cfResult) return ''; 99 99 let style = ''; 100 - if (cfResult.bgColor) style += 'background:' + cfResult.bgColor + ';'; 101 - if (cfResult.textColor) style += 'color:' + cfResult.textColor + ';'; 100 + if (cfResult.bgColor && isValidHexColor(cfResult.bgColor)) style += 'background:' + cfResult.bgColor + ';'; 101 + if (cfResult.textColor && isValidHexColor(cfResult.textColor)) style += 'color:' + cfResult.textColor + ';'; 102 102 return style; 103 103 } 104 104 ··· 234 234 235 235 const DEFAULT_BAR_COLOR = '#4472c4'; 236 236 237 + /** Validate a hex color to prevent CSS injection. */ 238 + function isValidHexColor(c: string): boolean { 239 + return /^#[0-9a-fA-F]{3,8}$/.test(c); 240 + } 241 + 242 + /** Sanitize a color value — returns default if invalid. */ 243 + function sanitizeColor(c: string | undefined, fallback: string): string { 244 + if (!c) return fallback; 245 + return isValidHexColor(c) ? c : fallback; 246 + } 247 + 237 248 /** 238 249 * Compute data bar widths for a range of cells. 239 250 * Each bar's width is proportional to the cell's value relative to the range. ··· 253 264 254 265 if (nums.length === 0) return result; 255 266 256 - const barColor = rule.barColor || DEFAULT_BAR_COLOR; 267 + const barColor = sanitizeColor(rule.barColor, DEFAULT_BAR_COLOR); 257 268 258 269 let min = nums[0]!.val; 259 270 let max = nums[0]!.val;
+9 -33
src/sheets/grid-rendering.ts
··· 157 157 const cfRules = getCfRulesArray(); 158 158 const stripedEnabled = getStripedRows(); 159 159 160 + // Collect cell values once for all range-based CF rules (color scale, data bars, icon sets) 160 161 const colorScaleRule = cfRules.find(r => r.type === 'colorScale'); 161 - let colorScaleStyles: Map<string, { bgColor?: string; textColor?: string }> | null = null; 162 - if (colorScaleRule) { 163 - const allCellValues = new Map<string, unknown>(); 164 - for (let r = 1; r <= rowCount; r++) { 165 - for (let c = 1; c <= colCount; c++) { 166 - const id = cellId(c, r); 167 - const cd = getCellData(id); 168 - if (cd) { 169 - const dv = computeDisplayValue(id, cd); 170 - allCellValues.set(id, dv); 171 - } 172 - } 173 - } 174 - colorScaleStyles = computeColorScale(allCellValues, colorScaleRule); 175 - } 176 - 177 - // Data bars 178 162 const dataBarRule = cfRules.find(r => r.type === 'dataBar'); 179 - let dataBarStyles: Map<string, DataBarResult> | null = null; 180 - if (dataBarRule) { 181 - const allCellValues = new Map<string, unknown>(); 182 - for (let r = 1; r <= rowCount; r++) { 183 - for (let c = 1; c <= colCount; c++) { 184 - const id = cellId(c, r); 185 - const cd = getCellData(id); 186 - if (cd) allCellValues.set(id, computeDisplayValue(id, cd)); 187 - } 188 - } 189 - dataBarStyles = computeDataBars(allCellValues, dataBarRule); 190 - } 163 + const iconSetRule = cfRules.find(r => r.type === 'iconSet'); 191 164 192 - // Icon sets 193 - const iconSetRule = cfRules.find(r => r.type === 'iconSet'); 165 + let colorScaleStyles: Map<string, { bgColor?: string; textColor?: string }> | null = null; 166 + let dataBarStyles: Map<string, DataBarResult> | null = null; 194 167 let iconSetStyles: Map<string, IconSetResult> | null = null; 195 - if (iconSetRule) { 168 + 169 + if (colorScaleRule || dataBarRule || iconSetRule) { 196 170 const allCellValues = new Map<string, unknown>(); 197 171 for (let r = 1; r <= rowCount; r++) { 198 172 for (let c = 1; c <= colCount; c++) { ··· 201 175 if (cd) allCellValues.set(id, computeDisplayValue(id, cd)); 202 176 } 203 177 } 204 - iconSetStyles = computeIconSets(allCellValues, iconSetRule); 178 + if (colorScaleRule) colorScaleStyles = computeColorScale(allCellValues, colorScaleRule); 179 + if (dataBarRule) dataBarStyles = computeDataBars(allCellValues, dataBarRule); 180 + if (iconSetRule) iconSetStyles = computeIconSets(allCellValues, iconSetRule); 205 181 } 206 182 207 183 const allRowsToRender: number[] = [];
-1
src/slides/rendering.ts
··· 82 82 thumb.addEventListener('dragover', (e) => { 83 83 e.preventDefault(); 84 84 e.dataTransfer!.dropEffect = 'move'; 85 - const fromIdx = parseInt(e.dataTransfer!.getData('text/plain') || '-1'); 86 85 refs.thumbnailList.querySelectorAll('.slides-thumbnail').forEach(t => { 87 86 (t as HTMLElement).classList.remove('drag-over-top', 'drag-over-bottom'); 88 87 });