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: SVG sanitization, AI proxy whitelist, snapshot race, metadata whitelist' (#247) from fix/batch6-svg-aiproxy-snapshot-metadata into main

scott df3e725c 299cf3a1

+33 -13
+26 -6
server/index.ts
··· 538 538 try { 539 539 const result = stmts.putSnapshot.run(req.body, req.params.id); 540 540 if (result.changes === 0) { 541 - // Document doesn't exist — auto-create it so saves never silently fail 542 - db.prepare("INSERT OR IGNORE INTO documents (id, type, name_encrypted) VALUES (?, 'doc', NULL)").run(req.params.id); 543 - stmts.putSnapshot.run(req.body, req.params.id); 541 + // Document doesn't exist — auto-create in a transaction to prevent races 542 + db.transaction(() => { 543 + db.prepare("INSERT OR IGNORE INTO documents (id, type, name_encrypted) VALUES (?, 'doc', NULL)").run(req.params.id); 544 + stmts.putSnapshot.run(req.body, req.params.id); 545 + })(); 544 546 } 545 547 res.json({ ok: true }); 546 548 } catch (err: unknown) { ··· 655 657 return; 656 658 } 657 659 658 - // Merge incoming body into existing metadata 660 + // Merge incoming body into existing metadata (whitelisted keys only) 661 + const allowedMetaKeys = ['label', 'description', 'starred', 'color', 'tags']; 659 662 let existing: Record<string, unknown> = {}; 660 663 if (version.metadata) { 661 664 try { 662 665 existing = JSON.parse(version.metadata) as Record<string, unknown>; 663 666 } catch { /* ignore parse errors */ } 664 667 } 665 - const merged = { ...existing, ...req.body }; 668 + const incoming: Record<string, unknown> = {}; 669 + if (req.body && typeof req.body === 'object') { 670 + for (const key of allowedMetaKeys) { 671 + if (key in req.body) incoming[key] = req.body[key]; 672 + } 673 + } 674 + const merged = { ...existing, ...incoming }; 666 675 667 676 db.prepare('UPDATE versions SET metadata = ? WHERE id = ? AND document_id = ?') 668 677 .run(JSON.stringify(merged), versionId, docId); ··· 676 685 app.post('/api/ai/chat/completions', async (req: Request, res: Response) => { 677 686 const gatewayUrl = `${AI_GATEWAY_URL.replace(/\/$/, '')}/v1/chat/completions`; 678 687 try { 688 + // Whitelist allowed fields to prevent arbitrary payload forwarding 689 + const allowedKeys = ['model', 'messages', 'temperature', 'max_tokens', 'stream', 'top_p', 'stop', 'presence_penalty', 'frequency_penalty']; 690 + const sanitized: Record<string, unknown> = {}; 691 + for (const key of allowedKeys) { 692 + if (req.body && key in req.body) sanitized[key] = req.body[key]; 693 + } 694 + if (!sanitized.messages || !Array.isArray(sanitized.messages)) { 695 + res.status(400).json({ error: 'messages array is required' }); 696 + return; 697 + } 698 + 679 699 const upstream = await fetch(gatewayUrl, { 680 700 method: 'POST', 681 701 headers: { 'Content-Type': 'application/json' }, 682 - body: JSON.stringify(req.body), 702 + body: JSON.stringify(sanitized), 683 703 }); 684 704 685 705 if (!upstream.ok) {
+7 -7
src/diagrams/export.ts
··· 110 110 // --------------------------------------------------------------------------- 111 111 112 112 function renderShapeSvg(shape: Shape): string { 113 - const fill = shape.style.fill ?? DEFAULT_FILL; 114 - const stroke = shape.style.stroke ?? DEFAULT_STROKE; 113 + const fill = escapeXml(shape.style.fill ?? DEFAULT_FILL); 114 + const stroke = escapeXml(shape.style.stroke ?? DEFAULT_STROKE); 115 115 const strokeWidth = shape.style.strokeWidth ?? DEFAULT_STROKE_WIDTH; 116 - const strokeDasharray = shape.style.strokeDasharray || ''; 116 + const strokeDasharray = escapeXml(shape.style.strokeDasharray || ''); 117 117 const opacity = shape.opacity !== undefined && shape.opacity !== 1 ? shape.opacity : null; 118 118 119 119 const cx = shape.x + shape.width / 2; ··· 150 150 151 151 case 'text': 152 152 // Text shapes render as a <text> element positioned at the center 153 - element = `<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-family="${shape.fontFamily || DEFAULT_FONT_FAMILY}" font-size="${shape.fontSize || DEFAULT_FONT_SIZE}" fill="${stroke}"${rotation}>${escapeXml(shape.label)}</text>`; 153 + element = `<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-family="${escapeXml(shape.fontFamily || DEFAULT_FONT_FAMILY)}" font-size="${shape.fontSize || DEFAULT_FONT_SIZE}" fill="${stroke}"${rotation}>${escapeXml(shape.label)}</text>`; 154 154 break; 155 155 156 156 case 'triangle': { ··· 262 262 const to = resolveEndpoint(arrow.to, shapes); 263 263 if (!from || !to) return ''; 264 264 265 - const stroke = arrow.style.stroke ?? DEFAULT_STROKE; 265 + const stroke = escapeXml(arrow.style.stroke ?? DEFAULT_STROKE); 266 266 const strokeWidth = arrow.style.strokeWidth ?? DEFAULT_STROKE_WIDTH; 267 267 268 - const markerId = arrowMarkerIdForColor(stroke); 268 + const markerId = arrowMarkerIdForColor(arrow.style.stroke ?? DEFAULT_STROKE); 269 269 let svg = `<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" stroke="${stroke}" stroke-width="${strokeWidth}" marker-end="url(#${markerId})"/>`; 270 270 271 271 if (arrow.label) { ··· 291 291 const markers = [...colors].map(color => { 292 292 const id = arrowMarkerIdForColor(color); 293 293 return ` <marker id="${id}" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto" markerUnits="strokeWidth"> 294 - <polygon points="0 0, 10 3.5, 0 7" fill="${color}"/> 294 + <polygon points="0 0, 10 3.5, 0 7" fill="${escapeXml(color)}"/> 295 295 </marker>`; 296 296 }).join('\n'); 297 297 return `<defs>\n${markers}\n</defs>`;