import.csv.formulainjection.test.ts

5.8 KB · raw

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});