feat(import): harden untrusted spreadsheet boundaries

This commit is contained in:
2026-03-30 08:02:52 +02:00
parent fac8c1c3a5
commit f6daf21983
13 changed files with 561 additions and 76 deletions
@@ -3,6 +3,7 @@
import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
import { assertSpreadsheetFile } from "~/lib/excel.js";
import type { SkillEntry } from "@capakraken/shared";
interface ParsedEntry {
@@ -54,6 +55,7 @@ export function BatchSkillImport() {
);
try {
assertSpreadsheetFile(file, { allowCsv: false, contextLabel: "skill matrix import" });
const buffer = await file.arrayBuffer();
const result = await parseSkillMatrixWorkbook(buffer);
@@ -152,7 +154,7 @@ export function BatchSkillImport() {
</svg>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Click to select multiple .xlsx files</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Name files after resource EID or display name for automatic matching</p>
<input ref={fileRef} type="file" accept=".xlsx,.xls" multiple className="hidden" onChange={handleFiles} />
<input ref={fileRef} type="file" accept=".xlsx" multiple className="hidden" onChange={handleFiles} />
</div>
{/* Summary */}
@@ -269,7 +269,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
event.target.value = "";
if (!isSpreadsheetFile(file)) {
setScopeImportWarnings(["Unsupported file type. Please upload .xlsx, .xls, or .csv."]);
setScopeImportWarnings(["Unsupported file type. Please upload .xlsx or .csv."]);
return;
}
@@ -586,7 +586,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="flex gap-2">
<label className="cursor-pointer rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Import XLSX
<input type="file" accept=".xlsx,.xls,.csv" onChange={handleScopeImport} className="hidden" />
<input type="file" accept=".xlsx,.csv" onChange={handleScopeImport} className="hidden" />
</label>
<button type="button" onClick={() => setScopeItems((current) => [...current, makeScope(current.length + 1)])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Add scope row
@@ -67,8 +67,8 @@ export function ScopeItemEditor({
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<label className="cursor-pointer rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50">
Import scope from XLSX
<input type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={(event) => void handleScopeImport(event)} />
Import scope from spreadsheet
<input type="file" accept=".xlsx,.csv" className="hidden" onChange={(event) => void handleScopeImport(event)} />
</label>
{scopeImportWarnings.length > 0 && (
<div className="text-xs text-amber-700">
@@ -2,7 +2,7 @@
import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSpreadsheet, isSpreadsheetFile } from "~/lib/excel.js";
import { assertSpreadsheetFile, parseSpreadsheet, isSpreadsheetFile } from "~/lib/excel.js";
type ImportStage = "idle" | "preview" | "importing" | "done";
@@ -48,13 +48,14 @@ export function ImportModal({ onClose }: Props) {
setResult(null);
if (!isSpreadsheetFile(file)) {
setFileError("Unsupported file type. Please upload an Excel (.xlsx, .xls) or CSV file.");
setFileError("Unsupported file type. Please upload a .xlsx or .csv file.");
return;
}
setFileName(file.name);
try {
assertSpreadsheetFile(file, { contextLabel: "resource import" });
const parsed = await parseSpreadsheet(file);
setRows(parsed);
setStage("preview");
@@ -111,7 +112,7 @@ export function ImportModal({ onClose }: Props) {
{stage === "idle" && (
<div className="space-y-4">
<p className="text-sm text-gray-600">
Upload an Excel or CSV file to import resources. The first row must contain column headers
Upload a `.xlsx` or CSV file to import resources. The first row must contain column headers
matching the resource fields (e.g.{" "}
<code className="px-1 py-0.5 bg-gray-100 rounded text-xs font-mono">
eid, displayName, email, chapter, lcrCents
@@ -127,13 +128,13 @@ export function ImportModal({ onClose }: Props) {
<svg className="w-10 h-10 text-gray-400 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-sm text-gray-500">Click to select Excel or CSV</p>
<p className="text-xs text-gray-400 mt-1">.xlsx, .xls, .csv supported</p>
<p className="text-sm text-gray-500">Click to select `.xlsx` or CSV</p>
<p className="text-xs text-gray-400 mt-1">.xlsx, .csv supported</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
accept=".xlsx,.csv"
className="hidden"
onChange={handleFileChange}
/>
@@ -3,6 +3,7 @@
import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
import { assertSpreadsheetFile } from "~/lib/excel.js";
import type { SkillEntry } from "@capakraken/shared";
interface Props {
@@ -46,6 +47,7 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
setPreview(null);
try {
assertSpreadsheetFile(file, { allowCsv: false, contextLabel: "skill matrix import" });
const buffer = await file.arrayBuffer();
const parsed = await parseSkillMatrixWorkbook(buffer);
@@ -127,7 +129,7 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
<input
ref={fileRef}
type="file"
accept=".xlsx,.xls"
accept=".xlsx"
className="hidden"
onChange={handleFile}
/>