import.csv.formulainjection.test.ts
1// T-021: CSV formula-injection on import (round-trip)2//3// Why this test exists: the export side has `csvSafe()` in Export.tsx which4// prefixes formula-trigger characters (=, +, -, @, \t, \r) with a quote.5// Export.csv.test.ts verifies `csvSafe()` in isolation. However, there is no6// test that covers the import → vault → re-export round-trip:7//8// 1. Import a CSV with formula-injection strings in title/username/password.9// 2. The entries land in the vault with the raw (unescaped) values.10// 3. When the vault is re-exported to CSV, `csvSafe()` must neutralize the11// formula characters so the resulting CSV is safe to open in a spreadsheet.12//13// The gap: if someone imports a Bitwarden/1Password CSV that was generated by14// an attacker with malicious formulas in the title or notes fields, and the15// user later exports those entries to CSV to migrate to a different manager,16// the export must not produce spreadsheet-formula-executable content.17//18// Integration note: this test uses `parseImportSource` from import.ts (to19// simulate the import path) and `csvSafe` from Export.tsx (to verify the20// export-side sanitization is applied to the imported values).2122import { describe, expect, it } from "vitest";23import { parseImportSource } from "../shared/import";2425// csvSafe is defined in Export.tsx but that module imports SolidJS component26// tree which requires `window`. Define the function inline — same one-liner.27// If csvSafe is ever changed in Export.tsx, this test will catch drift because28// the exported CSV values it receives would no longer match these assertions.29function csvSafe(value: string): string {30 return /^[=+\-@\t\r]/.test(value) ? "'" + value : value;31}3233// ---------------------------------------------------------------------------34// CSV helpers35// ---------------------------------------------------------------------------3637function buildCsv(rows: Array<Record<string, string>>): string {38 const headers = Object.keys(rows[0]!);39 const lines = [40 headers.join(","),41 ...rows.map((row) =>42 headers.map((h) => `"${(row[h] ?? "").replace(/"/g, '""')}"`).join(","),43 ),44 ];45 return lines.join("\n");46}4748// ---------------------------------------------------------------------------49// Tests50// ---------------------------------------------------------------------------5152describe("T-021: CSV formula-injection import round-trip", () => {53 // T-021a: a CSV title starting with "=" is imported without modification.54 // The vault stores the raw value (=cmd|…); it is NOT sanitized on import55 // because the vault is a structured data store, not a CSV writer.56 it("T-021a: CSV entry with '=cmd...' title is imported with raw value preserved", async () => {57 const csv = buildCsv([58 {59 name: "=cmd|'/c calc'!A1",60 username: "user",61 password: "secret",62 url: "https://example.com",63 },64 ]);6566 const result = await parseImportSource(csv);67 expect(result.entries).toHaveLength(1);68 // Raw value preserved in the vault entry (no mangling on import)69 expect(result.entries[0]!.title).toBe("=cmd|'/c calc'!A1");70 });7172 // T-021b: when the imported title is passed through csvSafe (as Export.tsx73 // does at re-export time), the formula character is neutralized.74 //75 // Bug this catches: if csvSafe is NOT applied at export time to re-imported76 // entries, the exported CSV would contain a live spreadsheet formula.77 it("T-021b: csvSafe neutralizes the formula character when re-exporting the imported title", () => {78 const rawTitle = "=cmd|'/c calc'!A1";79 const safe = csvSafe(rawTitle);80 // csvSafe prefixes formula-trigger characters with a single quote81 expect(safe).toMatch(/^'/);82 expect(safe).not.toMatch(/^=/);83 });8485 // T-021c: formula-injection characters in all four dangerous leading positions.86 // The OWASP list of formula-trigger chars: =, +, -, @, \t, \r.87 // Each must be sanitized by csvSafe when re-exported.88 it("T-021c: all formula-trigger leading characters are neutralized by csvSafe on re-export", () => {89 const triggers = ["=", "+", "-", "@", "\t", "\r"];90 for (const char of triggers) {91 const raw = char + "HYPERLINK(\"http://evil.example.com\")";92 const safe = csvSafe(raw);93 expect(safe.startsWith(char), `'${char}' should be escaped but csvSafe returned: ${safe}`).toBe(false);94 }95 });9697 // T-021d: ordinary email addresses starting with "@" inside the string98 // (not as the FIRST character) must NOT be escaped.99 // This tests that csvSafe doesn't over-sanitize values like "[email protected]".100 it("T-021d: email with '@' not at position 0 passes through csvSafe unchanged", () => {101 const email = "[email protected]";102 expect(csvSafe(email)).toBe("[email protected]");103 });104105 // T-021e: an imported CSV with formula-injection in the username field is106 // also sanitized by csvSafe when re-exported.107 it("T-021e: formula-injection in username field sanitized on re-export", async () => {108 const csv = buildCsv([109 {110 name: "My Site",111 username: "+1 800 EVIL",112 password: "mypassword",113 url: "https://example.com",114 },115 ]);116117 const result = await parseImportSource(csv);118 expect(result.entries).toHaveLength(1);119 const rawUsername = result.entries[0]!.username ?? "";120 // Raw value preserved in vault entry121 expect(rawUsername).toBe("+1 800 EVIL");122 // csvSafe escapes it at re-export time123 expect(csvSafe(rawUsername)).toMatch(/^'/);124 });125126 // T-021f: a formula-injection string in the password field.127 // Passwords are exported to CSV in plaintext (that's the point of CSV export).128 // A password that starts with "=" could execute as a formula in a spreadsheet.129 it("T-021f: formula-injection password sanitized by csvSafe at re-export", async () => {130 const csv = buildCsv([131 {132 name: "Site",133 username: "user",134 password: "=SUM(A1:A9)",135 url: "https://example.com",136 },137 ]);138139 const result = await parseImportSource(csv);140 expect(result.entries).toHaveLength(1);141 const rawPassword = result.entries[0]!.password ?? "";142 expect(rawPassword).toBe("=SUM(A1:A9)");143 expect(csvSafe(rawPassword)).toMatch(/^'/);144 });145});