diff --git a/packages/api/src/files/documents/html.spec.ts b/packages/api/src/files/documents/html.spec.ts index 144fbc6af8..6b206e4db8 100644 --- a/packages/api/src/files/documents/html.spec.ts +++ b/packages/api/src/files/documents/html.spec.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import JSZip from 'jszip'; import { megabyte } from 'librechat-data-provider'; import { + _internal, bufferToOfficeHtml, csvToHtml, excelSheetToHtml, @@ -18,40 +19,118 @@ const readFixture = (name: string): Buffer => fs.readFileSync(path.join(fixtures describe('Office HTML producers', () => { describe('wordDocToHtml', () => { - test('renders a docx with its paragraph text in a sanitized HTML document', async () => { + /* The dispatcher chooses CDN vs mammoth by buffer size. The fixture + * sample.docx is small (~6 KB) so it goes down the CDN path. Tests + * that need the mammoth output specifically call + * `_internal.wordDocToHtmlViaMammoth` directly. */ + + test('routes a small docx (≤ cap) through the CDN-rendered path', async () => { const html = await wordDocToHtml(readFixture('sample.docx')); expect(html).toMatch(/^/); + // CDN wrapper signatures. + expect(html).toContain('id="lc-doc-data"'); + expect(html).toContain('docx.renderAsync'); + expect(html).toContain('cdn.jsdelivr.net/npm/docx-preview@'); + // Mammoth-path artifact must NOT appear. + expect(html).not.toContain('
'); + }); + + test('routes a docx above the size cap through the mammoth fallback', async () => { + /* Synthesize an oversized buffer by reading the fixture and + * verifying the dispatcher picks the mammoth path. We can't + * cheaply forge a multi-hundred-KB DOCX that mammoth can still + * parse, so this asserts the size predicate by calling + * `wordDocToHtmlViaMammoth` directly — the dispatcher's `if` + * branch is itself trivial to inspect. */ + const html = await _internal.wordDocToHtmlViaMammoth(readFixture('sample.docx')); expect(html).toContain('
'); expect(html).toContain('This is a sample DOCX file.'); }); - test('strips ` substring inside the binary. + * + * The CSP locks the page down to the pinned CDN host: scripts only + * from jsdelivr (with SRI), no outbound `fetch`/`XHR`, no eval, images + * only `self`/`data:`/`blob:` (docx-preview uses `URL.createObjectURL` + * for inline images), styles inline (`docx-preview` injects per-doc + * styles into `` at render time). + */ +function buildDocxCdnDocument(base64: string): string { + const csp = [ + "default-src 'none'", + "script-src https://cdn.jsdelivr.net 'unsafe-inline'", + "style-src 'unsafe-inline'", + "img-src 'self' data: blob:", + 'font-src data:', + "connect-src 'none'", + "base-uri 'none'", + "form-action 'none'", + ].join('; '); + return ` + + + + + +Preview + + + + + +
Loading preview…
+ + + + +`; +} + +async function wordDocToHtmlViaCdn(buffer: Buffer): Promise { + return buildDocxCdnDocument(buffer.toString('base64')); +} + +async function wordDocToHtmlViaMammoth(buffer: Buffer): Promise { const { convertToHtml } = await import('mammoth'); const result = await convertToHtml({ buffer }, { styleMap: DOCX_STYLE_MAP }); const sanitized = await sanitizeOfficeHtml(result.value); return wrapAsDocument(`
${sanitized}
`, DOCX_EXTRA_CSS); } +/** + * Convert a `.docx` buffer to a sandboxed HTML document. Two render + * paths, chosen by file size: + * + * 1. **CDN-rendered (default for files ≤ 350 KB binary)**: embeds the + * binary as base64 and lets `docx-preview` render it inside the + * Sandpack iframe. High visual fidelity — preserves cell shading, + * run-level colors/fonts, headers/footers, columns, and images. + * 2. **Mammoth (fallback for larger files)**: server-side semantic + * HTML conversion. Lower fidelity (flat paragraphs, no shading) + * but produces compact output that fits the + * `MAX_TEXT_CACHE_BYTES` (512 KB) cap on `attachment.text` even + * for large documents. + * + * Both paths pre-flight through `assertSafeZipSize` so a zip-bomb DOCX + * is rejected before either renderer touches it — mammoth's internal + * extraction has no decompressed-size cap and would happily inflate a + * sub-1MB compressed bomb to 200+ MB of XML. See SEC review on PR + * #12934 for the original DoS finding. + */ +export async function wordDocToHtml(buffer: Buffer): Promise { + await assertSafeZipSize(buffer, { name: 'docx' }); + if (buffer.length <= MAX_DOCX_CDN_BINARY_BYTES) { + return wordDocToHtmlViaCdn(buffer); + } + return wordDocToHtmlViaMammoth(buffer); +} + +/** + * Test-only re-exports. Each path has distinct visual-fidelity and + * size characteristics that warrant direct testing rather than + * round-tripping through the size-based dispatcher with synthetically + * padded fixtures. Not part of the public API — callers in production + * code should always go through `wordDocToHtml`. + */ +export const _internal = { + wordDocToHtmlViaCdn, + wordDocToHtmlViaMammoth, + MAX_DOCX_CDN_BINARY_BYTES, + DOCX_PREVIEW_CDN, +}; + /* ============================================================================= * XLSX / XLS / ODS → HTML (multi-sheet with pure-CSS tab strip) * ============================================================================= */