Skip to content

Commit 38447ef

Browse files
steebchenclaude
andauthored
chore(security): replace SheetJS xlsx with exceljs (#2945)
## Summary Resolves the only outstanding **high/critical** Dependabot security alerts in the repository, both against the SheetJS `xlsx` package: | Advisory | Severity | Issue | | --- | --- | --- | | [GHSA-4r6h-8v6p-xvw6](GHSA-4r6h-8v6p-xvw6) | High (CVSS 7.8) | Prototype Pollution in SheetJS | | [GHSA-5pgg-2g8v-p4x9](GHSA-5pgg-2g8v-p4x9) | High (CVSS 7.5) | SheetJS Regular Expression Denial of Service (ReDoS) | ### Why a version bump can't fix this Both advisories are recorded with `introduced: 0` and **no fixed version** in the npm ecosystem. SheetJS stopped publishing to the npm registry after `0.18.5`; patched builds live only on the SheetJS CDN. The repo already pinned the CDN-hosted, patched `xlsx@0.20.3`, but Dependabot/GHSA still flag it because there is no registry release that clears the advisory range. The only durable remediation is to stop depending on `xlsx`. ### Change - **`apps/api/src/lib/file-extract.ts`** — migrate knowledge-base spreadsheet extraction from `xlsx` to the actively-maintained [`exceljs`](https://www.npmjs.com/package/exceljs) (no open high/critical advisories). Each worksheet is converted to CSV with standard field quoting (commas, quotes, and newlines are escaped). - **`apps/api/package.json` / `pnpm-lock.yaml`** — drop `xlsx`, add `exceljs@4.4.0`. - **`apps/playground/.../projects-page-client.tsx`** — stop advertising legacy binary `.xls` in the upload picker. - **`file-extract.spec.ts`** — build fixtures with `exceljs`; add a CSV-quoting case and a case asserting unreadable spreadsheet data is rejected. ### Behavior note `exceljs` reads modern `.xlsx` but cannot parse the legacy binary `.xls` (BIFF) format. `.xlsx` extraction is unchanged. Legacy `.xls` uploads (rare; Excel has defaulted to `.xlsx` since 2007) are no longer advertised in the picker and are rejected cleanly by the existing upload error path rather than being indexed as garbage. ## Verification - `osv-scanner` on the updated lockfile: **0 high/critical** remaining (previously 2). - `pnpm format` — clean - `turbo run build` (full monorepo) — passes - `apps/api` unit tests (`file-extract.spec.ts`) — 6/6 pass; `tsc --noEmit` clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_015JboNg5ryVipY8M6nzJykR)_ <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Improved spreadsheet upload handling for modern `.xlsx` files, including workbooks with multiple sheets (combined output with sheet names). * **Bug Fixes** * CSV generation now properly escapes fields (commas, quotes, and newlines). * Invalid/unreadable spreadsheet inputs are rejected instead of returning incorrect results. * **Chores** * Updated the supported upload formats by removing legacy `.xls` from the upload flow. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude <noreply@anthropic.com>
1 parent af9cf38 commit 38447ef

5 files changed

Lines changed: 566 additions & 118 deletions

File tree

apps/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"disposable-email-domains-js": "1.20.0",
4242
"dotenv": "17.4.2",
4343
"drizzle-zod": "0.8.3",
44+
"exceljs": "4.4.0",
4445
"gateway": "workspace:*",
4546
"hono": "^4.12.25",
4647
"hono-openapi": "^1.1.1",
@@ -52,7 +53,6 @@
5253
"resend": "6.8.0",
5354
"stripe": "18.1.1",
5455
"unpdf": "1.6.2",
55-
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
5656
"zod": "3.25.75",
5757
"zod-openapi": "^5.4.3"
5858
}

apps/api/src/lib/file-extract.spec.ts

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
1+
import ExcelJS from "exceljs";
12
import { describe, expect, it } from "vitest";
2-
import * as XLSX from "xlsx";
33

44
import { extractFileText } from "./file-extract.js";
55

