Skip to content

Commit 5478c67

Browse files
committed
Fixes #10387, #12460, and #19389 - better validation for text files
Files whose sniff yields nothing usable (empty, octet-stream, INI-shaped) now pass.
1 parent 66fc6a5 commit 5478c67

6 files changed

Lines changed: 459 additions & 6 deletions

File tree

app/Helpers/Helper.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,6 +1404,7 @@ public static function filetype_icon($filename)
14041404
'png' => 'far fa-image',
14051405
'webp' => 'far fa-image',
14061406
'avif' => 'far fa-image',
1407+
'ico' => 'far fa-image',
14071408
'svg' => 'fas fa-vector-square',
14081409

14091410
// word
@@ -1428,14 +1429,17 @@ public static function filetype_icon($filename)
14281429
'txt' => 'far fa-file-alt',
14291430
'rtf' => 'far fa-file-alt',
14301431
'xml' => 'fas fa-code',
1432+
'json' => 'fas fa-code',
14311433

14321434
// Misc
14331435
'pdf' => 'far fa-file-pdf',
14341436
'lic' => 'far fa-save',
1437+
'key' => 'fas fa-key',
14351438

14361439
// video
14371440
'mov' => 'fa-solid fa-video',
14381441
'mp4' => 'fa-solid fa-video',
1442+
'webm' => 'fa-solid fa-video',
14391443

14401444
// audio
14411445
'ogg' => 'fa-solid fa-file-audio',

app/Http/Controllers/Api/ImportController.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,28 @@ public function store(): JsonResponse
5757
$detector = new EncodingDetector;
5858

