Skip to content

fix(security): harden reader against malicious xlsx files (0.3.0) - #3

Merged
4a4c53 merged 6 commits into
mainfrom
claude/project-analysis-improvements-3dokrb
Sep 6, 2026
Merged

4a4c53 merged 6 commits into
mainfrom
claude/project-analysis-improvements-3dokrb

Conversation

@4a4c53

@4a4c53 4a4c53 commented Sep 6, 2026 •

Copy link
Copy Markdown
Owner

Resumen

Endurece read() frente a archivos .xlsx maliciosos o corruptos, añade una carpeta docs/ con el backlog de la revisión y sube la versión a 0.3.0 (hay opciones públicas nuevas).

Cada punto se reprodujo con un script antes del arreglo y está cubierto en test/security.test.ts.

Fallos de seguridad corregidos

  1. ReDoS en el parser XML. El lector recorría filas, celdas, shared strings y relaciones con regex [\s\S]*?; ante etiquetas sin cerrar el coste era cuadrático (86 KB de <row> sin cerrar: 221 ms; extrapolado, 10 MB ≈ una hora de CPU). src/xml.ts incorpora un escáner lineal (elements, firstElement, stripElements, attr basado en indexOf). Ahora: 1 ms, y una etiqueta sin cerrar lanza XML malformado: falta </row>.
  2. Agotamiento de memoria en toRows()/toObjects(). Materializan el rectángulo denso; una celda en XFD1048576 exigía 17 000 M de entradas. Nueva opción maxCells (predeterminado 20 M) con RangeError descriptivo. toRows() recorre solo celdas pobladas (~4× más rápido).
  3. Sin presupuesto total de descompresión. El límite era por entrada y un ZIP admite 65 535. unzipSync acepta maxTotalSize/maxEntrySize, el tope se aplica sobre la salida real de zlib (no sobre el tamaño declarado, que se verifica), y read() lo expone como ReadOptions.maxDecompressedSize. Las partes XML mayores de MAX_PART_SIZE (256 MiB) fallan con mensaje propio en lugar del error opaco de V8.
  4. Entidades XML. Decodificación en una sola pasada (&#38;lt; es el texto literal &lt;) y error descriptivo para referencias fuera de Unicode en vez de un RangeError crudo.
  5. Cabecera __proto__ en toObjects(). Object.fromEntries define propiedades propias; el prototipo queda intacto.

Otros cambios

  • Fix: un <v/> vacío (openpyxl lo escribe en fórmulas sin valor cacheado) se lee como null, no como 0. Verificado contra el fixture real de openpyxl.
  • Exports nuevos: ReadOptions, InvalidSheetNamesMode, DenseOptions, DEFAULT_MAX_CELLS, MAX_PART_SIZE, MAX_ENTRY_SIZE, MAX_TOTAL_SIZE, MAX_ROWS, MAX_COLS.
  • README: sección "Hardening against untrusted files" y opciones nuevas en la API.
  • docs/: backlog interno (seguridad, bugs confirmados, mejoras de tooling, roadmap por versiones). No se publica en npm.
  • Integra main tras PR test(helpers): extract synthetic xlsx builders for reuse #2 y adapta el test de __proto__ al comportamiento corregido.
  • chore: bump version to 0.3.0.

Verificación

Comprobación Resultado
pnpm test 170 tests, 0 fallos
pnpm test:coverage 99,65 % líneas · 93,78 % ramas · 100 % funciones (por encima de los umbrales)
pnpm lint, pnpm typecheck, pnpm build limpios
Rendimiento (200 000 filas × 5) lectura igual o algo más rápida que antes

🤖 Generated with Claude Code

Replace the regex-based XML walking in the reader with linear indexOf
scanners (elements/firstElement/stripElements/attr in xml.ts). Unclosed
tags now fail fast with a descriptive error instead of scaling
quadratically (ReDoS): 86 KB of unclosed <row> went from ~220 ms to ~1 ms.

Bound dense materialization: toRows() and toObjects() accept maxCells
(default 20M) and throw a RangeError above it, so a file with a single
cell at XFD1048576 no longer exhausts memory. toRows() now walks only
populated cells.

Add an aggregate decompression budget to unzipSync (maxTotalSize,
maxEntrySize), enforce it on the actual inflated output rather than the
declared size, verify declared sizes, and expose it as
ReadOptions.maxDecompressedSize. Reject XML parts above MAX_PART_SIZE
with a descriptive error instead of an opaque V8 string-length failure.

Decode XML entities in a single pass so &#38;lt; stays the literal &lt;,
and raise a descriptive error for code points outside Unicode instead of
a bare RangeError.

Build toObjects() rows with Object.fromEntries so a "__proto__" header
from the file becomes an own property instead of altering the prototype.

Export the new options, limits and types from the package entry point.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsvdZfRkdMH19tw7PyDvRY
Record the findings of the September 2026 review as a tracked backlog:
security fixes and open hardening items, confirmed correctness bugs with
repro and proposed fixes, engineering/tooling improvements, and a
feature roadmap grouped by version.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsvdZfRkdMH19tw7PyDvRY
The linear scanner yields an empty inner string for a self-closing
<v /> (openpyxl writes it for formula cells without a cached value),
which Number() turned into 0 and a shared-string lookup into index 0.
The previous regex simply did not match, so the value was null; restore
that behaviour and cover it with the openpyxl shape.

Also mark the items already resolved by PR #2 in the docs backlog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsvdZfRkdMH19tw7PyDvRY
The "limitation" test from PR #2 documented the pre-fix behaviour where a
__proto__ header dropped its column. toObjects() now builds rows with
Object.fromEntries, so replace it with an assertion of the fixed
behaviour: the key is an own property and the prototype is untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsvdZfRkdMH19tw7PyDvRY
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsvdZfRkdMH19tw7PyDvRY
@4a4c53
4a4c53 merged commit e40e29a into main Sep 6, 2026
5 checks passed
@4a4c53
4a4c53 deleted the claude/project-analysis-improvements-3dokrb branch September 6, 2026 14:27
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.

2 participants