6+
async function buildXlsx(
7+
sheetName: string,
8+
rows: (string | number)[][],
9+
): Promise<Buffer> {
10+
const workbook = new ExcelJS.Workbook();
11+
const sheet = workbook.addWorksheet(sheetName);
12+
sheet.addRows(rows);
13+
return Buffer.from(await workbook.xlsx.writeBuffer());
14+
}
15+
616
describe("extractFileText", () => {
717
it("passes plain text through as UTF-8", async () => {
818
const text = await extractFileText(
@@ -14,16 +24,10 @@ describe("extractFileText", () => {
1424
});
1525

1626
it("converts spreadsheet sheets to CSV with sheet headers", async () => {
17-
const workbook = XLSX.utils.book_new();
18-
const sheet = XLSX.utils.aoa_to_sheet([
27+
const buffer = await buildXlsx("Team", [
1928
["name", "role"],
2029
["Ada", "engineer"],
2130
]);
22-
XLSX.utils.book_append_sheet(workbook, sheet, "Team");
23-
const buffer = XLSX.write(workbook, {
24-
type: "buffer",
25-
bookType: "xlsx",
26-
}) as Buffer;
2731

2832
const text = await extractFileText(
2933
"team.xlsx",
@@ -35,25 +39,28 @@ describe("extractFileText", () => {
3539
expect(text).toContain("Ada,engineer");
3640
});
3741

38-
it("converts legacy .xls workbooks to CSV", async () => {
39-
const workbook = XLSX.utils.book_new();
40-
const sheet = XLSX.utils.aoa_to_sheet([
42+
it("quotes CSV fields containing commas", async () => {
43+
const buffer = await buildXlsx("Places", [
4144
["city", "country"],
42-
["Lund", "Sweden"],
45+
["Lund, Skåne", "Sweden"],
4346
]);
44-
XLSX.utils.book_append_sheet(workbook, sheet, "Places");
45-
const buffer = XLSX.write(workbook, {
46-
type: "buffer",
47-
bookType: "xls",
48-
}) as Buffer;
4947

5048
const text = await extractFileText(
51-
"places.xls",
52-
"application/vnd.ms-excel",
49+
"places.xlsx",
50+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
5351
buffer,
5452
);
55-
expect(text).toContain("# Places");
56-
expect(text).toContain("Lund,Sweden");
53+
expect(text).toContain('"Lund, Skåne",Sweden');
54+
});
55+
56+
it("rejects unreadable spreadsheet data", async () => {
57+
await expect(
58+
extractFileText(
59+
"legacy.xls",
60+
"application/vnd.ms-excel",
61+
Buffer.from("not a real spreadsheet"),
62+
),
63+
).rejects.toThrow();
5764
});
5865

5966
it("rejects buffers over the extraction size cap", async () => {

apps/api/src/lib/file-extract.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import ExcelJS from "exceljs";
12
import { extractText, getDocumentProxy } from "unpdf";
2-
import * as XLSX from "xlsx";
33

44
// Defensive cap mirroring the upload route's base64 limit, so the extractor
55
// stays safe even if called from a new code path without an upstream guard.
@@ -20,6 +20,28 @@ function isSpreadsheet(name: string, mimeType: string) {
2020
);
2121
}
2222

23+
// Quote a CSV field when it contains a delimiter, quote, or newline, doubling
24+
// any embedded quotes — matching standard CSV escaping.
25+
function csvEscape(value: string) {
26+
if (/[",\n\r]/.test(value)) {
27+
return `"${value.replace(/"/g, '""')}"`;
28+
}
29+
return value;
30+
}
31+
32+
function sheetToCsv(worksheet: ExcelJS.Worksheet) {
33+
const columnCount = worksheet.columnCount;
34+
const lines: string[] = [];
35+
worksheet.eachRow({ includeEmpty: true }, (row) => {
36+
const cells: string[] = [];
37+
for (let col = 1; col <= columnCount; col++) {
38+
cells.push(csvEscape(row.getCell(col).text ?? ""));
39+
}
40+
lines.push(cells.join(","));
41+
});
42+
return lines.join("\n");
43+
}
44+
2345
// Extract plain text from an uploaded knowledge base file. PDFs are parsed
2446
// with unpdf (pdf.js), spreadsheets are converted sheet-by-sheet to CSV, and
2547
// anything else is treated as UTF-8 text.
@@ -39,11 +61,14 @@ export async function extractFileText(
3961
}
4062

4163
if (isSpreadsheet(name, mimeType)) {
42-
const workbook = XLSX.read(buffer, { type: "buffer" });
43-
return workbook.SheetNames.map((sheetName) => {
44-
const csv = XLSX.utils.sheet_to_csv(workbook.Sheets[sheetName]);
45-
return `# ${sheetName}\n${csv}`;
46-
}).join("\n\n");
64+
const workbook = new ExcelJS.Workbook();
65+
// exceljs's bundled types declare `Buffer extends ArrayBuffer`, which is
66+
// incompatible with Node's Buffer type; the loader accepts a Node Buffer
67+
// at runtime, so cast for the type checker only.
68+
await workbook.xlsx.load(buffer as unknown as ArrayBuffer);
69+
return workbook.worksheets
70+
.map((worksheet) => `# ${worksheet.name}\n${sheetToCsv(worksheet)}`)
71+
.join("\n\n");
4772
}
4873

4974
return buffer.toString("utf8");

apps/playground/src/components/playground/projects-page-client.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ import type { Organization } from "@/lib/types";
6767
// Text-based formats are read client-side; PDF and Excel files are sent as
6868
// base64 and their text is extracted server-side.
6969
const ACCEPTED_FILE_EXTENSIONS =
70-
".txt,.md,.markdown,.mdx,.csv,.tsv,.json,.yaml,.yml,.xml,.html,.log,.js,.jsx,.ts,.tsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.pdf,.xlsx,.xls";
70+
".txt,.md,.markdown,.mdx,.csv,.tsv,.json,.yaml,.yml,.xml,.html,.log,.js,.jsx,.ts,.tsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.pdf,.xlsx";
7171

72-
const BINARY_FILE_EXTENSIONS = [".pdf", ".xlsx", ".xls"];
72+
const BINARY_FILE_EXTENSIONS = [".pdf", ".xlsx"];
7373

7474
function isBinaryKnowledgeFile(name: string) {
7575
const lower = name.toLowerCase();

0 commit comments

Comments
 (0)