""" utils/excel_safety.py ===================== Protection against spreadsheet formula injection in exports. Text that reaches an export can come from the public check-in page (address, device, selected location), from imported time-clock files, or from the legacy database. In an .xlsx file a cell whose value starts with "=" is stored as a formula; in a CSV a value starting with = + - @ tab or CR is evaluated when the file is opened in Excel. These helpers keep such text inert while the formulas the exports generate on purpose (map HYPERLINKs, SUM / SUMIF totals) keep working. """ import re # Formulas the exports generate themselves. Any other cell value starting with # "=" is written as plain text. HYPERLINKs are accepted only with a Google Maps # target and correctly escaped string arguments ("" for a quote), so user text # inside them can never close the string and append another function. _ALLOWED_FORMULA_PATTERNS = ( re.compile( r'^=HYPERLINK\("(?:https://www\.google\.com/maps/place/|http://maps\.google\.com/maps\?q=)' r'(?:[^"]|"")*","(?:[^"]|"")*"\)$' ), re.compile(r'^=SUM\(\$?[A-Z]{1,3}\$?\d+:\$?[A-Z]{1,3}\$?\d+\)$'), re.compile( r'^=SUMIF\(\$?[A-Z]{1,3}\$?\d+:\$?[A-Z]{1,3}\$?\d+,' r'\$?[A-Z]{1,3}\$?\d+,' r'\$?[A-Z]{1,3}\$?\d+:\$?[A-Z]{1,3}\$?\d+\)$' ), ) _CSV_FORMULA_PREFIXES = ('=', '+', '-', '@', '\t', '\r') def excel_escape_string(text): """Escape text for use inside a double-quoted string argument of an Excel formula.""" value = '' if text is None else str(text) return value.replace('"', '""').replace('\r', ' ').replace('\n', ' ') def excel_hyperlink(url, display_text): """=HYPERLINK("url","display text") with both arguments safely quoted.""" return f'=HYPERLINK("{excel_escape_string(url)}","{excel_escape_string(display_text)}")' def is_allowed_formula(value): """True for the formula shapes the exports generate on purpose.""" return any(pattern.match(value) for pattern in _ALLOWED_FORMULA_PATTERNS) def neutralize_unexpected_formulas(workbook): """ Store every formula the app did not generate itself as plain text. Call right before saving an export workbook. Returns the number of cells changed. The cell keeps its visible text; it is just never evaluated. """ changed = 0 for worksheet in workbook.worksheets: for row in worksheet.iter_rows(): for cell in row: value = cell.value if isinstance(value, str) and value.startswith('=') and not is_allowed_formula(value): cell.data_type = 's' # written as a string, not as changed += 1 return changed def csv_safe(value): """A CSV value that Excel will not evaluate as a formula (leading apostrophe).""" if isinstance(value, str) and value.startswith(_CSV_FORMULA_PREFIXES): return "'" + value return value