3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00
Files
snipe-it/app/Http/Controllers/Api/ImportController.php
snipe 5478c671ab Fixes #10387, #12460, and #19389 - better validation for text files
Files whose sniff yields nothing usable (empty, octet-stream, INI-shaped) now pass.
2026-07-28 21:16:39 +01:00

350 lines
15 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\ItemImportRequest;
use App\Http\Transformers\ImportsTransformer;
use App\Models\Import;
use Illuminate\Database\Eloquent\JsonEncodingException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use League\Csv\Reader;
use Onnov\DetectEncoding\EncodingDetector;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
class ImportController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(): JsonResponse|array
{
$this->authorize('import');
// Silently scope to the caller's own imports unless they're a superuser.
// The `import` permission is grantable to any user, but a stored import
// file (and the first CSV row exposed by the transformer) is only meant
// for whoever uploaded it. Superusers keep the full view.
$query = Import::with('adminuser')->latest();
if (! auth()->user()->isSuperUser()) {
$query->where('created_by', auth()->id());
}
return (new ImportsTransformer)->transformImports($query->get());
}
/**
* Process and store a CSV upload file.
*
* @param \Illuminate\Http\Request $request
*/
public function store(): JsonResponse
{
$this->authorize('import');
if (! config('app.lock_passwords')) {
$files = Request::file('files');
$path = config('app.private_uploads').'/imports';
$results = [];
$import = new Import;
$detector = new EncodingDetector;
foreach ($files as $file) {
$allowedMimes = [
'application/vnd.ms-excel',
'text/csv',
'application/csv',
'text/x-Algol68', // because wtf CSV files?
'text/plain',
'text/comma-separated-values',
'text/tsv',
];
$allowedExtensions = ['csv', 'tsv', 'txt'];
$clientExtension = strtolower(trim($file->getClientOriginalExtension()));
// The MIME allowlist is the primary check. When it fails,
// fall back to the client extension because finfo returns
// `application/octet-stream` for CSVs on Windows/IIS and
// for various perfectly-valid CSVs whose first row happens
// to match another magic signature. Callers reach this
// endpoint only with the `import` permission, and the CSV
// reader below will reject anything that isn't actually
// parseable with a more precise error than a MIME veto.
// See issue #10387.
if (! in_array($file->getMimeType(), $allowedMimes) && ! in_array($clientExtension, $allowedExtensions, true)) {
$results['error'] = 'File type must be CSV. Uploaded file is '.$file->getMimeType();
return response()->json(Helper::formatStandardApiResponse('error', null, $results['error']), 422);
}
// TODO: is there a lighter way to do this?
if (! ini_get('auto_detect_line_endings')) {
ini_set('auto_detect_line_endings', '1');
}
if (function_exists('iconv')) {
$file_contents = $file->getContent(); // TODO - this *does* load the whole file in RAM, but we need that to be able to 'iconv' it?
$encoding = $detector->getEncoding($file_contents);
\Log::debug("Discovered encoding: $encoding in uploaded CSV");
$reader = null;
if (strcasecmp($encoding, 'UTF-8') != 0) {
$transliterated = false;
try {
$transliterated = iconv(strtoupper($encoding), 'UTF-8', $file_contents);
} catch (\Exception $e) {
$transliterated = false; // blank out the partially-decoded string
return response()->json(
Helper::formatStandardApiResponse(
'error',
null,
trans('admin/hardware/message.import.transliterate_failure', ['encoding' => $encoding])
),
422
);
}
if ($transliterated !== false) {
$tmpname = tempnam(sys_get_temp_dir(), '');
$tmpresults = file_put_contents($tmpname, $transliterated);
$transliterated = null; // save on memory?
if ($tmpresults !== false) {
$newfile = new UploadedFile($tmpname, $file->getClientOriginalName(), null, null, true); // WARNING: this is enabling 'test mode' - which is gross, but otherwise the file won't be treated as 'uploaded'
if ($newfile->isValid()) {
$file = $newfile;
}
}
}
}
$file_contents = null; // try to save on memory, I guess?
}
$reader = Reader::createFromFileObject($file->openFile('r')); // file pointer leak?
try {
$import->header_row = $reader->nth(0);
} catch (JsonEncodingException $e) {
return response()->json(
Helper::formatStandardApiResponse(
'error',
null,
trans('admin/hardware/message.import.header_row_has_malformed_characters')
),
422
);
}
// duplicate headers check
$duplicate_headers = [];
for ($i = 0; $i < count($import->header_row); $i++) {
$header = $import->header_row[$i];
if (in_array($header, $import->header_row)) {
$found_at = array_search($header, $import->header_row);
if ($i > $found_at) {
// avoid reporting duplicates twice, e.g. "1 is same as 17! 17 is same as 1!!!"
// as well as "1 is same as 1!!!" (which is always true)
// has to be > because otherwise the first result of array_search will always be $i itself(!)
array_push($duplicate_headers, "Duplicate header '$header' detected, first at column: ".($found_at + 1).', repeats at column: '.($i + 1));
}
}
}
if (count($duplicate_headers) > 0) {
return response()->json(Helper::formatStandardApiResponse('error', null, implode('; ', $duplicate_headers)), 422);
}
try {
// Grab the first row to display via ajax as the user picks fields
$import->first_row = $reader->nth(1);
} catch (JsonEncodingException $e) {
return response()->json(
Helper::formatStandardApiResponse(
'error',
null,
trans('admin/hardware/message.import.content_row_has_malformed_characters')
),
422
);
}
$date = date('Y-m-d-his');
$fixed_filename = Str::of($file->getClientOriginalName())->basename('.csv').'.csv';
try {
$file->move($path, $date.'-'.$fixed_filename);
} catch (FileException $exception) {
$results['error'] = trans('admin/hardware/message.upload.error');
if (config('app.debug')) {
$results['error'] .= ' '.$exception->getMessage();
}
return response()->json(Helper::formatStandardApiResponse('error', null, $results['error']), 500);
}
$file_name = date('Y-m-d-his').'-'.$fixed_filename;
$import->file_path = $file_name;
$import->filesize = null;
if (! file_exists($path.'/'.$file_name)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_not_found')), 500);
}
$import->filesize = filesize($path.'/'.$file_name);
$import->created_by = auth()->id();
$import->save();
$results[] = $import;
}
$results = (new ImportsTransformer)->transformImports($results);
return response()->json([
'files' => $results,
]);
}
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.feature_disabled')), 422);
}
/**
* Processes the specified Import.
*
* @param int $import_id
*/
public function process(ItemImportRequest $request, $import_id): JsonResponse
{
$this->authorize('import');
// Demo mode: same "feature disabled" gate as store(). Uploading
// was blocked there but processing an existing (seeded or leftover)
// Import row would still mutate the demo DB - close the loophole.
if (config('app.lock_passwords')) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.feature_disabled')), 422);
}
// Run a backup immediately before processing
if ($request->input('run-backup')) {
Log::debug('Backup manually requested via importer');
Artisan::call('snipeit:backup', ['--filename' => 'pre-import-backup-'.date('Y-m-d-H-i-s')]);
} else {
Log::debug('NO BACKUP requested via importer');
}
$import = Import::find($import_id);
// Non-owners get the same "not found" branch as a missing record so we
// don't leak existence of another user's uploaded import file, and so
// an attacker with the `import` permission can't replay a stored file
// by guessing its (sequential) ID. Superusers can process any import.
if (is_null($import) || ($import->created_by !== auth()->id() && ! auth()->user()->isSuperUser())) {
$error[0][0] = trans('validation.exists', ['attribute' => 'file']);
return response()->json(Helper::formatStandardApiResponse('import-errors', null, $error), 500);
}
$errors = $request->import($import);
$redirectTo = 'hardware.index';
switch ($request->input('import-type')) {
case 'asset':
$model_perms = 'App\Models\Asset';
$redirectTo = 'hardware.index';
break;
case 'assetHistory':
$model_perms = 'App\Models\Asset';
$redirectTo = 'hardware.index';
break;
case 'assetModel':
$model_perms = 'App\Models\AssetModel';
$redirectTo = 'models.index';
break;
case 'accessory':
$model_perms = 'App\Models\Accessory';
$redirectTo = 'accessories.index';
break;
case 'consumable':
$model_perms = 'App\Models\Consumable';
$redirectTo = 'consumables.index';
break;
case 'component':
$model_perms = 'App\Models\Component';
$redirectTo = 'components.index';
break;
case 'license':
$model_perms = 'App\Models\License';
$redirectTo = 'licenses.index';
break;
case 'user':
$model_perms = 'App\Models\User';
$redirectTo = 'users.index';
break;
case 'location':
$model_perms = 'App\Models\Location';
$redirectTo = 'locations.index';
break;
case 'supplier':
$model_perms = 'App\Models\Supplier';
$redirectTo = 'suppliers.index';
break;
case 'manufacturer':
$model_perms = 'App\Models\Manufacturer';
$redirectTo = 'manufacturers.index';
break;
case 'category':
$model_perms = 'App\Models\Category';
$redirectTo = 'categories.index';
break;
}
if ($errors) { // Failure
return response()->json(Helper::formatStandardApiResponse('import-errors', null, $errors), 500);
}
// Flash message before the redirect
Session::flash('success', trans('admin/hardware/message.import.success'));
if (auth()->user()->can('view', $model_perms)) {
return response()->json(Helper::formatStandardApiResponse('success', null, ['redirect_url' => route($redirectTo)]));
}
return response()->json(Helper::formatStandardApiResponse('success', null, ['redirect_url' => route('imports.index')]));
}
/**
* Remove the specified resource from storage.
*
* @param int $import_id
*/
public function destroy($import_id): JsonResponse
{
$this->authorize('import');
if (config('app.lock_passwords')) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.feature_disabled')), 422);
}
if ($import = Import::find($import_id)) {
if ((auth()->user()->id != $import->created_by) && (! auth()->user()->isSuperUser())) {
return response()->json(Helper::formatStandardApiResponse('warning', null, trans('admin/hardware/message.import.file_not_deleted_warning')));
}
try {
// Try to delete the file
Storage::delete('imports/'.$import->file_path);
$import->delete();
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/hardware/message.import.file_delete_success')));
} catch (\Exception $e) {
// If the file delete didn't work, remove it from the database anyway and return a warning
$import->delete();
return response()->json(Helper::formatStandardApiResponse('warning', null, trans('admin/hardware/message.import.file_not_deleted_warning')));
}
}
return response()->json(Helper::formatStandardApiResponse('warning', null, trans('admin/hardware/message.import.file_not_deleted_warning')));
}
}