import.csv.formulainjection.test.ts

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