From 2ed4e307601bfcaa9a31735f11a70f5868cb179b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 21 Jun 2026 04:45:30 +0300 Subject: [PATCH] =?UTF-8?q?fix(export):=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BE=D0=BA=20?= =?UTF-8?q?=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA=20=D0=BE=D1=82=20CSV/formu?= =?UTF-8?q?la-=D0=B8=D0=BD=D1=8A=D0=B5=D0=BA=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-CSV: ячейки экспорта писались «как есть» — значение вроде =HYPERLINK(...) или @SUM(...) в комментарии/контакте/городе при открытии файла в Excel/ LibreOffice исполнялось как формула (OWASP Formula Injection). Новый App\Support\CsvFormulaGuard::neutralize() префиксует апострофом ячейку, начинающуюся с = + - @ TAB CR. Применён к свободному тексту в: - DealExportController (телефон/источник/город/статус/комментарий) - DealsExportProvider (телефон/контакт/статус/проект/менеджер) Числовые колонки (id/суммы/даты) не трогаются — ведущий «-» там легитимен. TenantChargesController НЕ уязвим: колонки enum(CHECK)/число/дата, свободного текста нет. TDD: 13 unit-тестов хелпера + feature-тест экспорта сделок + provider-тест. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Api/DealExportController.php | 14 +++-- .../Reports/Providers/DealsExportProvider.php | 13 ++-- app/app/Support/CsvFormulaGuard.php | 35 +++++++++++ app/tests/Feature/DealExportTest.php | 19 ++++++ .../Reports/DealsExportProviderTest.php | 62 +++++++++++++++++++ .../Unit/Support/CsvFormulaGuardTest.php | 35 +++++++++++ 6 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 app/app/Support/CsvFormulaGuard.php create mode 100644 app/tests/Feature/Reports/DealsExportProviderTest.php create mode 100644 app/tests/Unit/Support/CsvFormulaGuardTest.php diff --git a/app/app/Http/Controllers/Api/DealExportController.php b/app/app/Http/Controllers/Api/DealExportController.php index 40b12ccf..c2e75a37 100644 --- a/app/app/Http/Controllers/Api/DealExportController.php +++ b/app/app/Http/Controllers/Api/DealExportController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Deal; use App\Services\Pd\PdAuditLogger; +use App\Support\CsvFormulaGuard; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -122,12 +123,15 @@ class DealExportController extends Controller $signal = $deal->project?->signal_type; $source = trim(($deal->project?->name ?? '—').' · ' .(self::SIGNAL_LABELS[$signal] ?? '—')); + // F-CSV: свободный текст (телефон/источник/город/статус/ + // комментарий) экранируем от formula-инъекции. Дата — + // системная, не экранируется. $writer->addRow(Row::fromValues([ - (string) $deal->phone, - $source, - (string) ($deal->city ?? ''), - (string) ($statusNames[$deal->status] ?? $deal->status), - (string) ($deal->comment ?? ''), + CsvFormulaGuard::neutralize((string) $deal->phone), + CsvFormulaGuard::neutralize($source), + CsvFormulaGuard::neutralize((string) ($deal->city ?? '')), + CsvFormulaGuard::neutralize((string) ($statusNames[$deal->status] ?? $deal->status)), + CsvFormulaGuard::neutralize((string) ($deal->comment ?? '')), $deal->received_at?->toDateTimeString() ?? '', ])); } diff --git a/app/app/Services/Reports/Providers/DealsExportProvider.php b/app/app/Services/Reports/Providers/DealsExportProvider.php index 99278cb1..406a2391 100644 --- a/app/app/Services/Reports/Providers/DealsExportProvider.php +++ b/app/app/Services/Reports/Providers/DealsExportProvider.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Services\Reports\Providers; use App\Models\ReportJob; +use App\Support\CsvFormulaGuard; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -70,13 +71,15 @@ class DealsExportProvider implements ReportDataProvider $managerName = $row->user_email ?? ''; } + // F-CSV: свободный текст (телефон/контакт/статус/проект/менеджер) + // экранируем от formula-инъекции. ID/стоимость/дата — системные. return [ (int) $row->id, - (string) ($row->phone ?? ''), - (string) ($row->contact_name ?? ''), - (string) ($row->status ?? ''), - (string) ($row->project_name ?? ''), - $managerName, + CsvFormulaGuard::neutralize((string) ($row->phone ?? '')), + CsvFormulaGuard::neutralize((string) ($row->contact_name ?? '')), + CsvFormulaGuard::neutralize((string) ($row->status ?? '')), + CsvFormulaGuard::neutralize((string) ($row->project_name ?? '')), + CsvFormulaGuard::neutralize($managerName), $row->cost_rub !== null ? (string) $row->cost_rub : null, $row->received_at !== null ? Carbon::parse($row->received_at)->toIso8601String() : null, ]; diff --git a/app/app/Support/CsvFormulaGuard.php b/app/app/Support/CsvFormulaGuard.php new file mode 100644 index 00000000..7b1b534e --- /dev/null +++ b/app/app/Support/CsvFormulaGuard.php @@ -0,0 +1,35 @@ +assertStatus(200); $r->assertHeader('content-type', 'text/csv; charset=utf-8'); }); + +test('POST /api/deals/export нейтрализует CSV-формулы в свободном тексте (F-CSV)', function () { + Deal::factory()->for($this->tenant)->for($this->project)->create([ + 'received_at' => '2026-05-15 10:00:00', + 'comment' => '=HYPERLINK("http://evil","нажми")', + 'city' => '@SUM(1+1)', + ]); + + $r = $this->post('/api/deals/export', ['format' => 'csv']); + + $body = $r->streamedContent(); + // Формула нейтрализована префиксом-апострофом, исходная формула в начале + // ячейки отсутствует. + expect($body)->toContain("'=HYPERLINK("); + expect($body)->toContain("'@SUM(1+1)"); + // До фикса ячейка-комментарий обрамлялась как "=HYPERLINK(...; city — как ;@SUM. + expect($body)->not->toContain('"=HYPERLINK('); + expect($body)->not->toContain(';@SUM'); +}); diff --git a/app/tests/Feature/Reports/DealsExportProviderTest.php b/app/tests/Feature/Reports/DealsExportProviderTest.php new file mode 100644 index 00000000..f98d9f60 --- /dev/null +++ b/app/tests/Feature/Reports/DealsExportProviderTest.php @@ -0,0 +1,62 @@ +tenant = Tenant::factory()->create(); + $this->project = Project::factory()->create(['tenant_id' => $this->tenant->id, 'name' => 'Окна Москва']); +}); + +/** ReportJob без сохранения — провайдер читает только tenant_id + parameters. */ +function dealsExportJob(int $tenantId): ReportJob +{ + return new ReportJob([ + 'tenant_id' => $tenantId, + 'type' => 'deals_export', + 'parameters' => [ + 'format' => 'csv', + 'date_from' => Carbon::now()->startOfMonth()->toDateString(), + 'date_to' => Carbon::now()->endOfMonth()->toDateString(), + ], + ]); +} + +test('headers: 8 колонок', function () { + expect((new DealsExportProvider)->headers()) + ->toBe(['ID', 'Телефон', 'Контакт', 'Статус', 'Проект', 'Менеджер', 'Стоимость (₽)', 'Получено']); +}); + +test('нейтрализует CSV-формулы в свободном тексте (F-CSV)', function () { + DB::table('deals')->insert([ + 'tenant_id' => $this->tenant->id, + 'project_id' => $this->project->id, + 'phone' => '+79991234567', + 'contact_name' => '=HYPERLINK("http://evil")', + 'status' => 'new', + 'received_at' => Carbon::now()->startOfMonth()->addDays(5), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]); + // Имя проекта (tenant-контролируемый текст) с формульным префиксом. + DB::table('projects')->where('id', $this->project->id)->update(['name' => '@SUM(1+1)']); + + $rows = (new DealsExportProvider)->rows(dealsExportJob($this->tenant->id)); + + expect($rows)->toHaveCount(1); + // Контакт (idx 2) и телефон (idx 1) и проект (idx 4) — нейтрализованы. + expect($rows[0][2])->toBe('\'=HYPERLINK("http://evil")'); + expect($rows[0][1])->toBe("'+79991234567"); + expect($rows[0][4])->toBe("'@SUM(1+1)"); + // ID (idx 0) — числовой, не тронут. + expect($rows[0][0])->toBeInt(); +}); diff --git a/app/tests/Unit/Support/CsvFormulaGuardTest.php b/app/tests/Unit/Support/CsvFormulaGuardTest.php new file mode 100644 index 00000000..6cd79812 --- /dev/null +++ b/app/tests/Unit/Support/CsvFormulaGuardTest.php @@ -0,0 +1,35 @@ +toBe("'".$trigger.'HYPERLINK("http://evil")'); +})->with('formula_triggers'); + +it('не трогает безопасные значения', function (string $safe) { + expect(CsvFormulaGuard::neutralize($safe))->toBe($safe); +})->with([ + '+7 999 111-11-11 как обычный текст после цифр' => '79991234567', + 'кириллица' => 'Окна Москва', + 'латиница' => 'comment text', + 'число' => '1500.00', + 'email' => 'user@example.ru как текст', +]); + +it('пропускает null и пустую строку без изменений', function () { + expect(CsvFormulaGuard::neutralize(null))->toBeNull(); + expect(CsvFormulaGuard::neutralize(''))->toBe(''); +}); + +it('не ломает многобайтовую кириллицу в начале', function () { + expect(CsvFormulaGuard::neutralize('Москва'))->toBe('Москва'); +});