From 65179f9b3b6dc01f5b8a0d9f8f47a95b64297490 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Fri, 16 Oct 2026 16:27:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=AD=98=E9=87=8F=E6=8A=A5=E4=BB=B7):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=B9=E9=87=8F=E4=BF=9D=E5=AD=98=E8=A2=AB?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E6=A3=80=E6=9F=A5=E8=AF=AF=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Business/CourierStockQuoteEntries.php | 18 ++++++-- .../stock-quote-batch.blade.php | 43 +++++++++++++++++-- .../Business/CourierStockQuoteBatchTest.php | 40 ++++++++++++++++- 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/Business/CourierStockQuoteEntries.php b/app/Http/Controllers/Business/CourierStockQuoteEntries.php index 318353d8..b1490dc3 100644 --- a/app/Http/Controllers/Business/CourierStockQuoteEntries.php +++ b/app/Http/Controllers/Business/CourierStockQuoteEntries.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Business; use App\Models\Business\CourierStockQuoteEntry; use App\Support\CourierStockQuoteBatchService; use App\Support\CourierStockQuoteEntryService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; @@ -54,11 +55,16 @@ class CourierStockQuoteEntries extends Controller ]); } - public function saveBatch(Request $request, string $batch): RedirectResponse + public function saveBatch(Request $request, string $batch): RedirectResponse|JsonResponse { - // 单个 JSON 字段避免百家工作表触及 PHP 表单字段数量限制。 - $request->validate(['selection_json' => ['required', 'json', 'max:200000']]); - $selection = json_decode($request->input('selection_json'), true); + // JSON 请求体不受 max_input_vars 限制,防火墙也可逐字段检查,避免跨字段误匹配。 + // 保留旧表单入口,已打开的页面仍由原有防护检查,不豁免任何用户输入。 + if ($request->isJson()) { + $selection = $request->json()->all(); + } else { + $request->validate(['selection_json' => ['required', 'json', 'max:200000']]); + $selection = json_decode($request->input('selection_json'), true); + } if (! is_array($selection)) { throw ValidationException::withMessages(['selection_json' => '请选择本次要录入的工作表。']); } @@ -86,6 +92,10 @@ class CourierStockQuoteEntries extends Controller ])->validate(); $this->batches->save($batch, (int) user_id(), $data); + if ($request->expectsJson()) { + return response()->json(['redirect' => route('business.courier-stock-quotes.batch', $batch)]); + } + return redirect()->route('business.courier-stock-quotes.batch', $batch); } diff --git a/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php b/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php index a9e86ce6..3a94d68d 100644 --- a/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php +++ b/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php @@ -72,6 +72,7 @@ @if($batch)

已保留本次上传的 Excel 和选择,无需重新上传。

@endif @endif + @if(!$batch)

一个 Excel,每家一张工作表,电子报价旁附签署原件图片,整本上传一次。各家的价格、计费条件和执行日期分别核对。

@@ -116,7 +117,6 @@

{{ $manifest['source_file']['name'] }} · 识别到 {{ count($rows) }} 张工作表,{{ count(array_filter($rows, fn ($row) => $row['courier_expcode'] !== '' && !$row['error'] && !$row['duplicate'])) }} 张已匹配。未匹配、重名或重复报价会提示处理,不覆盖已有报价。

@csrf -

共用设置与计费规则

@@ -263,7 +263,10 @@ const select = !visible.every(row => row.querySelector('[data-row-selected]').checked); visible.forEach(row => row.querySelector('[data-row-selected]').checked = select); update(); }); - form.addEventListener('submit', event => { + let saving = false; + form.addEventListener('submit', async event => { + event.preventDefault(); + if (saving) return; const payload = { quote_type: form.querySelector('[data-common-type]').value, effective_date: commonDate.value, @@ -273,9 +276,41 @@ rule_groups: [...groups.querySelectorAll('[data-rule-group]')].map(group => ({ name: group.querySelector('[data-rule-name]').value, rules: group.querySelector('[data-rule-text]').value })), entries: rows.map(row => ({ selected: row.querySelector('[data-row-selected]').checked, courier_expcode: row.querySelector('[data-row-courier]').value.trim(), rule_group: Number(row.querySelector('[data-row-rule]').value), effective_date: row.querySelector('[data-row-date]').value })) }; - form.querySelector('[data-selection-json]').value = JSON.stringify(payload); - form.querySelectorAll('button[type=submit]').forEach(button => button.disabled = true); + const errors = root.querySelector('[data-save-errors]'); + errors.hidden = true; + const buttons = [...form.querySelectorAll('button[type=submit]')]; + const label = event.submitter?.textContent; + saving = true; + buttons.forEach(button => button.disabled = true); if (event.submitter) event.submitter.textContent = '正在批量保存…'; + try { + const response = await fetch(form.action, { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': form.querySelector('[name=_token]').value }, + body: JSON.stringify(payload) + }); + const result = response.headers.get('content-type')?.includes('application/json') ? await response.json() : {}; + if (response.ok && result.redirect) { + window.location.assign(result.redirect); + return; + } + const messages = result.errors ? Object.values(result.errors).flat() : [ + response.status === 419 ? '登录状态已过期,请刷新页面后重试;已上传的原文件会保留。' : + response.status === 403 ? '保存请求被安全检查或权限检查拦截,本页内容已保留,请联系管理员检查。' : + '本次保存未能确认完成,请在本页重试;同一批次重试不会重复录入。' + ]; + errors.replaceChildren(...messages.map(message => { const item = document.createElement('div'); item.textContent = message; return item; })); + errors.hidden = false; + errors.focus(); + } catch (error) { + errors.textContent = '网络连接中断,暂时无法确认保存结果。请在本页重试,同一批次不会重复录入,已填写内容和原件会保留。'; + errors.hidden = false; + errors.focus(); + } finally { + saving = false; + buttons.forEach(button => button.disabled = false); + if (event.submitter) event.submitter.textContent = label; + } }); update(); })(); diff --git a/tests/Feature/Business/CourierStockQuoteBatchTest.php b/tests/Feature/Business/CourierStockQuoteBatchTest.php index 1dc819cf..0926f0d6 100644 --- a/tests/Feature/Business/CourierStockQuoteBatchTest.php +++ b/tests/Feature/Business/CourierStockQuoteBatchTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Business; +use Akaunting\Firewall\Middleware\Xss; use App\Models\Auth\Permission; use App\Models\Business\CourierQuoteChange; use App\Models\Business\CourierStockQuoteEntry; @@ -101,6 +102,42 @@ class CourierStockQuoteBatchTest extends FeatureTestCase $this->assertDatabaseCount('business_courier_quote_signatures', 0); } + public function test_structured_json_save_passes_real_xss_checks_and_returns_correctable_errors(): void + { + $batch = $this->preview(['EXP-001', 'EXP-002']); + $selection = $this->selection(['EXP-001', 'EXP-002']); + // 合法名称中出现 JavaScript,但没有协议或标签;序列化后会与后续字段的冒号串联误匹配。 + $selection['summary'] = 'JavaScript物流报价补录'; + $middleware = new Xss(); + $middleware->prepare(\Illuminate\Http\Request::create('/', 'POST')); + $this->assertSame(1, $middleware->match($middleware->getPatterns()[3], ['selection_json' => json_encode($selection)])); + config(['firewall.middleware.xss.enabled' => true]); + $url = route('business.courier-stock-quotes.batch-save', $batch); + $selection['entries'][1]['courier_expcode'] = 'EXP-001'; + $this->withExceptionHandling()->loginAs()->postJson($url, $selection) + ->assertUnprocessable()->assertJsonValidationErrors('entries.1'); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); + $selection['entries'][1]['courier_expcode'] = 'EXP-002'; + $selection['submit_for_review'] = $selection['confirmed'] = true; + $this->postJson($url, $selection)->assertOk() + ->assertJsonPath('redirect', route('business.courier-stock-quotes.batch', $batch)); + $this->assertSame([CourierQuoteChange::STATUS_PENDING_REVIEW], CourierQuoteChange::pluck('status')->unique()->all()); + $this->postJson($url, $selection)->assertOk(); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 2); + $this->assertDatabaseCount('business_courier_quote_versions', 0); + } + + public function test_structured_json_keeps_malicious_fields_blocked_by_real_xss_middleware(): void + { + $batch = $this->preview(['EXP-001']); + $selection = $this->selection(['EXP-001']); + $selection['rule_groups'][0]['rules'] = ''; + config(['firewall.middleware.xss.enabled' => true, 'firewall.logs.enabled' => false]); + $this->withExceptionHandling()->loginAs()->postJson(route('business.courier-stock-quotes.batch-save', $batch), $selection) + ->assertForbidden(); + $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); + } + public function test_row_errors_are_atomic_keep_preview_and_do_not_override_existing_quotes(): void { $batch = $this->preview(['EXP-001', 'EXP-002']); @@ -160,7 +197,8 @@ class CourierStockQuoteBatchTest extends FeatureTestCase $this->mirror($couriers); $batch = $this->preview($names, array_fill(0, 120, 1)); $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('120'); - $this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($this->selection($names))])->assertRedirect(); + config(['firewall.middleware.xss.enabled' => true]); + $this->postJson(route('business.courier-stock-quotes.batch-save', $batch), $this->selection($names))->assertOk(); $this->assertDatabaseCount('business_courier_stock_quote_entries', 120); $this->assertDatabaseCount('business_courier_quote_changes', 120); $this->assertCount(1, CourierStockQuoteEntry::all()->pluck('files')->map(fn ($files) => $files[0]['path'])->unique()); -- 2.47.2