fix(core): treat unparsable timing attributes as missing instead of NaN#1206
fix(core): treat unparsable timing attributes as missing instead of NaN#1206calcarazgre646 wants to merge 2 commits into
Conversation
A typo like data-start="abc" flowed through parseFloat unguarded in both the timing compiler and the HTML parser. The compiler serialized it into the compiled output as data-end="NaN"; the parser turned a garbage data-end into a NaN duration via Math.max(0, NaN), which is NaN. Neither lint nor compile surfaced anything to the user. compileTag now normalizes an unparsable data-start to 0 in the output, strips unparsable data-end/data-duration so they take the existing missing-attribute paths (recompute or resolver), and the parser falls back to its own defaults. Same policy extractResolvedMedia already applied to data-duration, and the same validation the composition-level duration already gets in extractCompositionMetadata.
CodeQL flags the \s* prefix as polynomial backtracking on attacker-controlled input (js/polynomial-redos). A single \s is enough: the attribute always has whitespace before it inside a tag.
6b08c71 to
d34c592
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
Clear with minor nits
The case is straightforward, the deduction sound, and the evidence on the floor accounts for every footprint in the snow. A typo in a data-* timing attribute was sailing past parseFloat and being re-serialized as the literal string "NaN", where it then poisoned every downstream reader with all the discretion of a fox in a chicken coop. The remedy — a small, well-named parseTimingAttr(value, fallback) helper that mirrors the Number.isFinite policy extractResolvedMedia had already adopted for data-duration — is the long-term shape: one guard, applied uniformly at every read in the compile and parse layers, with a deliberate normalization-or-strip at every write so the garbage never reaches the document. Tests pin the cardinal points (abc, oops, Infinity, the Math.max(0, NaN) propagation in the parser, the updateElementInHtml write path), all 1175 core suites pass, and CI sits green across CodeQL, perf, regression, and the Windows render shard.
A few observations worth registering. None blocking.
Findings
1. Dispatch chain — read-side consumers in packages/core not unified onto parseTimingAttr.
The helper is exported, but five sibling readers continue to defend against garbage with the older parseFloat(...) || 0 idiom rather than adopting the new canonical guard:
packages/core/src/runtime/adapters/css.ts:96—Number.parseFloat(entry.el.getAttribute("data-start") ?? "0") || 0packages/core/src/studio-api/helpers/sourceMutation.ts:298, 301, 302, 365— same|| 0pattern acrossdata-start,data-end,data-duration,data-playback-startpackages/core/src/studio-api/helpers/sourceMutation.ts:366-367— a hand-rolledNumber.isFinite(rateRaw) ? rateRaw : 1that is structurallyparseTimingAttrwith a non-zero fallback
The audit is reassuring: each of these sites is read-only and NaN || 0 = 0, so garbage already silently collapses to 0 at every consumer — no NaN escapes into the runtime. So this is not the band-aid pattern (no contract drift, no surface bug), but it is the missed unification opportunity: a future reader will wonder why two idioms coexist for the same job, and the next pair of eyes that needs to widen the policy (say, to clamp negatives, or to telemeter unparsable hits) will have to touch six call sites in two idioms instead of one. A follow-up PR that ports the || 0 and the hand-rolled Number.isFinite site onto parseTimingAttr would close the door fully and is cheap. Mentioning in the description that "the read-side || 0 idiom at css.ts and sourceMutation.ts already neutralizes the same garbage and was intentionally left as a follow-up" would also do the job.
2. Small footgun — getAttr's discovery regex ([^"']+) and the strip regex ([^"']*) disagree on empty values.
In timingCompiler.ts, getAttr at line 60-63 uses [^"']+ (one-or-more), so data-end="" returns null from the getter. The unparsable-strip at line 124 uses [^"']* (zero-or-more), which would match the empty-value attribute — but it never runs, because the prior getAttr call returned null and the dataEndStr !== null guard short-circuits. Practical consequence: an empty data-end="" survives compile-time intact. The parser at htmlParser.ts:190 catches it via parseTimingAttr's parseFloat("") = NaN branch and falls back to start + 5, so the NaN never escapes into the timeline model — but the compiled HTML itself still carries the malformed attribute. Small; bounded by the downstream guard; worth a one-line follow-up either way (either widen the getter to [^"']* so empty values are detected and stripped, or accept that empty values are a no-op at compile and rely on parse-side fallback). I lean toward widening the getter for symmetry; the cost is one character.
3. parseFloat("Infinity") === Infinity — covered by the helper, but worth a note.
The unit test at timingCompiler.test.ts:246 correctly asserts that parseTimingAttr("Infinity", 3) falls back to 3 because Number.isFinite(Infinity) === false. Good. The same logic guards parseFloat("1e1000") (which JavaScript silently coerces to Infinity). Whether intentional or coincidental, it is the right behavior — a literal Infinity in data-end should not be honored as a clip ending in the far future. The author may want to add a one-line comment to the helper noting that the Number.isFinite check is also what rejects "Infinity" and "NaN" as string-literals, which are the two finite-looking failure modes a parser-only reader might miss.
4. Test coverage gaps — minor.
The headline cases are well-covered. Three composite shapes are tacit-only:
- Both
data-start="abc"ANDdata-end="oops"on the same element. By composition of the existing logic, data-start normalizes to 0, data-end strips, the element marks unresolved withstart=0. No explicit test asserts the composition; the coverage relies on each helper test plus the reader's compositionality argument. clampDurationswith a garbagedata-start. The path attimingCompiler.ts:303now usesparseTimingAttr, so a previously-NaN-poisoneddata-endwill now recompute cleanly to the clamp value. Useful change, untested.injectDurationswith a garbagedata-start. Same shape at line 238. Tested for the clean case attimingCompiler.test.ts:112-127; the unparsable-data-startbranch is not explicitly exercised.
The composition is correct; the additional cases would be one-liners. Optional.
Stale-base check
The PR branches from ef186139. Current main advances to 513819ee. Between them, only 6f677292 (PR #1413, the Miguel-led packages/core simplification) touched the same files (htmlParser.ts only). The regions are disjoint — #1413 modified the UHD-resolution constants near line 139 and the addElementToHtml helper around line 612; #1206 modifies the parseHtml element walk at lines 182-191 and updateElementInHtml at line 532. No squash hazard. A pre-merge rebase will conflict-free.
CI
All required checks green at d34c592c: CI build/lint/typecheck/test/format/fallow-audit, CodeQL (the prior \s* ReDoS finding was resolved by the bounded-prefix fix in the head commit), perf load/fps/scrub/drift/parity, preview-regression, 8 regression shards, Windows render verification, CLI smoke, semantic PR title.
Verdict
Clear with minor nits at d34c592c. None of the four findings are band-aid-bar issues — the dispatch chain is intact, the regex strip is sound, and the parser fallback is the right shape. Re-verify if HEAD moves before stamp.
Review by Via
Problem
Element-level timing attributes go through
parseFloatunguarded in the timing compiler and the HTML parser, so a typo likedata-start="abc"propagates as NaN with no signal to the user:compileTag(timingCompiler.ts) computesdata-endasstart + parseFloat(durationStr)and serializes the result into the compiled HTML: the output literally containsdata-end="NaN". The same unguarded reads exist ininjectDurations,extractResolvedMedia(start/mediaStart; duration was already guarded),clampDurations, and the unresolved-div scan.parseHtml(htmlParser.ts) computesduration = Math.max(0, parseFloat(dataEnd) - start)— andMath.max(0, NaN)is NaN, so a garbagedata-endlands in the timeline model as a NaN duration.updateElementInHtmlwritesdata-end="NaN"back into the document whendata-startis garbage.Neither lint nor compile reports anything: the linter has no numeric check for element timing attributes, while composition-level duration is already validated (
validateCompositionHtmlerrors on non-finitedata-composition-duration, andextractCompositionMetadataguards withisFinite). Element timing attrs missed the same policy.Change
Unparsable is treated as missing, which all of these paths already know how to handle:
parseTimingAttr(value, fallback)helper intimingCompiler.ts(the same policyextractResolvedMediaalready applies todata-duration), used at every timing-attribute read in both files.compileTagadditionally normalizes the compiled output: an unparsabledata-startis rewritten to0, and unparsabledata-end/data-durationare stripped so the element takes the existing recompute-or-resolver path. Downstream consumers (parser, runtime) never see the garbage values.parseHtmlfalls back to its existing defaults (start 0, the 5s default duration).No behavior change for valid inputs.
Tests
data-startnormalized to 0 withdata-endcomputed from it; unparsabledata-durationdropped and the element marked unresolved; unparsabledata-endstripped and recomputed; unparsabledata-media-startresolved to 0; directparseTimingAttrcases (including"Infinity"). All assert the compiled output contains noNaN.data-startparses as 0; unparsabledata-endfalls back to the 5s default instead of NaN;updateElementInHtmlrecomputesdata-endfrom 0 instead of writing NaN.bun run buildgreen.