5959
foreach ($files as $file) {
60-
if (! in_array($file->getMimeType(), [
60+
$allowedMimes = [
6161
'application/vnd.ms-excel',
6262
'text/csv',
6363
'application/csv',
6464
'text/x-Algol68', // because wtf CSV files?
6565
'text/plain',
6666
'text/comma-separated-values',
67-
'text/tsv', ])) {
67+
'text/tsv',
68+
];
69+
$allowedExtensions = ['csv', 'tsv', 'txt'];
70+
$clientExtension = strtolower(trim($file->getClientOriginalExtension()));
71+
72+
// The MIME allowlist is the primary check. When it fails,
73+
// fall back to the client extension because finfo returns
74+
// `application/octet-stream` for CSVs on Windows/IIS and
75+
// for various perfectly-valid CSVs whose first row happens
76+
// to match another magic signature. Callers reach this
77+
// endpoint only with the `import` permission, and the CSV
78+
// reader below will reject anything that isn't actually
79+
// parseable with a more precise error than a MIME veto.
80+
// See issue #10387.
81+
if (! in_array($file->getMimeType(), $allowedMimes) && ! in_array($clientExtension, $allowedExtensions, true)) {
6882
$results['error'] = 'File type must be CSV. Uploaded file is '.$file->getMimeType();
6983

7084
return response()->json(Helper::formatStandardApiResponse('error', null, $results['error']), 422);

app/Http/Requests/UploadFileRequest.php

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use App\Helpers\Helper;
66
use App\Http\Traits\ConvertsBase64ToFiles;
7+
use App\Rules\AllowedUploadExtension;
78
use enshrined\svgSanitize\Sanitizer;
89
use Illuminate\Support\Facades\Log;
910
use Illuminate\Support\Facades\Storage;
@@ -29,10 +30,21 @@ public function authorize()
2930
*/
3031
public function rules()
3132
{
32-
$max_file_size = Helper::file_upload_max_size();
33-
33+
// AllowedUploadExtension replaces Laravel's `mimes:` rule because
34+
// `mimes:` content-sniffs, reverse-maps the detected MIME to a
35+
// single extension, and rejects anything the guesser can't map,
36+
// even when the client extension is on the allowlist. That was
37+
// rejecting legitimate uploads (empty .txt, INI-shaped text,
38+
// Windows-sniffed .csv reporting octet-stream) with a generic
39+
// "check the form below" error. See issues #12460 and #10387.
3440
return [
35-
'file.*' => 'required|mimes:'.config('filesystems.allowed_upload_extensions_for_validator').'|max:'.$max_file_size,
41+
'file.*' => [
42+
'bail',
43+
'required',
44+
'file',
45+
new AllowedUploadExtension(config('filesystems.allowed_upload_extensions_array')),
46+
'max:'.Helper::file_upload_max_size(),
47+
],
3648
];
3749
}
3850

@@ -44,7 +56,13 @@ public function handleFile(string $dirname, string $name_prefix, $file): string
4456
{
4557

4658
$extension = $file->getClientOriginalExtension();
47-
$file_name = $name_prefix.'-'.str_random(8).'-'.str_slug(basename($file->getClientOriginalName(), '.'.$extension)).'.'.$file->guessExtension();
59+
// Prefer the content-sniffed extension for the stored name so a
60+
// rename can't hide the real content type from the filesystem.
61+
// Fall back to the client extension when finfo returns nothing,
62+
// otherwise the stored filename ends in a bare "." and the
63+
// eventual download has no extension.
64+
$stored_extension = $file->guessExtension() ?: strtolower($extension);
65+
$file_name = $name_prefix.'-'.str_random(8).'-'.str_slug(basename($file->getClientOriginalName(), '.'.$extension)).'.'.$stored_extension;
4866

4967
// Check for SVG and sanitize it
5068
if ($file->getMimeType() === 'image/svg+xml') {
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
namespace App\Rules;
4+
5+
use Closure;
6+
use Illuminate\Contracts\Validation\ValidationRule;
7+
use Illuminate\Http\UploadedFile;
8+
9+
class AllowedUploadExtension implements ValidationRule
10+
{
11+
/** @param array<int, string> $extensions */
12+
public function __construct(private readonly array $extensions) {}
13+
14+
public function validate(string $attribute, mixed $value, Closure $fail): void
15+
{
16+
if (! $value instanceof UploadedFile || ! $value->isValid()) {
17+
$fail(trans('validation.uploaded', ['attribute' => $attribute]));
18+
19+
return;
20+
}
21+
22+
// Never let a PHP-executable extension through, even if a caller's
23+
// allowlist accidentally names one. Mirrors the guard baked into
24+
// Laravel's own `mimes:` rule via shouldBlockPhpUpload, so this rule
25+
// stays a safe drop-in replacement.
26+
$phpExecutableExtensions = [
27+
'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar',
28+
];
29+
30+
$clientExtension = strtolower(trim($value->getClientOriginalExtension()));
31+
$allowed = array_map('strtolower', $this->extensions);
32+
33+
$rejected = trans('validation.mimes', [
34+
'attribute' => $attribute,
35+
'values' => implode(', ', $allowed),
36+
]);
37+
38+
if (in_array($clientExtension, $phpExecutableExtensions, true)) {
39+
$fail($rejected);
40+
41+
return;
42+
}
43+
44+
if (! in_array($clientExtension, $allowed, true)) {
45+
$fail($rejected);
46+
47+
return;
48+
}
49+
50+
// Belt against content that finfo confidently identifies as
51+
// server-runnable, even when it wouldn't reverse-map to an
52+
// extension on the allowlist. Catches the classic webshell
53+
// upload (PHP bytes named `shell.jpg`) which otherwise slips
54+
// through because Symfony's guesser returns null for text/x-php
55+
// and the uninformative-sniff branch below would let it pass.
56+
// Native executable formats (PE, ELF, Mach-O) live here as
57+
// defense-in-depth. Shebang scripts (shell, python, perl) are
58+
// deliberately absent because Snipe-IT does not execute uploads
59+
// and script snippets in .txt attachments are legitimate.
60+
$executableContentMimes = [
61+
'text/x-php',
62+
'application/x-httpd-php',
63+
'application/x-httpd-php-source',
64+
'application/x-executable',
65+
'application/x-mach-binary',
66+
'application/x-elf',
67+
'application/x-sharedlib',
68+
];
69+
70+
$sniffedMime = strtolower((string) $value->getMimeType());
71+
72+
if (in_array($sniffedMime, $executableContentMimes, true)) {
73+
$fail($rejected);
74+
75+
return;
76+
}
77+
78+
// Symfony's guessExtension() sniffs the content with finfo and
79+
// reverse-maps the detected MIME to an extension. It returns null
80+
// when libmagic matches nothing that reverse-maps cleanly (e.g.
81+
// INI-shaped plain text). Empty files and unknown binary blobs
82+
// sniff to application/x-empty and application/octet-stream,
83+
// which reverse-map to 'bin' but carry no real signal. Windows
84+
// and other thin magic databases also default to octet-stream
85+
// for many everyday files (see issue #10387). Treat all three
86+
// as "no evidence against the client extension" and defer to
87+
// what the client sent. When the sniff does yield a meaningful
88+
// extension it must also be on the allowlist, which still
89+
// catches obvious mislabels like an .exe renamed to .txt.
90+
$uninformativeMimes = ['application/octet-stream', 'application/x-empty', ''];
91+
92+
if (in_array($sniffedMime, $uninformativeMimes, true)) {
93+
return;
94+
}
95+
96+
$guessed = strtolower(trim((string) $value->guessExtension()));
97+
98+
if ($guessed !== '' && ! in_array($guessed, $allowed, true)) {
99+
$fail($rejected);
100+
}
101+
}
102+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
namespace Tests\Feature\FileUploads;
4+
5+
use App\Models\Actionlog;
6+
use App\Models\Asset;
7+
use App\Models\License;
8+
use App\Models\User;
9+
use Illuminate\Http\UploadedFile;
10+
use Illuminate\Support\Facades\Storage;
11+
use PHPUnit\Framework\Attributes\Test;
12+
use Tests\TestCase;
13+
14+
// Regression coverage for issues #12460 and #10387: legitimate uploads
15+
// that were rejected because Laravel's built-in `mimes:` rule (and the
16+
// hand-rolled MIME allowlist in the CSV importer) rely on finfo content
17+
// sniffing that misidentifies ordinary files. Fake UploadedFiles bypass
18+
// finfo entirely (their getMimeType() reads MimeType::from($name)), so
19+
// these tests use real temp files.
20+
class UploadFileValidationTest extends TestCase
21+
{
22+
private array $tempFiles = [];
23+
24+
protected function setUp(): void
25+
{
26+
parent::setUp();
27+
Storage::fake();
28+
}
29+
30+
protected function tearDown(): void
31+
{
32+
foreach ($this->tempFiles as $path) {
33+
@unlink($path);
34+
}
35+
36+
parent::tearDown();
37+
}
38+
39+
private function realUpload(string $clientName, string $content): UploadedFile
40+
{
41+
$path = tempnam(sys_get_temp_dir(), 'snipeit_upload_');
42+
file_put_contents($path, $content);
43+
$this->tempFiles[] = $path;
44+
45+
return new UploadedFile($path, $clientName, null, null, true);
46+
}
47+
48+
// Issue #12460 TechWilk reproduction: plain-text .txt whose bytes
49+
// trigger libmagic's INI heuristic (leading `;`, tab-separated
50+
// values). Before the fix, UploadFileRequest's `mimes:txt,...` rule
51+
// rejected this because finfo returned application/x-wine-extension-ini
52+
// and Symfony's guesser had no reverse mapping to `txt`.
53+
#[Test]
54+
public function accepts_txt_file_that_libmagic_misidentifies_as_ini(): void
55+
{
56+
$license = License::factory()->create();
57+
58+
$this->actingAsForApi(User::factory()->superuser()->create())
59+
->post(
60+
route('api.files.store', ['object_type' => 'licenses', 'id' => $license->id]),
61+
['file' => [$this->realUpload('sample.txt', ";Bob[A]\tSmith[B]\r\n50\t0.8")]]
62+
)
63+
->assertOk();
64+
65+
$log = Actionlog::where('item_id', $license->id)
66+
->where('item_type', License::class)
67+
->where('action_type', 'uploaded')
68+
->latest('id')
69+
->firstOrFail();
70+
71+
// Stored filename must still carry a .txt extension. Before the
72+
// handleFile fallback, guessExtension() returned null on this
73+
// input and the stored name ended in a bare "." with no
74+
// extension, breaking the eventual download.
75+
$this->assertStringEndsWith('.txt', $log->filename);
76+
}
77+
78+
// Issue #12460 primary: empty .txt file. finfo returns
79+
// application/x-empty for zero-byte files.
80+
#[Test]
81+
public function accepts_empty_txt_file(): void
82+
{
83+
$asset = Asset::factory()->create();
84+
85+
$this->actingAsForApi(User::factory()->superuser()->create())
86+
->post(
87+
route('api.files.store', ['object_type' => 'assets', 'id' => $asset->id]),
88+
['file' => [$this->realUpload('empty.txt', '')]]
89+
)
90+
->assertOk();
91+
}
92+
93+
// The extension allowlist is still authoritative: an .exe rename
94+
// must not slip past just because we deferred to the client
95+
// extension. Sniff returns application/x-dosexec which reverse-maps
96+
// to `exe`, and `exe` is not on the extensions allowlist.
97+
#[Test]
98+
public function still_rejects_files_whose_extension_is_not_on_the_allowlist(): void
99+
{
100+
$asset = Asset::factory()->create();
101+
102+
$peHeader = "MZ\x90\x00\x03\x00\x00\x00\x04\x00\x00\x00\xff\xff\x00\x00";
103+
104+
$this->actingAsForApi(User::factory()->superuser()->create())
105+
->post(
106+
route('api.files.store', ['object_type' => 'assets', 'id' => $asset->id]),
107+
['file' => [$this->realUpload('installer.exe', $peHeader)]]
108+
)
109+
->assertSessionHasErrors('file.0');
110+
}
111+
112+
// Issue #10387: CSV importer used to reject anything whose sniffed
113+
// MIME wasn't on a small hand-rolled list. Windows/IIS commonly
114+
// sniffs .csv as application/octet-stream because the platform's
115+
// magic database is thinner than Linux's. Real CSV content of the
116+
// shape below also sniffs as octet-stream on the current test
117+
// environment, which is exactly the scenario the reporter hit.
118+
#[Test]
119+
public function csv_importer_accepts_csv_that_content_sniffs_as_octet_stream(): void
120+
{
121+
// Leading NULs guarantee finfo returns application/octet-stream,
122+
// reproducing the Windows-sniff behavior deterministically.
123+
$csv = "\x00\x01\x02header1,header2\nvalue1,value2\n";
124+
125+
$this->actingAsForApi(User::factory()->superuser()->create())
126+
->post(
127+
route('api.imports.store'),
128+
['files' => [$this->realUpload('inventory.csv', $csv)]]
129+
)
130+
->assertOk();
131+
}
132+
133+
// Backstop: a genuine non-CSV file (a PNG here) whose extension is
134+
// also not csv/tsv/txt must still be rejected by the importer.
135+
#[Test]
136+
public function csv_importer_still_rejects_non_csv_extensions(): void
137+
{
138+
$png = base64_decode(
139+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
140+
);
141+
142+
$this->actingAsForApi(User::factory()->superuser()->create())
143+
->post(
144+
route('api.imports.store'),
145+
['files' => [$this->realUpload('picture.png', $png)]]
146+
)
147+
->assertStatus(422);
148+
}
149+
}

0 commit comments

Comments
 (0)