From 201c170ff736a1a95aa904cf897b3b5c1f222946 Mon Sep 17 00:00:00 2001 From: brycejohnson1417 Date: Tue, 29 Sep 2026 13:51:40 -0400 Subject: [PATCH 1/2] docs: claim spreadsheet security cleanup scope --- SESSION.md | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/SESSION.md b/SESSION.md index 67dc371..070beef 100644 --- a/SESSION.md +++ b/SESSION.md @@ -1,18 +1,9 @@ -# Issue 90: production authentication must fail closed - -Issue: https://github.com/brycejohnson1417/Picc-web-app/issues/90 -Branch: codex/90-production-auth, based on origin/main 28f021f. - -Scope: prevent production demo/admin bypass, reject invalid production Clerk configuration consistently at middleware/API boundaries, retain explicit local demo mode. -Out of scope: OAuth permissions, provider replacement, membership changes, production environment edits, schema/data writes, UI redesign. - -Owned paths: lib/config/runtime.ts, lib/config/runtime.test.ts, middleware.ts, lib/auth/middleware.test.ts, lib/auth/api-guard.ts, lib/auth/api-guard.test.ts, SESSION.md. -Open PRs checked: 189, 182, 166, 144, 135, 82. No source overlap with focused runtime/API guard changes. SESSION.md is a stale shared handoff on older PRs and is not product source. PR 144 proposes incompatible retired architecture and must not land over this work. - -Plan: RED tests for production/test/missing/live keys and explicit demo flags; GREEN shared runtime gate and middleware/API behavior; verify normal authenticated protection and independent cron/webhook boundaries. Run npm run verify and a real-browser production misconfiguration check. Existing baseline: 50 files / 215 tests passed during the same-session audit at 28f021f. - -Architecture: retain Clerk and existing server boundaries. No auth provider migration. Approval-lane work: do not merge before explicit approval on the PR. - -Validation completed: RED 14 failures / 2 passes in focused auth suite; GREEN 16/16 focused tests. Full npm run verify passed lint, typecheck, 52 test files / 230 tests, Prisma validation, and production build. Local production build with test keys and DEMO_MODE=true returned 503/no-store for territory, home, sign-in, and API routes; installed Playwright verified the browser body. Screenshot is retained in the private audit folder. Current production configuration was read-only checked: live Clerk keys, demo mode disabled. No production environment or auth changes applied. - -PR review follow-up: include / and /sso-callback(.*) in the fail-closed entry-route matcher. Added three regressions that first failed with HTTP 200, then passed with non-cacheable 503. Full verification with Node 22 and patched Next 15.5.26 passed (53 files, 235 tests, lint, typecheck, Prisma validation, production build). User approved authentication merge in the cleanup task. +# Issue 107: Spreadsheet parser hardening + +Issue: https://github.com/brycejohnson1417/Picc-web-app/issues/107 +Branch: codex/107-spreadsheet-hardening, from main 4fde001. +Scope: replace the vulnerable registry SheetJS version with the official fixed distribution; bound spreadsheet inputs and retain required workbook/CSV behavior. +Owned: package.json, package-lock.json, lib/integrations/sheets.ts, lib/integrations/spreadsheet-input.ts and tests, lib/server/preferred-partner-proposal.ts, SESSION.md. +Out of scope: Prisma tooling, provider changes, schema, production data, UI redesign. +Overlap checked: #195/#194/#189/#182/#166/#135/#82. No source overlap with current changes except SESSION; inspect #135 proposal overlap before implementation and sequence/rebase as needed. Dependency updates exclusive. +Validation: RED malformed/oversized input tests first; GREEN representative workbook/CSV tests; clean npm ci, npm audit, npm run verify. Existing SheetJS API adapter retained; no new parser abstraction beyond input validation. From 125cf0238258456ce061e1c62a68ef25a3d8becc Mon Sep 17 00:00:00 2001 From: brycejohnson1417 Date: Tue, 29 Sep 2026 13:58:08 -0400 Subject: [PATCH 2/2] fix: use patched SheetJS and bound spreadsheet parsing --- lib/integrations/sheets.test.ts | 32 +++++++ lib/integrations/sheets.ts | 28 +++++- lib/integrations/spreadsheet-input.test.ts | 22 +++++ lib/integrations/spreadsheet-input.ts | 51 +++++++++++ lib/server/preferred-partner-proposal.ts | 32 +------ package-lock.json | 100 ++------------------- package.json | 2 +- 7 files changed, 140 insertions(+), 127 deletions(-) create mode 100644 lib/integrations/sheets.test.ts create mode 100644 lib/integrations/spreadsheet-input.test.ts create mode 100644 lib/integrations/spreadsheet-input.ts diff --git a/lib/integrations/sheets.test.ts b/lib/integrations/sheets.test.ts new file mode 100644 index 0000000..47fe285 --- /dev/null +++ b/lib/integrations/sheets.test.ts @@ -0,0 +1,32 @@ +import { afterEach, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as XLSX from 'xlsx'; +import { inspectNabisWorkbook } from './sheets'; + +const folders: string[] = []; +function file(name: string, content: string | Buffer) { + const folder = mkdtempSync(join(tmpdir(), 'picc-sheet-test-')); + folders.push(folder); + const path = join(folder, name); + writeFileSync(path, content); + return path; +} +afterEach(() => folders.splice(0).forEach((folder) => rmSync(folder, { recursive: true }))); +it('rejects unsupported file types before parsing', () => { + expect(() => inspectNabisWorkbook(file('report.html', '
Accounts
'))).toThrow('file type'); +}); +it('rejects an oversized workbook before parsing', () => { + expect(() => inspectNabisWorkbook(file('report.xlsx', Buffer.alloc(10 * 1024 * 1024 + 1)))).toThrow('10 MB'); +}); +it('rejects text disguised as a workbook', () => { + expect(() => inspectNabisWorkbook(file('report.xlsx', 'Account,Value\nExample,12'))).toThrow('valid Excel'); +}); +it('preserves workbook tab coverage and row samples', () => { + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([['Name','Value'],['Example',12]]), 'orders'); + const result = inspectNabisWorkbook(file('report.xlsx', XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }))); + expect(result.tabs).toEqual(['orders']); + expect(result.sample).toContainEqual({ tab:'orders', header:['Name','Value'], firstDataRow:['Example',12], rowCount:2 }); +}); diff --git a/lib/integrations/sheets.ts b/lib/integrations/sheets.ts index ac09f3e..fab7932 100644 --- a/lib/integrations/sheets.ts +++ b/lib/integrations/sheets.ts @@ -1,8 +1,34 @@ +import { readFileSync, statSync } from 'node:fs'; +import { extname } from 'node:path'; +import { MAX_WORKBOOK_BYTES, MAX_SHEET_ROWS } from './spreadsheet-input'; import * as XLSX from 'xlsx'; import { NABIS_SCHEMA_MAPPING, REQUIRED_NABIS_TABS } from '@/lib/data/sheets-schema'; export function inspectNabisWorkbook(path: string) { - const workbook = XLSX.readFile(path, { cellDates: true }); + const extension = extname(path).toLowerCase(); + if (!['.xlsx', '.xls', '.csv'].includes(extension)) { + throw new Error('Unsupported spreadsheet file type. Use XLSX, XLS, or CSV.'); + } + const file = statSync(path); + if (!file.isFile() || file.size > MAX_WORKBOOK_BYTES) { + throw new Error('Workbook must be a regular file of at most 10 MB.'); + } + const bytes = readFileSync(path); + const zip = bytes.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04])); + const ole = bytes.subarray(0, 8).equals(Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])); + if ((extension === '.xlsx' && !zip) || (extension === '.xls' && !ole)) { + throw new Error('File is not a valid Excel workbook.'); + } + const workbook = XLSX.read(bytes, { type: 'buffer', cellDates: true, sheetRows: MAX_SHEET_ROWS + 1 }); + for (const sheet of Object.values(workbook.Sheets)) { + const range = sheet['!fullref'] || sheet['!ref']; + if (range && XLSX.utils.decode_range(range).e.c >= 256) { + throw new Error('Workbook exceeds the 256 column limit per sheet.'); + } + if (range && XLSX.utils.decode_range(range).e.r >= MAX_SHEET_ROWS) { + throw new Error('Workbook exceeds the 50,000 row limit per sheet.'); + } + } const tabs = workbook.SheetNames; const requiredCoverage = REQUIRED_NABIS_TABS.map((tab) => ({ diff --git a/lib/integrations/spreadsheet-input.test.ts b/lib/integrations/spreadsheet-input.test.ts new file mode 100644 index 0000000..f001e48 --- /dev/null +++ b/lib/integrations/spreadsheet-input.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from 'vitest'; +import { requireReportSize, parseReportRows } from './spreadsheet-input'; + +it('accepts a normal CSV with quoted commas and Unicode', () => { + expect(() => requireReportSize('Name,Price\n"Café, large",12')).not.toThrow(); +}); +it('rejects oversized text by bytes rather than character count', () => { + expect(() => requireReportSize('é'.repeat(1024 * 1024 + 1))).toThrow('2 MB'); +}); + +it('preserves quoted commas, Unicode, leading zeros, and prices in CSV reports', () => { + expect(parseReportRows('Name,SKU,Price\n"Café, large",0012,12.50')).toEqual({ + format: 'csv', rows: [{ Name: 'Café, large', SKU: '0012', Price: '12.50' }], + }); +}); +it('retains JSON rows and rejects malformed JSON', () => { + expect(parseReportRows('[{"Name":"Example","Price":12}]').rows).toEqual([{Name:'Example',Price:12}]); + expect(() => parseReportRows('[broken')).toThrow(); +}); +it('rejects excessive columns without returning a truncated report', () => { + expect(() => parseReportRows(Array.from({length:257}, (_,i) => `column${i}`).join(','))).toThrow('256 column'); +}); diff --git a/lib/integrations/spreadsheet-input.ts b/lib/integrations/spreadsheet-input.ts new file mode 100644 index 0000000..31ff2ee --- /dev/null +++ b/lib/integrations/spreadsheet-input.ts @@ -0,0 +1,51 @@ +import * as XLSX from 'xlsx'; + +export const MAX_WORKBOOK_BYTES = 10 * 1024 * 1024; +export const MAX_REPORT_BYTES = 2 * 1024 * 1024; +export const MAX_SHEET_ROWS = 50_000; + +export function requireReportSize(input: string) { + if (Buffer.byteLength(input, 'utf8') > MAX_REPORT_BYTES) { + const error = new Error('Report exceeds the 2 MB limit. Split the export into smaller reports.'); + Object.assign(error, { statusCode: 400 }); + throw error; + } +} + +export function parseReportRows(rawInput: string) { + requireReportSize(rawInput); + const trimmed = rawInput.trim(); + if (!trimmed) { + return { format: 'json' as const, rows: [] as Record[] }; + } + + if (trimmed.startsWith('[') || trimmed.startsWith('{')) { + const parsed = JSON.parse(trimmed) as unknown; + if (Array.isArray(parsed)) { + return { + format: 'json' as const, + rows: parsed.filter((row): row is Record => Boolean(row) && typeof row === 'object'), + }; + } + throw new Error('Expected a JSON array of Headset rows.'); + } + + const workbook = XLSX.read(trimmed, { type: 'string', raw: false, sheetRows: MAX_SHEET_ROWS + 1 }); + const firstSheetName = workbook.SheetNames[0]; + if (!firstSheetName) { + return { format: 'csv' as const, rows: [] as Record[] }; + } + const sheet = workbook.Sheets[firstSheetName]; + const range = sheet['!fullref'] || sheet['!ref']; + if (range && XLSX.utils.decode_range(range).e.c >= 256) { + throw Object.assign(new Error('Report exceeds the 256 column limit.'), { statusCode: 400 }); + } + if (range && XLSX.utils.decode_range(range).e.r >= MAX_SHEET_ROWS) { + throw Object.assign(new Error('Report exceeds the 50,000 row limit.'), { statusCode: 400 }); + } + return { + format: 'csv' as const, + rows: XLSX.utils.sheet_to_json>(sheet, { defval: null, raw: false }), + }; +} + diff --git a/lib/server/preferred-partner-proposal.ts b/lib/server/preferred-partner-proposal.ts index 0ab33f5..37034b5 100644 --- a/lib/server/preferred-partner-proposal.ts +++ b/lib/server/preferred-partner-proposal.ts @@ -1,6 +1,6 @@ +import { parseReportRows } from '@/lib/integrations/spreadsheet-input'; import 'server-only'; -import * as XLSX from 'xlsx'; import { resolveAccountIdentity } from '@/lib/server/account-identity'; import { loadNabisDaysOffRows, loadNyInventoryRows, loadNyWarehouseRows } from '@/lib/server/nabis-api'; import { loadTerritoryStoreDetail } from '@/lib/server/notion-territory'; @@ -375,34 +375,6 @@ function calculateEarliestDeliveryDate(generatedAt: Date, daysOffRows: Array Boolean(row) && typeof row === 'object'), - }; - } - throw new Error('Expected a JSON array of Headset rows.'); - } - - const workbook = XLSX.read(trimmed, { type: 'string', raw: false }); - const firstSheetName = workbook.SheetNames[0]; - if (!firstSheetName) { - return { format: 'csv' as const, rows: [] as InputRow[] }; - } - const sheet = workbook.Sheets[firstSheetName]; - return { - format: 'csv' as const, - rows: XLSX.utils.sheet_to_json(sheet, { defval: null, raw: false }), - }; -} function normalizeHeadsetRows(rawRows: InputRow[]) { return rawRows @@ -1143,7 +1115,7 @@ export async function getPreferredPartnerProposal(input: { accountIdOrPageId: string; rawReport: string; }) { - const { format, rows: rawRows } = parseInputRows(input.rawReport); + const { format, rows: rawRows } = parseReportRows(input.rawReport); const parsedRows = normalizeHeadsetRows(rawRows); if (parsedRows.length === 0) { const error = new Error('Paste a Headset JSON array or CSV export before generating a PPP proposal.'); diff --git a/package-lock.json b/package-lock.json index 8200cc4..a5a8d80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "sharp": "^0.35.5", "sonner": "^1.7.2", "tailwind-merge": "^2.6.0", - "xlsx": "^0.18.5", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^3.24.1" }, "devDependencies": { @@ -4861,15 +4861,6 @@ "node": ">=0.4.0" } }, - "node_modules/adler-32": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", - "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -5324,19 +5315,6 @@ "node": ">=10.0.0" } }, - "node_modules/cfb": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", - "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "crc-32": "~1.2.0" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -5433,15 +5411,6 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/codepage": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", - "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5515,18 +5484,6 @@ "url": "https://opencollective.com/core-js" } }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6817,15 +6774,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/frac": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", - "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -8778,6 +8726,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -9625,18 +9574,6 @@ "node": ">=0.10.0" } }, - "node_modules/ssf": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", - "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", - "license": "Apache-2.0", - "dependencies": { - "frac": "~1.1.2" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -11463,24 +11400,6 @@ "node": ">=8" } }, - "node_modules/wmf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", - "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/word": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", - "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -11514,19 +11433,10 @@ } }, "node_modules/xlsx": { - "version": "0.18.5", - "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", - "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "version": "0.20.3", + "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==", "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "cfb": "~1.2.1", - "codepage": "~1.15.0", - "crc-32": "~1.2.1", - "ssf": "~0.11.2", - "wmf": "~1.0.1", - "word": "~0.3.0" - }, "bin": { "xlsx": "bin/xlsx.njs" }, diff --git a/package.json b/package.json index b8eb0fb..25fd0ae 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "sharp": "^0.35.5", "sonner": "^1.7.2", "tailwind-merge": "^2.6.0", - "xlsx": "^0.18.5", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^3.24.1" }, "devDependencies": {