diff --git a/apps/api/plane/app/views/workspace/base.py b/apps/api/plane/app/views/workspace/base.py index b9c7e79fdc0..be43eace2f6 100644 --- a/apps/api/plane/app/views/workspace/base.py +++ b/apps/api/plane/app/views/workspace/base.py @@ -49,6 +49,7 @@ from plane.bgtasks.event_tracking_task import track_event from plane.utils.url import contains_url from plane.utils.analytics_events import WORKSPACE_CREATED, WORKSPACE_DELETED +from plane.utils.csv_utils import sanitize_csv_row class WorkSpaceViewSet(BaseViewSet): @@ -81,12 +82,14 @@ def get_queryset(self): def create(self, request): try: - (DISABLE_WORKSPACE_CREATION,) = get_configuration_value([ - { - "key": "DISABLE_WORKSPACE_CREATION", - "default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), - } - ]) + (DISABLE_WORKSPACE_CREATION,) = get_configuration_value( + [ + { + "key": "DISABLE_WORKSPACE_CREATION", + "default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), + } + ] + ) if DISABLE_WORKSPACE_CREATION == "1": return Response( @@ -369,7 +372,7 @@ def generate_csv_from_rows(self, rows): """Generate CSV buffer from rows.""" csv_buffer = io.StringIO() writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL) - [writer.writerow(row) for row in rows] + [writer.writerow(sanitize_csv_row(row)) for row in rows] csv_buffer.seek(0) return csv_buffer diff --git a/apps/api/plane/bgtasks/analytic_plot_export.py b/apps/api/plane/bgtasks/analytic_plot_export.py index 77ea6522dbb..e36314af6e6 100644 --- a/apps/api/plane/bgtasks/analytic_plot_export.py +++ b/apps/api/plane/bgtasks/analytic_plot_export.py @@ -24,6 +24,7 @@ from plane.utils.analytics_plot import build_graph_plot from plane.utils.exception_logger import log_exception from plane.utils.issue_filters import issue_filters +from plane.utils.csv_utils import sanitize_csv_row row_mapping = { "state__name": "State", @@ -180,7 +181,7 @@ def generate_csv_from_rows(rows): """Generate CSV buffer from rows.""" csv_buffer = io.StringIO() writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL) - [writer.writerow(row) for row in rows] + [writer.writerow(sanitize_csv_row(row)) for row in rows] return csv_buffer diff --git a/apps/api/plane/utils/csv_utils.py b/apps/api/plane/utils/csv_utils.py new file mode 100644 index 00000000000..104318e1614 --- /dev/null +++ b/apps/api/plane/utils/csv_utils.py @@ -0,0 +1,23 @@ +# CSV utility functions for safe export + +# Characters that trigger formula evaluation in spreadsheet applications +_CSV_FORMULA_TRIGGERS = frozenset(("=", "+", "-", "@", "\t", "\r", "\n")) + + +def sanitize_csv_value(value): + """Sanitize a value for CSV export to prevent formula injection. + + Prefixes string values starting with formula-triggering characters + with a single quote so spreadsheet applications treat them as text + instead of evaluating them as formulas. + + See: https://owasp.org/www-community/attacks/CSV_Injection + """ + if isinstance(value, str) and value and value[0] in _CSV_FORMULA_TRIGGERS: + return "'" + value + return value + + +def sanitize_csv_row(row): + """Sanitize all values in a CSV row.""" + return [sanitize_csv_value(v) for v in row] diff --git a/apps/api/plane/utils/exporters/formatters.py b/apps/api/plane/utils/exporters/formatters.py index 398ae969313..611a60fca4f 100644 --- a/apps/api/plane/utils/exporters/formatters.py +++ b/apps/api/plane/utils/exporters/formatters.py @@ -9,6 +9,9 @@ from openpyxl import Workbook +# Module imports +from plane.utils.csv_utils import sanitize_csv_row + class BaseFormatter: """Base class for export formatters.""" @@ -84,7 +87,7 @@ def _create_csv_file(self, data: List[List[str]]) -> str: buf = io.StringIO() writer = csv.writer(buf, delimiter=",", quoting=csv.QUOTE_ALL) for row in data: - writer.writerow(row) + writer.writerow(sanitize_csv_row(row)) buf.seek(0) return buf.getvalue() diff --git a/apps/api/plane/utils/porters/formatters.py b/apps/api/plane/utils/porters/formatters.py index 7b31f6d539a..461a6a5e427 100644 --- a/apps/api/plane/utils/porters/formatters.py +++ b/apps/api/plane/utils/porters/formatters.py @@ -18,6 +18,10 @@ from openpyxl import Workbook, load_workbook +# Module imports +from plane.utils.csv_utils import sanitize_csv_row, sanitize_csv_value + + class BaseFormatter(ABC): @abstractmethod def encode(self, data: List[Dict]) -> Union[str, bytes]: @@ -128,11 +132,12 @@ def encode(self, data: List[Dict]) -> str: # Write data rows in the same field order for row in data: - writer.writerow([row.get(key, "") for key in fieldnames]) + writer.writerow(sanitize_csv_row([row.get(key, "") for key in fieldnames])) else: writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=self.delimiter) writer.writeheader() - writer.writerows(data) + for row in data: + writer.writerow({k: sanitize_csv_value(row.get(k, "")) for k in fieldnames}) return output.getvalue()