// T-021: CSV formula-injection on import (round-trip) // // Why this test exists: the export side has `csvSafe()` in Export.tsx which // prefixes formula-trigger characters (=, +, -, @, \t, \r) with a quote. // Export.csv.test.ts verifies `csvSafe()` in isolation. However, there is no // test that covers the import → vault → re-export round-trip: // // 1. Import a CSV with formula-injection strings in title/username/password. // 2. The entries land in the vault with the raw (unescaped) values. // 3. When the vault is re-exported to CSV, `csvSafe()` must neutralize the // formula characters so the resulting CSV is safe to open in a spreadsheet. // // The gap: if someone imports a Bitwarden/1Password CSV that was generated by // an attacker with malicious formulas in the title or notes fields, and the // user later exports those entries to CSV to migrate to a different manager, // the export must not produce spreadsheet-formula-executable content. // // Integration note: this test uses `parseImportSource` from import.ts (to // simulate the import path) and `csvSafe` from Export.tsx (to verify the // export-side sanitization is applied to the imported values). import { describe, expect, it } from "vitest"; import { parseImportSource } from "../shared/import"; // csvSafe is defined in Export.tsx but that module imports SolidJS component // tree which requires `window`. Define the function inline — same one-liner. // If csvSafe is ever changed in Export.tsx, this test will catch drift because // the exported CSV values it receives would no longer match these assertions. function csvSafe(value: string): string { return /^[=+\-@\t\r]/.test(value) ? "'" + value : value; } // --------------------------------------------------------------------------- // CSV helpers // --------------------------------------------------------------------------- function buildCsv(rows: Array>): string { const headers = Object.keys(rows[0]!); const lines = [ headers.join(","), ...rows.map((row) => headers.map((h) => `"${(row[h] ?? "").replace(/"/g, '""')}"`).join(","), ), ]; return lines.join("\n"); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("T-021: CSV formula-injection import round-trip", () => { // T-021a: a CSV title starting with "=" is imported without modification. // The vault stores the raw value (=cmd|…); it is NOT sanitized on import // because the vault is a structured data store, not a CSV writer. it("T-021a: CSV entry with '=cmd...' title is imported with raw value preserved", async () => { const csv = buildCsv([ { name: "=cmd|'/c calc'!A1", username: "user", password: "secret", url: "https://example.com", }, ]); const result = await parseImportSource(csv); expect(result.entries).toHaveLength(1); // Raw value preserved in the vault entry (no mangling on import) expect(result.entries[0]!.title).toBe("=cmd|'/c calc'!A1"); }); // T-021b: when the imported title is passed through csvSafe (as Export.tsx // does at re-export time), the formula character is neutralized. // // Bug this catches: if csvSafe is NOT applied at export time to re-imported // entries, the exported CSV would contain a live spreadsheet formula. it("T-021b: csvSafe neutralizes the formula character when re-exporting the imported title", () => { const rawTitle = "=cmd|'/c calc'!A1"; const safe = csvSafe(rawTitle); // csvSafe prefixes formula-trigger characters with a single quote expect(safe).toMatch(/^'/); expect(safe).not.toMatch(/^=/); }); // T-021c: formula-injection characters in all four dangerous leading positions. // The OWASP list of formula-trigger chars: =, +, -, @, \t, \r. // Each must be sanitized by csvSafe when re-exported. it("T-021c: all formula-trigger leading characters are neutralized by csvSafe on re-export", () => { const triggers = ["=", "+", "-", "@", "\t", "\r"]; for (const char of triggers) { const raw = char + "HYPERLINK(\"http://evil.example.com\")"; const safe = csvSafe(raw); expect(safe.startsWith(char), `'${char}' should be escaped but csvSafe returned: ${safe}`).toBe(false); } }); // T-021d: ordinary email addresses starting with "@" inside the string // (not as the FIRST character) must NOT be escaped. // This tests that csvSafe doesn't over-sanitize values like "user@example.com". it("T-021d: email with '@' not at position 0 passes through csvSafe unchanged", () => { const email = "user@example.com"; expect(csvSafe(email)).toBe("user@example.com"); }); // T-021e: an imported CSV with formula-injection in the username field is // also sanitized by csvSafe when re-exported. it("T-021e: formula-injection in username field sanitized on re-export", async () => { const csv = buildCsv([ { name: "My Site", username: "+1 800 EVIL", password: "mypassword", url: "https://example.com", }, ]); const result = await parseImportSource(csv); expect(result.entries).toHaveLength(1); const rawUsername = result.entries[0]!.username ?? ""; // Raw value preserved in vault entry expect(rawUsername).toBe("+1 800 EVIL"); // csvSafe escapes it at re-export time expect(csvSafe(rawUsername)).toMatch(/^'/); }); // T-021f: a formula-injection string in the password field. // Passwords are exported to CSV in plaintext (that's the point of CSV export). // A password that starts with "=" could execute as a formula in a spreadsheet. it("T-021f: formula-injection password sanitized by csvSafe at re-export", async () => { const csv = buildCsv([ { name: "Site", username: "user", password: "=SUM(A1:A9)", url: "https://example.com", }, ]); const result = await parseImportSource(csv); expect(result.entries).toHaveLength(1); const rawPassword = result.entries[0]!.password ?? ""; expect(rawPassword).toBe("=SUM(A1:A9)"); expect(csvSafe(rawPassword)).toMatch(/^'/); }); });