fix(存量报价): 修复批量保存被安全检查误拦截 #347
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -72,6 +72,7 @@
|
||||
@if($batch)<p class="sb-hint">已保留本次上传的 Excel 和选择,无需重新上传。</p>@endif
|
||||
</div>
|
||||
@endif
|
||||
<div class="sb-errors sb-error" role="alert" data-save-errors hidden tabindex="-1"></div>
|
||||
@if(!$batch)
|
||||
<p class="sb-notice">一个 Excel,每家一张工作表,电子报价旁附签署原件图片,整本上传一次。各家的价格、计费条件和执行日期分别核对。</p>
|
||||
<form action="{{ route('business.courier-stock-quotes.batch-preview') }}" method="POST" enctype="multipart/form-data" data-batch-upload>
|
||||
@ -116,7 +117,6 @@
|
||||
<p class="sb-notice">{{ $manifest['source_file']['name'] }} · 识别到 <strong>{{ count($rows) }}</strong> 张工作表,{{ count(array_filter($rows, fn ($row) => $row['courier_expcode'] !== '' && !$row['error'] && !$row['duplicate'])) }} 张已匹配。未匹配、重名或重复报价会提示处理,不覆盖已有报价。</p>
|
||||
<form action="{{ route('business.courier-stock-quotes.batch-save', $batch) }}" method="POST" data-batch-review>
|
||||
@csrf
|
||||
<input type="hidden" name="selection_json" data-selection-json>
|
||||
<section class="sb-panel">
|
||||
<h2 class="sb-header"><span class="material-icons-outlined" aria-hidden="true">rule</span>共用设置与计费规则</h2>
|
||||
<div class="sb-body">
|
||||
@ -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();
|
||||
})();
|
||||
|
||||
@ -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'] = '<script>alert(1)</script>';
|
||||
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());
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user