Skip to content

fix(sheets): bound recalc/read/write against untrusted-input DoS + fix exports map - #1

Merged
andrei-hasna merged 3 commits into
mainfrom
fix/recalc-dos-budget
Jul 24, 2026
Merged

fix(sheets): bound recalc/read/write against untrusted-input DoS + fix exports map#1
andrei-hasna merged 3 commits into
mainfrom
fix/recalc-dos-budget

Conversation

@andrei-hasna

Copy link
Copy Markdown
Contributor

Summary

Every write and load in @hasna/sheets routes through recalc(), whose dependency graph, range materialization, formula parsing, and string output were all unbounded. Raw client workbooks (saveSheetWorkbookAction) and agent tool calls (sheets_set_cells) could hang the process or exhaust memory. This PR bounds all of them and fixes the broken exports map.

Vectors neutralized (real PoC payloads, measured after the fix)

Vector Payload Before After
V1a range read amplification getRangeValues("A1:A2000000") on a 100-row grid ~617ms, ~2M rows ~0ms, clamped to grid
V1b oversized declared range =SUM(A1:A200000) / 200k rows ~66s ~6ms, range_too_large
V2 O(N^2) dependency graph 40k cells each =SUM(A1:A20000) seconds→minutes (1M cells ≈ hours) ~7.5s, recalc_op_budget_exceeded (bounded)
V3 bulk CSV import 100001 formula cells unbounded ~28ms, batch_too_large
V4 output bomb =REPT("z",300000000) ~7.3s + ~300MB ~4ms, bounded #VALUE! cell
V5 parse cost (single) one >8192-char formula body ~seconds ~0ms, formula_too_long
V5 parse cost (aggregate) 5000 × 8000-char =1+1+... ~34s+ ~10s, recalc_time_budget_exceeded (bounded)

What changed

  • New src/lib/limits.ts: SheetsLimits + DEFAULT_LIMITS, a typed dash-free SheetsLimitError (code/limit/actual, instanceof-safe), and a RecalcBudget (integer op counter + wall-clock deadline).
  • Source caps reject oversized input before the expensive work runs (the only defense that can bound a single synchronous parser.parse, which a counter cannot interrupt): formula length 8192, cell content 32767, sheet dims 1048576×16384, populated cells 1e6, range cells 65536, batch 1e5.
  • The recalc budget is threaded through the O(N^2) dependency loop and the per-cell evaluation loop; limit breaches thrown inside onCell/onRange (which the parser rewraps as FormulaError) are stashed and re-thrown after parse().
  • REPT/CONCAT/CONCATENATE/TEXTJOIN overridden to refuse output larger than a cell can hold, plus a post-eval coerceResult guard for the raw & operator.
  • Exports map fix: default/require condition added to . and ./react, plus ./package.json — unblocks webpack/next build in consuming apps (previously worked around with a next.config alias).
  • SheetsLimitError, SheetsLimits, SheetsLimitCode, LimitOptions, DEFAULT_LIMITS exported from the package root.
  • Version 0.1.00.2.0.

Design note (deviation)

maxRangeCells is 65536, below Excel's 1,048,576 row ceiling. fast-formula-parser evaluates a single range super-linearly — a 200k-cell SUM costs ~66s in one uninterruptible synchronous call, with a sharp cost cliff past ~65k cells — so the per-range cap has to sit under that cliff to be an effective single-call CPU bound. This is 100x+ above any realistic in-app range.

Backward compatibility

All limits live in DEFAULT_LIMITS and every new parameter is optional, so existing callers are unchanged; a realistic ~10k-cell workbook recalcs in well under every budget (verified by a false-positive test).

Tests

src/lib/recalc.dos.test.ts reproduces V1..V5 with the real payloads, verifies the SheetsLimitError shape, pins DEFAULT_LIMITS to the shipped values, and guards against false positives. Full suite: 68 pass / 0 fail. tsc --noEmit clean. bun run build clean.

…x exports map

Every write and load routes through recalc(), whose dependency graph, range
materialization, formula parsing, and string output were all unbounded. Raw
client workbooks (saveSheetWorkbookAction) and agent tool calls (sheets_set_cells)
could hang or exhaust memory:
  V1 range amplification (getRangeValues / SUM over an oversized declared range)
  V2 O(N^2) dependency graph (N cells each SUM a wide range)
  V3 bulk CSV import of many formula cells
  V4 REPT/CONCAT output bomb (tiny input, huge output)
  V5 oversized formula parse cost (single giant body and many max-length bodies)

New src/lib/limits.ts adds SheetsLimits + DEFAULT_LIMITS, a typed dash-free
SheetsLimitError (code/limit/actual, instanceof-safe), and a RecalcBudget (op
counter + wall-clock deadline) threaded through recalc(). Source caps reject
oversized input before the expensive work runs (the only defense that can bound
a single synchronous parser.parse call, which a counter cannot interrupt):
formula length 8192, cell content 32767, sheet dims 1048576 x 16384, populated
cells 1e6, range cells 65536, batch 1e5. The string-amplifier functions
(REPT/CONCAT/CONCATENATE/TEXTJOIN) are overridden to refuse output larger than a
cell can hold, with a post-eval coerceResult guard for the raw & operator.

maxRangeCells is 65536, below Excel's row ceiling: fast-formula-parser evaluates
a single range super-linearly (a 200k-cell SUM costs ~66s in one uninterruptible
call, with a sharp cliff past ~65k), so the per-range cap must sit under that
cliff. Limit breaches thrown inside onCell/onRange (which the parser rewraps as
FormulaError) are stashed and re-thrown after parse().

All limits are optional overrides over DEFAULT_LIMITS, so existing callers are
unchanged; a realistic ~10k-cell workbook recalcs in well under the budgets.

Also fixes the package.json exports map: add a default/require condition to "."
and "./react" and add "./package.json", which unblocks webpack/next build in
consuming apps (previously worked around with a next.config alias).

Adds src/lib/recalc.dos.test.ts reproducing V1..V5 with the real payloads plus a
false-positive guard. Bumps 0.1.0 -> 0.2.0.
…ors trip in ~1.5-2s

The round-4 op-budget + Excel-spec caps bound every DoS vector, but two residual within-cap payloads only terminated at the 10s wall-clock: an O(N^2) dep graph (thousands of =SUM(A1:A20000) cells) and a parse-aggregate flood (thousands of ~8000-char =1+1+... bodies). A 10s single-thread block is still an availability hit.

Lower recalcWallClockMs 10000->1800 and scale recalcOpBudget 500M->90M to the same time envelope. Both vectors now trip in ~1.5-1.8s (V2 on the op budget, V5-aggregate on the wall clock) instead of ~10s. A realistic ~10k-cell workbook recalcs in ~70ms, so 1800ms keeps a ~25x false-positive margin. No architecture change; only the default constants (and the test pinning them) are tuned.
The V5 aggregate DoS test tightened only totalFormulaCharsBudget and left
recalcWallClockMs at the 1800ms default, so on a slow CI runner the ~250
expensive 8000-char parses crossed the wall-clock deadline first and the
test flaked with recalc_time_budget_exceeded instead of the asserted
formula_parse_budget_exceeded. Lower the char budget so it trips after a few
dozen parses and lift the wall clock, making the parse-char budget the
deterministic binding constraint. Production code is unchanged.
@andrei-hasna
andrei-hasna merged commit e1a2f3c into main Jul 24, 2026
1 check passed
@andrei-hasna
andrei-hasna deleted the fix/recalc-dos-budget branch July 24, 2026 15:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant