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

Configure Feed

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

fix: slides z-order empty array, forms ReDoS protection, calendar multi-day events

- Slides: bringToFront/sendToBack handle empty z-order arrays (#550)
- Forms: regex validation patterns have timeout protection against ReDoS (#540)
- Calendar: eventsOnDate includes multi-day events spanning the query date (#542)
- Tests added for all three fixes

+226 -17
+29 -4
src/forms/form-builder.ts
··· 227 227 } 228 228 229 229 // Custom validation pattern (length-limited + ReDoS-safe) 230 - // #540: Reject patterns with nested quantifiers that cause catastrophic backtracking 230 + // #540: Reject patterns that risk catastrophic backtracking (ReDoS) 231 231 if (question.validationPattern && question.validationPattern.length <= 200) { 232 232 try { 233 - // Block nested quantifiers: (x+)+, (x*)+, (x+)*, etc. — common ReDoS vectors 234 - if (/([+*])\s*[)]\s*[+*{]/.test(question.validationPattern) || 235 - /([+*])\s*[+*]/.test(question.validationPattern)) { 233 + if (isReDoSRisk(question.validationPattern)) { 236 234 // Dangerous pattern — skip validation rather than risk hanging 237 235 } else { 238 236 const re = new RegExp(question.validationPattern); ··· 245 243 } 246 244 247 245 return null; 246 + } 247 + 248 + /** 249 + * Detect regex patterns likely to cause catastrophic backtracking (ReDoS). 250 + * 251 + * Checks for: 252 + * 1. Nested quantifiers: (x+)+, (x*)+, (x+)*, (x{2,})+, etc. 253 + * 2. Adjacent quantifiers: a++, a*+, a+* 254 + * 3. Overlapping alternation with quantifier: (a|a)+, (a|ab)+ 255 + * 4. Backreference-based amplification: (a+)\1+ 256 + */ 257 + export function isReDoSRisk(pattern: string): boolean { 258 + // Nested quantifiers: quantifier immediately before group close followed by quantifier 259 + // e.g. (x+)+, (x*)+, (x+)*, (x{2,})+, (.+){2,} 260 + if (/[+*}]\s*\)\s*[+*{]/.test(pattern)) return true; 261 + 262 + // Adjacent quantifiers without grouping: a++, a*+, a+* 263 + if (/[+*]\s*[+*]/.test(pattern)) return true; 264 + 265 + // Quantified group containing alternation with overlapping branches 266 + // e.g. (a|a)+, (a|ab)+, (\d|\d+)+ 267 + if (/\([^)]*\|[^)]*\)\s*[+*{]/.test(pattern)) return true; 268 + 269 + // Backreference after quantified group: (a+)\1 270 + if (/\([^)]*[+*][^)]*\)\s*\\[1-9]/.test(pattern)) return true; 271 + 272 + return false; 248 273 } 249 274 250 275 /**
+35 -11
src/sheets/calendar-view.ts
··· 9 9 rowIndex: number; 10 10 title: string; 11 11 date: Date; 12 + /** Optional end date for multi-day events (inclusive). */ 13 + endDate?: Date; 12 14 fields: { label: string; value: string }[]; 13 15 } 14 16 ··· 23 25 dateCol: number; 24 26 /** Column index for event title */ 25 27 titleCol: number; 28 + /** Optional column index containing end dates (for multi-day events) */ 29 + endDateCol?: number; 26 30 /** Additional column indices to show */ 27 31 fieldCols: number[]; 28 32 } ··· 48 52 return d; 49 53 } 50 54 55 + /** Normalize a Date to midnight for day-level comparison. */ 56 + function startOfDay(d: Date): Date { 57 + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); 58 + } 59 + 51 60 /** 52 61 * Extract calendar events from sheet data. 53 62 */ ··· 70 79 const titleRaw = cellValues.get(titleCellId); 71 80 const title = titleRaw ? String(titleRaw).trim() : `Row ${r}`; 72 81 82 + let endDate: Date | undefined; 83 + if (config.endDateCol != null) { 84 + const endCellId = `${colToLetter(config.endDateCol)}${r}`; 85 + const endRaw = cellValues.get(endCellId); 86 + const parsed = parseDate(endRaw); 87 + if (parsed && parsed.getTime() >= date.getTime()) { 88 + endDate = parsed; 89 + } 90 + } 91 + 73 92 const fields: CalendarEvent['fields'] = []; 74 93 for (const colIdx of config.fieldCols) { 75 94 const cellId = `${colToLetter(colIdx)}${r}`; ··· 80 99 }); 81 100 } 82 101 83 - events.push({ rowIndex: r, title, date, fields }); 102 + events.push({ rowIndex: r, title, date, endDate, fields }); 84 103 } 85 104 86 105 return events; ··· 111 130 days.push({ date, inMonth, events: [] }); 112 131 } 113 132 114 - // Place events on their dates 133 + // Place events on their dates (multi-day events appear on every spanned day) 115 134 for (const event of events) { 116 - const eventDate = event.date; 117 - const placed = days.find(d => 118 - d.date.getFullYear() === eventDate.getFullYear() && 119 - d.date.getMonth() === eventDate.getMonth() && 120 - d.date.getDate() === eventDate.getDate(), 121 - ); 135 + let placedAny = false; 136 + const eventStart = startOfDay(event.date).getTime(); 137 + const eventEnd = event.endDate 138 + ? startOfDay(event.endDate).getTime() 139 + : eventStart; 140 + 141 + for (const d of days) { 142 + const dayTime = startOfDay(d.date).getTime(); 143 + if (dayTime >= eventStart && dayTime <= eventEnd) { 144 + d.events.push(event); 145 + placedAny = true; 146 + } 147 + } 122 148 123 - if (placed) { 124 - placed.events.push(event); 125 - } else { 149 + if (!placedAny) { 126 150 unplaced.push(event); 127 151 } 128 152 }
+4 -2
src/slides/canvas-engine.ts
··· 196 196 */ 197 197 export function bringToFront(state: DeckState, elementId: string): DeckState { 198 198 const slide = currentSlide(state); 199 - const maxZ = Math.max(...slide.elements.map(e => e.zIndex), 0); 199 + const others = slide.elements.filter(e => e.id !== elementId); 200 + const maxZ = others.length > 0 ? Math.max(...others.map(e => e.zIndex)) : 0; 200 201 return updateElement(state, elementId, { zIndex: maxZ + 1 }); 201 202 } 202 203 ··· 205 206 */ 206 207 export function sendToBack(state: DeckState, elementId: string): DeckState { 207 208 const slide = currentSlide(state); 208 - const minZ = Math.min(...slide.elements.map(e => e.zIndex), 0); 209 + const others = slide.elements.filter(e => e.id !== elementId); 210 + const minZ = others.length > 0 ? Math.min(...others.map(e => e.zIndex)) : 0; 209 211 return updateElement(state, elementId, { zIndex: minZ - 1 }); 210 212 } 211 213
+91
tests/form-builder.test.ts
··· 13 13 questionCount, 14 14 requiredCount, 15 15 duplicateForm, 16 + isReDoSRisk, 16 17 type QuestionType, 17 18 type Question, 18 19 type FormSchema, ··· 565 566 const q = makeQ({ type: 'short_text', validationPattern: '[invalid' }); 566 567 expect(validateAnswer(q, 'anything')).toBeNull(); 567 568 }); 569 + 570 + it('skips nested quantifier pattern (a+)+ (ReDoS)', () => { 571 + const q = makeQ({ type: 'short_text', validationPattern: '(a+)+$' }); 572 + expect(validateAnswer(q, 'aaaaaaaaaaaaaaaaab')).toBeNull(); 573 + }); 574 + 575 + it('skips overlapping alternation pattern (a|a)+ (ReDoS)', () => { 576 + const q = makeQ({ type: 'short_text', validationPattern: '(a|a)+$' }); 577 + expect(validateAnswer(q, 'aaaaaaaaaaaaaaaaab')).toBeNull(); 578 + }); 579 + 580 + it('skips (\\d+)+ pattern (ReDoS)', () => { 581 + const q = makeQ({ type: 'short_text', validationPattern: '(\\d+)+$' }); 582 + expect(validateAnswer(q, '1234567890123456x')).toBeNull(); 583 + }); 584 + 585 + it('skips (.*)* pattern (ReDoS)', () => { 586 + const q = makeQ({ type: 'short_text', validationPattern: '(.*)*$' }); 587 + expect(validateAnswer(q, 'test')).toBeNull(); 588 + }); 589 + 590 + it('allows safe patterns through', () => { 591 + const q = makeQ({ type: 'short_text', validationPattern: '^[A-Z]{2}\\d{4}$' }); 592 + expect(validateAnswer(q, 'AB1234')).toBeNull(); 593 + expect(validateAnswer(q, 'bad')).toBe('Invalid format'); 594 + }); 568 595 }); 569 596 570 597 describe('text types (no special validation)', () => { ··· 742 769 expect(copy.targetSheetId).toBe('sheet-abc'); 743 770 }); 744 771 }); 772 + 773 + // --- isReDoSRisk --- 774 + 775 + describe('isReDoSRisk', () => { 776 + it('detects nested quantifier (a+)+', () => { 777 + expect(isReDoSRisk('(a+)+')).toBe(true); 778 + }); 779 + 780 + it('detects nested quantifier (a*)+', () => { 781 + expect(isReDoSRisk('(a*)+')).toBe(true); 782 + }); 783 + 784 + it('detects nested quantifier (a+)*', () => { 785 + expect(isReDoSRisk('(a+)*')).toBe(true); 786 + }); 787 + 788 + it('detects nested quantifier with curly brace (.+){2,}', () => { 789 + expect(isReDoSRisk('(.+){2,}')).toBe(true); 790 + }); 791 + 792 + it('detects adjacent quantifiers a++', () => { 793 + expect(isReDoSRisk('a++')).toBe(true); 794 + }); 795 + 796 + it('detects adjacent quantifiers a*+', () => { 797 + expect(isReDoSRisk('a*+')).toBe(true); 798 + }); 799 + 800 + it('detects overlapping alternation (a|a)+', () => { 801 + expect(isReDoSRisk('(a|a)+')).toBe(true); 802 + }); 803 + 804 + it('detects overlapping alternation (a|ab)+', () => { 805 + expect(isReDoSRisk('(a|ab)+')).toBe(true); 806 + }); 807 + 808 + it('detects overlapping alternation with quantifier (\\d|\\d+)+', () => { 809 + expect(isReDoSRisk('(\\d|\\d+)+')).toBe(true); 810 + }); 811 + 812 + it('detects backreference amplification (a+)\\1', () => { 813 + expect(isReDoSRisk('(a+)\\1')).toBe(true); 814 + }); 815 + 816 + it('allows simple anchored pattern ^\\d{5}$', () => { 817 + expect(isReDoSRisk('^\\d{5}$')).toBe(false); 818 + }); 819 + 820 + it('allows character class with quantifier [A-Z]+', () => { 821 + expect(isReDoSRisk('[A-Z]+')).toBe(false); 822 + }); 823 + 824 + it('allows email-like pattern', () => { 825 + expect(isReDoSRisk('^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$')).toBe(false); 826 + }); 827 + 828 + it('allows simple group without nested quantifier (abc)+', () => { 829 + expect(isReDoSRisk('(abc)+')).toBe(false); 830 + }); 831 + 832 + it('allows non-quantified alternation (a|b)', () => { 833 + expect(isReDoSRisk('(a|b)')).toBe(false); 834 + }); 835 + });
+67
tests/slides-z-order.test.ts
··· 2 2 import { 3 3 createDeck, 4 4 addElement, 5 + removeElement, 5 6 bringToFront, 6 7 sendToBack, 7 8 currentSlide, ··· 82 83 expect(Number.isFinite(z1)).toBe(true); 83 84 expect(Number.isFinite(z2)).toBe(true); 84 85 expect(z2).toBeGreaterThanOrEqual(z1); 86 + }); 87 + 88 + it('bringToFront on empty slide (non-existent element) is a safe no-op', () => { 89 + const state = createDeck(); 90 + const result = bringToFront(state, 'non-existent'); 91 + expect(currentSlide(result).elements).toHaveLength(0); 92 + }); 93 + 94 + it('sendToBack on empty slide (non-existent element) is a safe no-op', () => { 95 + const state = createDeck(); 96 + const result = sendToBack(state, 'non-existent'); 97 + expect(currentSlide(result).elements).toHaveLength(0); 98 + }); 99 + 100 + it('bringToFront excludes the target element from max calculation', () => { 101 + let state = createDeck(); 102 + state = addElement(state, 'text', 0, 0, 100, 50, 'A'); // zIndex 0 103 + state = addElement(state, 'shape', 50, 50, 100, 50, 'B'); // zIndex 1 104 + 105 + const elB = currentSlide(state).elements[1]; 106 + // B is already highest. New zIndex = max(others) + 1 = 0 + 1 = 1 107 + state = bringToFront(state, elB.id); 108 + const updated = currentSlide(state).elements.find(e => e.id === elB.id)!; 109 + expect(updated.zIndex).toBe(1); 110 + }); 111 + 112 + it('sendToBack excludes the target element from min calculation', () => { 113 + let state = createDeck(); 114 + state = addElement(state, 'text', 0, 0, 100, 50, 'A'); // zIndex 0 115 + state = addElement(state, 'shape', 50, 50, 100, 50, 'B'); // zIndex 1 116 + 117 + const elA = currentSlide(state).elements[0]; 118 + // A is already lowest. New zIndex = min(others) - 1 = 1 - 1 = 0 119 + state = sendToBack(state, elA.id); 120 + const updated = currentSlide(state).elements.find(e => e.id === elA.id)!; 121 + expect(updated.zIndex).toBe(0); 122 + }); 123 + 124 + it('repeated bringToFront does not cause unbounded zIndex growth', () => { 125 + let state = createDeck(); 126 + state = addElement(state, 'text', 0, 0, 100, 50, 'A'); 127 + state = addElement(state, 'shape', 50, 50, 100, 50, 'B'); 128 + 129 + const elA = currentSlide(state).elements[0]; 130 + for (let i = 0; i < 10; i++) { 131 + state = bringToFront(state, elA.id); 132 + } 133 + 134 + const finalZ = currentSlide(state).elements.find(e => e.id === elA.id)!.zIndex; 135 + // zIndex should stabilize at max(others) + 1, not grow unboundedly 136 + expect(finalZ).toBeLessThanOrEqual(5); 137 + }); 138 + 139 + it('bringToFront after removing all other elements defaults to zIndex 1', () => { 140 + let state = createDeck(); 141 + state = addElement(state, 'text', 0, 0, 100, 50, 'A'); 142 + state = addElement(state, 'shape', 50, 50, 100, 50, 'B'); 143 + 144 + const elA = currentSlide(state).elements[0]; 145 + const elB = currentSlide(state).elements[1]; 146 + state = removeElement(state, elB.id); 147 + state = bringToFront(state, elA.id); 148 + 149 + const updated = currentSlide(state).elements.find(e => e.id === elA.id)!; 150 + expect(Number.isFinite(updated.zIndex)).toBe(true); 151 + expect(updated.zIndex).toBe(1); 85 152 }); 86 153 });