Skip to content

Commit dc9654d

Browse files
authored
fix(file-parsers): cap untrusted number formats and make time-format check linear (#8893)
* fix(file-parsers): cap untrusted number formats and make time-format check linear * fix(file-parsers): keep the elapsed-format check on the uncapped format * fix(file-parsers): leave dates with over-long formats untouched instead of rescanning them
1 parent 25f5cd4 commit dc9654d

2 files changed

Lines changed: 48 additions & 5 deletions

File tree

‎apps/sim/lib/file-parsers/sheet-display-text.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,19 @@ describe('normalizeSheetDisplayText', () => {
230230
expect(inside.w).toBe('2026-03-04')
231231
expect(outside.w).toBe('3/5/2026')
232232
})
233+
234+
it('keeps the file text for a date format longer than Excel allows', () => {
235+
const sheet = XLSX.utils.aoa_to_sheet([['a']])
236+
const at = new Date(Date.UTC(2024, 2, 15, 14, 30))
237+
sheet.A1 = { t: 'd', v: at, z: `m/d/yyyy h:mm${'"x"'.repeat(100)}`, w: '3/15/2024 14:30' }
238+
sheet.B1 = { t: 'd', v: at, z: `m/d/yyyy h:mm${'"x"'.repeat(80)}`, w: '3/15/2024 14:30' }
239+
sheet.C1 = { t: 'd', v: at, z: `[h]:mm${'"x"'.repeat(100)}`, w: '14:30' }
240+
sheet['!ref'] = 'A1:C1'
241+
242+
normalizeSheetDisplayText(sheet, XLSX.utils.decode_range('A1:C1'), XLSX.utils)
243+
244+
expect(sheet.A1.w).toBe('3/15/2024 14:30')
245+
expect(sheet.B1.w).toBe('2024-03-15T14:30:00')
246+
expect(sheet.C1.w).toBe('14:30')
247+
})
233248
})

‎apps/sim/lib/file-parsers/sheet-display-text.ts‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ const GENERAL_SIGNIFICANT_DIGITS = 15
2323

2424
const ELAPSED_TOKEN = /\[(h+|m+|s+)\]/i
2525

26+
/**
27+
* Excel rejects a custom number format longer than 255 characters, so a longer
28+
* `cell.z` is not a real format. It comes verbatim from the workbook's
29+
* `styles.xml`, and parsing it would let one cell cost unbounded CPU.
30+
*/
31+
const MAX_NUMBER_FORMAT_LENGTH = 255
32+
33+
const TIME_SEPARATOR = /[:\s]/
34+
2635
/**
2736
* Strips the parts of a number format that carry no date tokens: quoted
2837
* literals, backslash escapes, bracketed colour/condition/elapsed sections and
@@ -48,14 +57,25 @@ export function isTimeOnlyFormat(format: string): boolean {
4857
if (/[yd]/.test(tokens)) return false
4958
if (!/[hms]/.test(tokens)) return false
5059
for (const match of tokens.matchAll(/m+/g)) {
51-
const before = tokens.slice(0, match.index).replace(/[:\s]+$/, '')
52-
const after = tokens.slice(match.index + match[0].length).replace(/^[:\s]+/, '')
53-
const isMinutes = before.endsWith('h') || after.startsWith('s')
54-
if (!isMinutes) return false
60+
if (!isMinutesRun(tokens, match.index, match.index + match[0].length)) return false
5561
}
5662
return true
5763
}
5864

65+
/**
66+
* Whether the `m` run at `[start, end)` sits next to an hour or a seconds
67+
* token, skipping `:` and whitespace. Each separator span is walked at most
68+
* once from each neighbouring run, so classifying a whole format is linear.
69+
*/
70+
function isMinutesRun(tokens: string, start: number, end: number): boolean {
71+
let before = start - 1
72+
while (before >= 0 && TIME_SEPARATOR.test(tokens[before])) before--
73+
if (tokens[before] === 'h') return true
74+
let after = end
75+
while (after < tokens.length && TIME_SEPARATOR.test(tokens[after])) after++
76+
return tokens[after] === 's'
77+
}
78+
5979
const MS_PER_SECOND = 1000
6080

6181
/**
@@ -106,6 +126,9 @@ function isGeneralFormat(format: unknown): boolean {
106126
* Elapsed-time formats (`[h]:mm`, `[mm]:ss`) are durations, not moments;
107127
* `cellDates` still parses them into a `Date`, so their `w` (`30:00`) is kept.
108128
*
129+
* A date whose format is longer than Excel allows is not a real format, so it
130+
* keeps the text the file rendered for it too.
131+
*
109132
* A number with no format at all is treated as General too. Every other
110133
* number keeps the text the file rendered for it, so a LibreOffice workbook
111134
* indexes as LibreOffice showed it (`0,5` in a German locale).
@@ -133,7 +156,12 @@ export function normalizeSheetDisplayText(
133156

134157
if (cell.t === 'd' && cell.v instanceof Date) {
135158
const format = typeof cell.z === 'string' ? cell.z : undefined
136-
if (format !== undefined && ELAPSED_TOKEN.test(format)) continue
159+
if (
160+
format !== undefined &&
161+
(format.length > MAX_NUMBER_FORMAT_LENGTH || ELAPSED_TOKEN.test(format))
162+
) {
163+
continue
164+
}
137165
cell.w = isoDateText(cell.v, format)
138166
} else if (cell.t === 'n' && typeof cell.v === 'number' && isGeneralFormat(cell.z)) {
139167
cell.w = generalNumberText(cell.v)

0 commit comments

Comments
 (0)