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";24import { csvSafe } from "../../popup/views/Export";2526// ---------------------------------------------------------------------------27// CSV helpers28// ---------------------------------------------------------------------------2930function buildCsv(rows: Array<Record<string, string>>): string {31 const headers = Object.keys(rows[0]!);32 const lines = [33 headers.join(","),34 ...rows.map((row) =>35 headers.map((h) => `"${(row[h] ?? "").replace(/"/g, '""')}"`).join(","),36 ),37 ];38 return lines.join("\n");39}4041// ---------------------------------------------------------------------------42// Tests43// ---------------------------------------------------------------------------4445describe("T-021: CSV formula-injection import round-trip", () => {46 // T-021a: a CSV title starting with "=" is imported without modification.47 // The vault stores the raw value (=cmd|…); it is NOT sanitized on import48 // because the vault is a structured data store, not a CSV writer.49 it("T-021a: CSV entry with '=cmd...' title is imported with raw value preserved", async () => {50 const csv = buildCsv([51 {52 name: "=cmd|'/c calc'!A1",53 username: "user",54 password: "secret",55 url: "https://example.com",56 },57 ]);5859 const result = await parseImportSource(csv);60 expect(result.entries).toHaveLength(1);61 // Raw value preserved in the vault entry (no mangling on import)62 expect(result.entries[0]!.title).toBe("=cmd|'/c calc'!A1");63 });6465 // T-021b: when the imported title is passed through csvSafe (as Export.tsx66 // does at re-export time), the formula character is neutralized.67 //68 // Bug this catches: if csvSafe is NOT applied at export time to re-imported69 // entries, the exported CSV would contain a live spreadsheet formula.70 it("T-021b: csvSafe neutralizes the formula character when re-exporting the imported title", () => {71 const rawTitle = "=cmd|'/c calc'!A1";72 const safe = csvSafe(rawTitle);73 // csvSafe prefixes formula-trigger characters with a single quote74 expect(safe).toMatch(/^'/);75 expect(safe).not.toMatch(/^=/);76 });7778 // T-021c: formula-injection characters in all four dangerous leading positions.79 // The OWASP list of formula-trigger chars: =, +, -, @, \t, \r.80 // Each must be sanitized by csvSafe when re-exported.81 it("T-021c: all formula-trigger leading characters are neutralized by csvSafe on re-export", () => {82 const triggers = ["=", "+", "-", "@", "\t", "\r"];83 for (const char of triggers) {84 const raw = char + "HYPERLINK(\"http://evil.example.com\")";85 const safe = csvSafe(raw);86 expect(safe.startsWith(char), `'${char}' should be escaped but csvSafe returned: ${safe}`).toBe(false);87 }88 });8990 // T-021d: ordinary email addresses starting with "@" inside the string91 // (not as the FIRST character) must NOT be escaped.92 // This tests that csvSafe doesn't over-sanitize values like "[email protected]".93 it("T-021d: email with '@' not at position 0 passes through csvSafe unchanged", () => {94 const email = "[email protected]";95 expect(csvSafe(email)).toBe("[email protected]");96 });9798 // T-021e: an imported CSV with formula-injection in the username field is99 // also sanitized by csvSafe when re-exported.100 it("T-021e: formula-injection in username field sanitized on re-export", async () => {101 const csv = buildCsv([102 {103 name: "My Site",104 username: "+1 800 EVIL",105 password: "mypassword",106 url: "https://example.com",107 },108 ]);109110 const result = await parseImportSource(csv);111 expect(result.entries).toHaveLength(1);112 const rawUsername = result.entries[0]!.username ?? "";113 // Raw value preserved in vault entry114 expect(rawUsername).toBe("+1 800 EVIL");115 // csvSafe escapes it at re-export time116 expect(csvSafe(rawUsername)).toMatch(/^'/);117 });118119 // T-021f: a formula-injection string in the password field.120 // Passwords are exported to CSV in plaintext (that's the point of CSV export).121 // A password that starts with "=" could execute as a formula in a spreadsheet.122 it("T-021f: formula-injection password sanitized by csvSafe at re-export", async () => {123 const csv = buildCsv([124 {125 name: "Site",126 username: "user",127 password: "=SUM(A1:A9)",128 url: "https://example.com",129 },130 ]);131132 const result = await parseImportSource(csv);133 expect(result.entries).toHaveLength(1);134 const rawPassword = result.entries[0]!.password ?? "";135 expect(rawPassword).toBe("=SUM(A1:A9)");136 expect(csvSafe(rawPassword)).toMatch(/^'/);137 });138});