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

Configure Feed

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

fix(security): SSRF + auth bypass + XSS + parser limits (#612)

Server-side (critical/high):
- ICS proxy: block Tailscale CGNAT 100.64.0.0/10, full 127.0.0.0/8,
IPv4-mapped ::ffff:, add redirect:'manual' to block redirect SSRF
- ICS proxy: cap response body at 5MB, cache at 100 entries
- DELETE /api/push/subscribe: require auth, scope to authenticated user
- POST /api/push/schedule: reject arrays >500 reminders

Client-side (medium/low):
- Mermaid: sanitize rendered SVG with DOMPurify (defense-in-depth)
- Mermaid: persist rendered SVG as data-rendered-svg for HTML/PDF export
- SVG import: preserve fill="none"/stroke="none"; add points to line shapes
- SVG import: depth limit 100, element cap 5000
- PPTX import: slide cap 200, group recursion depth 20
- PDF import: page cap 500

All 8381/8381 tests passing.

+203 -49
+22
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.37.1] — 2026-04-14 11 + 12 + ### Security 13 + - ICS proxy: block Tailscale CGNAT range `100.64.0.0/10` — previously could be used to reach any Tailscale node on the tailnet (#612) 14 + - ICS proxy: block full `127.0.0.0/8` loopback range (was only blocking `127.0.0.1`) (#612) 15 + - ICS proxy: set `redirect: 'manual'` and explicitly reject `3xx` responses, preventing redirect-based SSRF bypass (#612) 16 + - ICS proxy: cap response body at 5 MB and limit cache to 100 entries to prevent memory exhaustion (#612) 17 + - ICS proxy: block IPv6 `::ffff:` (IPv4-mapped) range (#612) 18 + - Push subscriptions: `DELETE /api/push/subscribe` now requires authentication and scopes deletion to the authenticated user (#612) 19 + - Push reminders: reject payloads with more than 500 reminders (#612) 20 + 21 + ### Fixed 22 + - Mermaid blocks: SVG output now sanitized with DOMPurify (`svg` profile) as defense-in-depth against Mermaid library XSS bypasses (#612) 23 + - Mermaid blocks: last rendered SVG stored as `data-rendered-svg` attribute so HTML and PDF exports include the diagram (#612) 24 + - SVG import: `fill="none"` and `stroke="none"` now preserved correctly (previously overwritten with white/black defaults) (#612) 25 + - SVG import: `<line>` elements now produce shapes with `points` array for the whiteboard renderer (previously invisible on canvas) (#612) 26 + - SVG import: recursion limited to 100 levels of nested `<g>` groups to prevent stack overflow (#612) 27 + - SVG import: total shapes capped at 5 000 to prevent browser freeze on very large SVGs (#612) 28 + - PPTX import: slide count capped at 200; group recursion limited to 20 levels (#612) 29 + - PDF import: page extraction capped at 500 pages (#612) 30 + 10 31 ## [0.37.0] — 2026-04-14 11 32 12 33 ### Added 34 + - feat: SVG import to diagrams editor (#611) 13 35 - Docs: Mermaid diagram blocks — type `/diagram` or `/mermaid` in the editor to insert an inline Mermaid diagram (flowcharts, sequence diagrams, ER, Gantt, class diagrams, etc.). Code editor panel reveals on click; SVG renders with 400ms debounce. Copy SVG button included. Mermaid is dynamically imported to keep initial bundle size unchanged. (#610) 14 36 - Diagrams: SVG file import — "Import SVG" button in the diagrams toolbar opens a file picker. Supported elements: `rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`, `text`, and nested `<g>` groups. Coordinates are scaled from the SVG viewBox to the 960×540 canvas. Unsupported elements (path, image) are silently skipped with a count logged to console. (#611) 15 37
+16 -4
package-lock.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.36.1", 3 + "version": "0.37.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "tools", 9 - "version": "0.36.1", 9 + "version": "0.37.0", 10 10 "dependencies": { 11 11 "@tiptap/core": "^2.11.0", 12 12 "@tiptap/extension-code-block-lowlight": "^2.27.2", ··· 35 35 "better-sqlite3": "^12.8.0", 36 36 "chart.js": "^4.5.1", 37 37 "compression": "^1.7.5", 38 + "dompurify": "^3.3.3", 38 39 "exceljs": "^4.4.0", 39 40 "express": "^4.21.0", 40 41 "html2pdf.js": "^0.14.0", ··· 55 56 "@playwright/test": "^1.58.2", 56 57 "@types/better-sqlite3": "^7.6.13", 57 58 "@types/compression": "^1.8.1", 59 + "@types/dompurify": "^3.0.5", 58 60 "@types/express": "^5.0.6", 59 61 "@types/node": "^25.5.0", 60 62 "@types/web-push": "^3.6.4", ··· 3332 3334 "dev": true, 3333 3335 "license": "MIT" 3334 3336 }, 3337 + "node_modules/@types/dompurify": { 3338 + "version": "3.0.5", 3339 + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", 3340 + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", 3341 + "dev": true, 3342 + "license": "MIT", 3343 + "dependencies": { 3344 + "@types/trusted-types": "*" 3345 + } 3346 + }, 3335 3347 "node_modules/@types/estree": { 3336 3348 "version": "1.0.8", 3337 3349 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", ··· 3537 3549 "version": "2.0.7", 3538 3550 "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", 3539 3551 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", 3540 - "license": "MIT", 3541 - "optional": true 3552 + "devOptional": true, 3553 + "license": "MIT" 3542 3554 }, 3543 3555 "node_modules/@types/unist": { 3544 3556 "version": "3.0.3",
+3 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.37.0", 3 + "version": "0.37.1", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js", ··· 44 44 "better-sqlite3": "^12.8.0", 45 45 "chart.js": "^4.5.1", 46 46 "compression": "^1.7.5", 47 + "dompurify": "^3.3.3", 47 48 "exceljs": "^4.4.0", 48 49 "express": "^4.21.0", 49 50 "html2pdf.js": "^0.14.0", ··· 64 65 "@playwright/test": "^1.58.2", 65 66 "@types/better-sqlite3": "^7.6.13", 66 67 "@types/compression": "^1.8.1", 68 + "@types/dompurify": "^3.0.5", 67 69 "@types/express": "^5.0.6", 68 70 "@types/node": "^25.5.0", 69 71 "@types/web-push": "^3.6.4",
+49 -15
server/routes/notifications.ts
··· 119 119 }); 120 120 121 121 router.delete('/api/push/subscribe', (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 122 + if (!req.tsUser) { 123 + res.status(403).json({ error: 'Authentication required' }); 124 + return; 125 + } 126 + 122 127 const { endpoint } = req.body as { endpoint?: string }; 123 128 124 129 if (!endpoint || typeof endpoint !== 'string') { ··· 126 131 return; 127 132 } 128 133 129 - stmts.deleteSubscription.run(endpoint); 134 + // Scope deletion to the authenticated user — prevent cross-user unsubscription 135 + db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ? AND user_login = ?') 136 + .run(endpoint, req.tsUser.login); 130 137 res.status(204).send(); 131 138 }); 132 139 ··· 142 149 143 150 if (!Array.isArray(reminders)) { 144 151 res.status(400).json({ error: 'reminders must be an array' }); 152 + return; 153 + } 154 + 155 + if (reminders.length > 500) { 156 + res.status(400).json({ error: 'Too many reminders (max 500)' }); 145 157 return; 146 158 } 147 159 ··· 197 209 198 210 const hostname = parsed.hostname.toLowerCase(); 199 211 200 - // Block localhost variants 201 - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]') { 202 - return false; 203 - } 212 + // Block localhost and all loopback variants (127.0.0.0/8, not just 127.0.0.1) 213 + if (hostname === 'localhost') return false; 204 214 205 - // Block private IPv4 ranges 215 + // Block private and reserved IPv4 ranges 206 216 const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); 207 217 if (ipv4Match) { 208 218 const [, a, b] = ipv4Match.map(Number); 209 - if (a === 10) return false; // 10.0.0.0/8 210 - if (a === 172 && b !== undefined && b >= 16 && b <= 31) return false; // 172.16.0.0/12 211 - if (a === 192 && b === 168) return false; // 192.168.0.0/16 212 - if (a === 169 && b === 254) return false; // 169.254.0.0/16 (link-local) 213 - if (a === 0) return false; // 0.0.0.0/8 219 + if (a === 10) return false; // 10.0.0.0/8 220 + if (a === 127) return false; // 127.0.0.0/8 (full loopback, not just .1) 221 + if (a === 172 && b !== undefined && b >= 16 && b <= 31) return false; // 172.16.0.0/12 222 + if (a === 192 && b === 168) return false; // 192.168.0.0/16 223 + if (a === 169 && b === 254) return false; // 169.254.0.0/16 (link-local) 224 + if (a === 100 && b !== undefined && b >= 64 && b <= 127) return false; // 100.64.0.0/10 (Tailscale CGNAT) 225 + if (a === 0) return false; // 0.0.0.0/8 226 + if (a === 240) return false; // 240.0.0.0/4 (reserved) 227 + if (a === 255) return false; // 255.255.255.255 214 228 } 215 229 216 - // Block IPv6 private/link-local (common bracket notation in URLs) 230 + // Block IPv6 private/link-local/loopback 217 231 if (hostname.startsWith('[')) { 218 232 const inner = hostname.slice(1, -1).toLowerCase(); 219 - if (inner.startsWith('fe80:') || inner.startsWith('fc') || inner.startsWith('fd') || inner === '::1') { 233 + if ( 234 + inner === '::1' || // loopback 235 + inner.startsWith('fe80:') || // link-local 236 + inner.startsWith('fc') || // ULA fc00::/7 237 + inner.startsWith('fd') || // ULA fd00::/8 238 + inner.startsWith('::ffff:') // IPv4-mapped (could map to blocked IPv4) 239 + ) { 220 240 return false; 221 241 } 222 242 } ··· 254 274 255 275 const response = await fetch(url, { 256 276 signal: controller.signal, 277 + redirect: 'manual', // Never follow redirects — could bypass URL validation 257 278 headers: { 'Accept': 'text/calendar, text/plain' }, 258 279 }); 259 280 clearTimeout(timeout); 260 281 282 + // Reject redirects — an attacker can redirect to a blocked internal URL 283 + if (response.status >= 300 && response.status < 400) { 284 + res.status(400).json({ error: 'Redirects are not permitted for ICS feeds' }); 285 + return; 286 + } 287 + 261 288 if (!response.ok) { 262 289 res.status(502).json({ error: `Upstream returned ${response.status}` }); 263 290 return; 264 291 } 265 292 266 - const body = await response.text(); 293 + // Cap body size at 5 MB to prevent memory exhaustion 294 + const MAX_ICS_BYTES = 5 * 1024 * 1024; 295 + const bodyText = await response.text(); 296 + const body = bodyText.length > MAX_ICS_BYTES ? bodyText.slice(0, MAX_ICS_BYTES) : bodyText; 267 297 268 - // Cache the response 298 + // Cache the response (max 100 entries, evict oldest on overflow) 269 299 icsCache.set(url, { body, fetchedAt: Date.now() }); 300 + if (icsCache.size > 100) { 301 + const oldestKey = icsCache.keys().next().value; 302 + if (oldestKey !== undefined) icsCache.delete(oldestKey); 303 + } 270 304 271 305 // Evict stale cache entries 272 306 const now = Date.now();
+26 -9
src/diagrams/svg-import.ts
··· 41 41 // --------------------------------------------------------------------------- 42 42 43 43 function cssColorToHex(color: string): string { 44 - if (!color || color === 'none' || color === 'transparent') return ''; 44 + if (!color) return ''; 45 + // Preserve explicit 'none' and 'transparent' — don't let makeShape overwrite with defaults 46 + if (color === 'none' || color === 'transparent') return 'none'; 45 47 if (color.startsWith('#')) return color.toLowerCase(); 46 48 // Named colours → rough hex (common ones only) 47 49 const named: Record<string, string> = { ··· 172 174 styleProps: { fill: string; stroke: string; strokeWidth: number }, 173 175 label: string, 174 176 ): Shape { 177 + // Respect explicit 'none' — don't overwrite with defaults 178 + const fill = styleProps.fill === 'none' ? 'none' : (styleProps.fill || '#ffffff'); 179 + const stroke = styleProps.stroke === 'none' ? 'none' : (styleProps.stroke || '#000000'); 175 180 return { 176 181 id: nextId(), 177 182 kind, ··· 183 188 rotation: 0, 184 189 opacity: 1, 185 190 style: { 186 - fill: styleProps.fill || '#ffffff', 187 - stroke: styleProps.stroke || '#000000', 191 + fill, 192 + stroke, 188 193 strokeWidth: String(styleProps.strokeWidth), 189 194 }, 190 195 }; ··· 213 218 return makeShape('ellipse', { x: sc.x(cx - rx), y: sc.y(cy - ry), w: sc.w(rx * 2), h: sc.h(ry * 2) }, readStyle(el), ''); 214 219 } 215 220 216 - function convertLine(el: Element, sc: Scalers, tfm: SimpleTransform, z: number): Shape | null { 221 + function convertLine(el: Element, sc: Scalers, tfm: SimpleTransform, _z: number): Shape | null { 217 222 const { x: x1, y: y1 } = applyTransform(tfm, n(el, 'x1'), n(el, 'y1')); 218 223 const { x: x2, y: y2 } = applyTransform(tfm, n(el, 'x2'), n(el, 'y2')); 219 224 const px1 = sc.x(x1), py1 = sc.y(y1), px2 = sc.x(x2), py2 = sc.y(y2); 220 225 const bx = Math.min(px1, px2), by = Math.min(py1, py2); 221 226 const bw = Math.abs(px2 - px1), bh = Math.abs(py2 - py1); 222 - const style = readStyle(el); 223 227 if (bw < 2 && bh < 2) return null; 224 - return makeShape('line', { x: bx, y: by, w: Math.max(bw, 2), h: Math.max(bh, 2) }, style, ''); 228 + const shape = makeShape('line', { x: bx, y: by, w: Math.max(bw, 2), h: Math.max(bh, 2) }, readStyle(el), ''); 229 + // The whiteboard renderer draws lines from shape.points, not the bounding box. 230 + shape.points = [{ x: px1, y: py1 }, { x: px2, y: py2 }]; 231 + return shape; 225 232 } 226 233 227 234 function convertText(el: Element, sc: Scalers, tfm: SimpleTransform, z: number): Shape | null { ··· 244 251 // Group element — recurse with composed transform 245 252 // --------------------------------------------------------------------------- 246 253 247 - function extractFromElement(el: Element, sc: Scalers, tfm: SimpleTransform, zBase: number): Shape[] { 254 + const MAX_SHAPES = 5000; 255 + const MAX_DEPTH = 100; 256 + 257 + function extractFromElement(el: Element, sc: Scalers, tfm: SimpleTransform, zBase: number, depth = 0): Shape[] { 258 + if (depth > MAX_DEPTH) return []; 259 + 248 260 const shapes: Shape[] = []; 249 261 let z = zBase; 250 262 ··· 253 265 if (tagName === 'g') { 254 266 const childTfm = composeTransform(tfm, parseTransform(el.getAttribute('transform') ?? '')); 255 267 for (const child of Array.from(el.children)) { 256 - const childShapes = extractFromElement(child, sc, childTfm, z); 268 + const childShapes = extractFromElement(child, sc, childTfm, z, depth + 1); 257 269 shapes.push(...childShapes); 258 270 z += childShapes.length; 259 271 } ··· 328 340 const rootTfm = parseTransform(svgEl.getAttribute('transform') ?? ''); 329 341 330 342 for (const child of Array.from(svgEl.children)) { 343 + if (shapes.length >= MAX_SHAPES) break; 331 344 const tag = child.tagName.toLowerCase().replace(/^svg:/, ''); 332 345 if (tag === 'defs' || tag === 'style' || tag === 'title' || tag === 'desc') continue; 333 - const extracted = extractFromElement(child, sc, rootTfm, shapes.length); 346 + const extracted = extractFromElement(child, sc, rootTfm, shapes.length, 0); 334 347 shapes.push(...extracted); 348 + if (shapes.length >= MAX_SHAPES) { 349 + shapes.splice(MAX_SHAPES); // trim to exact limit 350 + break; 351 + } 335 352 } 336 353 337 354 // Offset imported shapes so they don't land exactly on existing content
+37 -7
src/docs/extensions/mermaid-block.ts
··· 17 17 18 18 import { Node, mergeAttributes } from '@tiptap/core'; 19 19 import type { Editor } from '@tiptap/core'; 20 + import DOMPurify from 'dompurify'; 20 21 21 22 declare module '@tiptap/core' { 22 23 interface Commands<ReturnType> { ··· 58 59 return _mermaidLib; 59 60 } 60 61 61 - /** Render Mermaid source to SVG string. Returns SVG or throws on syntax error. */ 62 + /** Render Mermaid source to a sanitized SVG string. Returns SVG or throws on syntax error. */ 62 63 async function renderToSvg(code: string): Promise<string> { 63 64 const mermaid = await getMermaid(); 64 65 const id = nextId(); 65 - // mermaid.render creates a temporary element, renders, returns svg string. 66 66 const { svg } = await mermaid.render(id, code); 67 - // Clean up any leftover element mermaid may have inserted 68 67 document.getElementById(id)?.remove(); 69 - return svg; 68 + // Sanitize SVG through DOMPurify as defense-in-depth against Mermaid library XSS bypasses. 69 + return DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }); 70 70 } 71 71 72 72 function escHtml(s: string): string { ··· 91 91 parseHTML: (el: HTMLElement) => el.getAttribute('data-code') ?? DEFAULT_CODE, 92 92 renderHTML: (attrs: Record<string, string>) => ({ 'data-code': attrs['code'] ?? DEFAULT_CODE }), 93 93 }, 94 + // Stores the last rendered SVG so HTML/PDF exports include the diagram visually. 95 + // Updated by the NodeView after each successful render. 96 + renderedSvg: { 97 + default: '', 98 + parseHTML: (el: HTMLElement) => el.getAttribute('data-rendered-svg') ?? '', 99 + renderHTML: (attrs: Record<string, string>) => ( 100 + attrs['renderedSvg'] ? { 'data-rendered-svg': attrs['renderedSvg'] } : {} 101 + ), 102 + }, 94 103 }; 95 104 }, 96 105 ··· 99 108 }, 100 109 101 110 renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, string> }) { 102 - return ['div', mergeAttributes({ 'data-type': 'mermaid-diagram' }, HTMLAttributes)]; 111 + // For HTML/PDF exports (editor.getHTML()), embed the last rendered SVG so the 112 + // diagram is visible even without the live NodeView. 113 + const svgContent = HTMLAttributes['data-rendered-svg'] ?? ''; 114 + return [ 115 + 'div', 116 + mergeAttributes({ 'data-type': 'mermaid-diagram', style: 'margin:1em 0' }, HTMLAttributes), 117 + ...(svgContent ? [['span', { innerHTML: svgContent }]] : [ 118 + ['span', { style: 'color:#888;font-style:italic;font-size:0.9em' }, '[Mermaid diagram — open doc to render]'], 119 + ]), 120 + ]; 103 121 }, 104 122 105 123 addCommands() { ··· 171 189 svgEl.style.maxWidth = '100%'; 172 190 svgEl.style.height = 'auto'; 173 191 } 192 + // Persist the rendered SVG so HTML/PDF exports include the diagram. 193 + if (typeof getPos === 'function') { 194 + editor.view.dispatch( 195 + editor.view.state.tr.setNodeMarkup(getPos(), undefined, { 196 + ...node.attrs, 197 + code, 198 + renderedSvg: svg, 199 + }) 200 + ); 201 + } 174 202 } catch (err: unknown) { 175 203 const msg = err instanceof Error ? err.message : String(err); 176 204 preview.innerHTML = `<div class="mermaid-error"><strong>Diagram error</strong><pre>${escHtml(msg)}</pre></div>`; ··· 231 259 scheduleRender(currentCode); 232 260 }); 233 261 234 - // Commit code to Yjs when textarea loses focus 262 + // On blur, render immediately (cancels any pending debounce) so the 263 + // node attrs are committed to Yjs before the user navigates away. 235 264 textarea.addEventListener('blur', () => { 236 265 if (currentCode !== (node.attrs.code ?? DEFAULT_CODE)) { 237 - updateNodeCode(currentCode); 266 + if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; } 267 + render(currentCode); 238 268 } 239 269 }); 240 270
+4 -1
src/docs/pdf-import.ts
··· 33 33 const pdf = await loadingTask.promise; 34 34 const pages: PdfTextItem[][] = []; 35 35 36 - for (let p = 1; p <= pdf.numPages; p++) { 36 + const MAX_PAGES = 500; 37 + const pageCount = Math.min(pdf.numPages, MAX_PAGES); 38 + 39 + for (let p = 1; p <= pageCount; p++) { 37 40 const page = await pdf.getPage(p); 38 41 const content = await page.getTextContent(); 39 42 const viewport = page.getViewport({ scale: 1 });
+7 -4
src/slides/pptx-import.ts
··· 254 254 style: { fontSize: '12px', fontFamily: 'monospace', color: '#222222', whiteSpace: 'pre', padding: '4px', lineHeight: '1.6', overflow: 'hidden' } }; 255 255 } 256 256 257 - // --- Group (recursive) --- 258 - async function parseGrp(grpSp: Element, conv: Conv, z: number, rels: Map<string, string>, zip: JSZip): Promise<SlideElement[]> { 257 + // --- Group (recursive, depth-limited) --- 258 + const MAX_GROUP_DEPTH = 20; 259 + async function parseGrp(grpSp: Element, conv: Conv, z: number, rels: Map<string, string>, zip: JSZip, depth = 0): Promise<SlideElement[]> { 260 + if (depth > MAX_GROUP_DEPTH) return []; 259 261 const els: SlideElement[] = []; 260 262 for (const child of Array.from(grpSp.children)) { 261 263 if (child.localName === 'sp') { const e = parseSp(child, conv, z); if (e) { els.push(e); z++; } } 262 264 else if (child.localName === 'pic') { const e = await parsePic(child, conv, z, rels, zip); if (e) { els.push(e); z++; } } 263 265 else if (child.localName === 'graphicFrame') { const e = parseGf(child, conv, z); if (e) { els.push(e); z++; } } 264 - else if (child.localName === 'grpSp') { const n = await parseGrp(child, conv, z, rels, zip); els.push(...n); z += n.length; } 266 + else if (child.localName === 'grpSp') { const n = await parseGrp(child, conv, z, rels, zip, depth + 1); els.push(...n); z += n.length; } 265 267 } 266 268 return els; 267 269 } ··· 330 332 const order = await slideOrder(zip); 331 333 const slides: Slide[] = []; 332 334 333 - for (const idx of order) { 335 + const MAX_SLIDES = 200; 336 + for (const idx of order.slice(0, MAX_SLIDES)) { 334 337 const slideFile = zip.file(`ppt/slides/slide${idx}.xml`); 335 338 if (!slideFile) continue; 336 339 const doc = parseXml(await slideFile.async('string'));
+39 -8
tests/server-notifications.test.ts
··· 27 27 28 28 const hostname = parsed.hostname.toLowerCase(); 29 29 30 - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]') { 31 - return false; 32 - } 30 + if (hostname === 'localhost') return false; 33 31 34 32 const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); 35 33 if (ipv4Match) { 36 34 const [, a, b] = ipv4Match.map(Number); 37 35 if (a === 10) return false; 36 + if (a === 127) return false; // 127.0.0.0/8 38 37 if (a === 172 && b !== undefined && b >= 16 && b <= 31) return false; 39 38 if (a === 192 && b === 168) return false; 40 39 if (a === 169 && b === 254) return false; 40 + if (a === 100 && b !== undefined && b >= 64 && b <= 127) return false; // Tailscale CGNAT 41 41 if (a === 0) return false; 42 + if (a === 240) return false; 43 + if (a === 255) return false; 42 44 } 43 45 44 46 if (hostname.startsWith('[')) { 45 47 const inner = hostname.slice(1, -1).toLowerCase(); 46 - if (inner.startsWith('fe80:') || inner.startsWith('fc') || inner.startsWith('fd') || inner === '::1') { 48 + if ( 49 + inner === '::1' || 50 + inner.startsWith('fe80:') || 51 + inner.startsWith('fc') || 52 + inner.startsWith('fd') || 53 + inner.startsWith('::ffff:') 54 + ) { 47 55 return false; 48 56 } 49 57 } ··· 68 76 expect(isValidIcsUrl('ftp://example.com/feed.ics')).toBe(false); 69 77 }); 70 78 71 - it('rejects localhost', () => { 79 + it('rejects localhost and full 127.0.0.0/8 loopback range', () => { 72 80 expect(isValidIcsUrl('https://localhost/feed.ics')).toBe(false); 73 81 expect(isValidIcsUrl('https://127.0.0.1/feed.ics')).toBe(false); 82 + expect(isValidIcsUrl('https://127.0.0.2/feed.ics')).toBe(false); // previously missed 83 + expect(isValidIcsUrl('https://127.255.255.255/feed.ics')).toBe(false); // previously missed 74 84 expect(isValidIcsUrl('https://[::1]/feed.ics')).toBe(false); 75 85 }); 76 86 87 + it('rejects Tailscale CGNAT range 100.64.0.0/10', () => { 88 + expect(isValidIcsUrl('https://100.64.0.1/feed.ics')).toBe(false); 89 + expect(isValidIcsUrl('https://100.100.0.1/feed.ics')).toBe(false); 90 + expect(isValidIcsUrl('https://100.127.255.255/feed.ics')).toBe(false); 91 + // 100.63.x.x is NOT in the CGNAT range (below 100.64) 92 + expect(isValidIcsUrl('https://100.63.0.1/feed.ics')).toBe(true); 93 + // 100.128.x.x is NOT in the CGNAT range (above 100.127) 94 + expect(isValidIcsUrl('https://100.128.0.1/feed.ics')).toBe(true); 95 + }); 96 + 77 97 it('rejects private IP ranges', () => { 78 98 // 10.0.0.0/8 79 99 expect(isValidIcsUrl('https://10.0.0.1/feed.ics')).toBe(false); ··· 107 127 expect(isValidIcsUrl('https://[fe80::1]/feed.ics')).toBe(false); 108 128 expect(isValidIcsUrl('https://[fc00::1]/feed.ics')).toBe(false); 109 129 expect(isValidIcsUrl('https://[fd00::1]/feed.ics')).toBe(false); 130 + expect(isValidIcsUrl('https://[::ffff:127.0.0.1]/feed.ics')).toBe(false); // IPv4-mapped loopback 110 131 }); 111 132 }); 112 133 ··· 232 253 233 254 // DELETE /api/push/subscribe 234 255 app.delete('/api/push/subscribe', (req: Req, res: Res) => { 256 + if (!req.tsUser) { res.status(403).json({ error: 'Authentication required' }); return; } 235 257 const { endpoint } = req.body as { endpoint?: string }; 236 258 if (!endpoint || typeof endpoint !== 'string') { 237 259 res.status(400).json({ error: 'endpoint is required' }); return; 238 260 } 239 - stmts.deleteSubscription.run(endpoint); 261 + db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ? AND user_login = ?').run(endpoint, req.tsUser.login); 240 262 res.status(204).send(); 241 263 }); 242 264 ··· 457 479 expect(count.count).toBe(1); 458 480 }); 459 481 482 + it('rejects delete without authentication', async () => { 483 + const res = await fetch(`${baseUrl}/api/push/subscribe`, { 484 + method: 'DELETE', 485 + headers: { 'Content-Type': 'application/json' }, 486 + body: JSON.stringify({ endpoint: testEndpoint }), 487 + }); 488 + expect(res.status).toBe(403); 489 + }); 490 + 460 491 it('deletes a push subscription', async () => { 461 492 // Re-subscribe as testuser first 462 493 await fetch(`${baseUrl}/api/push/subscribe`, { ··· 467 498 468 499 const res = await fetch(`${baseUrl}/api/push/subscribe`, { 469 500 method: 'DELETE', 470 - headers: { 'Content-Type': 'application/json' }, 501 + headers: authHeaders(), 471 502 body: JSON.stringify({ endpoint: testEndpoint }), 472 503 }); 473 504 expect(res.status).toBe(204); ··· 481 512 it('rejects delete without endpoint', async () => { 482 513 const res = await fetch(`${baseUrl}/api/push/subscribe`, { 483 514 method: 'DELETE', 484 - headers: { 'Content-Type': 'application/json' }, 515 + headers: authHeaders(), 485 516 body: JSON.stringify({}), 486 517 }); 487 518 expect(res.status).toBe(400);