166 lines
5.7 KiB
PHP
166 lines
5.7 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Web\Cs;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Rules\RecaptchaV3Rule;
|
|
use App\Services\QnaService;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class CsQnaController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly QnaService $qnaService
|
|
) {}
|
|
|
|
// 글쓰기 폼
|
|
public function create(Request $request)
|
|
{
|
|
$codes = $this->qnaService->getEnquiryCodes();
|
|
|
|
return view('web.cs.qna.index', [
|
|
'enquiryCodes' => $codes, // select 옵션
|
|
]);
|
|
}
|
|
|
|
// 등록
|
|
public function store(Request $request)
|
|
{
|
|
// legacy.auth가 세션에 _sess 넣어준다 했으니 그대로 사용
|
|
$sess = (array) $request->session()->get('_sess', []);
|
|
$memNo = (int)($sess['_mno'] ?? 0);
|
|
|
|
if ($memNo <= 0) {
|
|
abort(403);
|
|
}
|
|
|
|
/**
|
|
* 0) 레이트리밋(봇/대량 등록 방어)
|
|
* - 회원번호 + IP 기준으로 1분당 5회, 10분당 20회 같은 식으로 강하게 제한
|
|
*/
|
|
$ip = (string) $request->ip();
|
|
$key = 'qna:create:' . $memNo . ':' . $ip;
|
|
|
|
if (RateLimiter::tooManyAttempts($key, 5)) {
|
|
abort(429, '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.');
|
|
}
|
|
RateLimiter::hit($key, 60); // 60초
|
|
|
|
|
|
$rules = [
|
|
'enquiry_code' => ['required', 'string', 'max:20'],
|
|
'enquiry_title' => ['required', 'string', 'max:60'],
|
|
'enquiry_content' => ['required', 'string', 'max:5000'],
|
|
'return_type' => ['nullable', Rule::in(['sms', 'email'])],
|
|
'hp' => ['nullable', 'size:0'], //봇공격 대비
|
|
];
|
|
|
|
if (app()->environment('production')) {
|
|
$rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('cs_qna_create')];
|
|
}
|
|
|
|
$validated = $request->validate($rules);
|
|
|
|
/**
|
|
* 2) 정규화/무해화 (XSS/스크립트/이상 문자 방어)
|
|
* - strip_tags는 기본, 추가로 제어문자 제거 + 공백정리
|
|
* - HTML은 아예 저장하지 않는 정책이면 가장 안전
|
|
*/
|
|
$validated['enquiry_title'] = $this->sanitizePlainText(
|
|
(string) $validated['enquiry_title'],
|
|
60
|
|
);
|
|
|
|
$validated['enquiry_content'] = $this->sanitizePlainText(
|
|
(string) $validated['enquiry_content'],
|
|
5000
|
|
);
|
|
|
|
/**
|
|
* 3) “의심 입력” 즉시 차단(선택)
|
|
* - 운영 경험상 스크립트가 박힌 적이 있다 했으니,
|
|
* script 태그/iframe/onerror/javascript: 같은 패턴은 바로 차단하는게 낫다.
|
|
*/
|
|
$badField = null;
|
|
if ($this->looksMalicious($validated['enquiry_title'])) $badField = 'enquiry_title';
|
|
if ($this->looksMalicious($validated['enquiry_content'])) $badField = $badField ?: 'enquiry_content';
|
|
|
|
|
|
if ($badField) {
|
|
return redirect()->route('web.cs.qna.index')
|
|
->with('ui_dialog', [
|
|
'type' => 'alert',
|
|
'title' => '안내',
|
|
'message' => '특수 스크립트/태그가 포함되어 등록할 수 없습니다. HTML/스크립트 없이 내용만 작성해 주세요.',
|
|
]);
|
|
}
|
|
|
|
$sess = (array) $request->session()->get('_sess', []);
|
|
|
|
$validated['_user_email'] = (string)($sess['_mid'] ?? '');
|
|
$validated['_user_name'] = (string)($sess['_mname'] ?? '');
|
|
$validated['_user_cell_enc'] = (string)($sess['_mcell'] ?? '');
|
|
$validated['_ip'] = (string)($sess['_ip'] ?? $request->ip());
|
|
|
|
|
|
// 등록 (현재 연도 테이블에 저장)
|
|
$this->qnaService->createQna($memNo, $validated);
|
|
|
|
// 등록 후 내 문의내역으로 이동
|
|
return redirect()->route('web.mypage.qna.index')
|
|
->with('ui_dialog', [
|
|
'type' => 'alert',
|
|
'title' => '안내',
|
|
'message' => '문의가 등록되었습니다.',
|
|
]);
|
|
}
|
|
|
|
private function sanitizePlainText(string $s, int $maxLen): string
|
|
{
|
|
// 1) 유니코드 정규화(가능하면) - 없으면 패스
|
|
if (class_exists(\Normalizer::class)) {
|
|
$s = \Normalizer::normalize($s, \Normalizer::FORM_C) ?: $s;
|
|
}
|
|
|
|
// 2) 태그 제거(HTML 저장 자체를 금지하는 정책)
|
|
$s = strip_tags($s);
|
|
|
|
// 3) 제어문자 제거(줄바꿈/탭은 허용)
|
|
// \p{C}는 Control chars 포함. \r\n\t는 남김.
|
|
$s = preg_replace('/[\p{C}&&[^\r\n\t]]/u', '', $s) ?? $s;
|
|
|
|
// 4) 공백 정리(연속 스페이스/줄바꿈 폭발 방지)
|
|
$s = preg_replace("/[ \t]+/u", ' ', $s) ?? $s;
|
|
$s = preg_replace("/\n{4,}/u", "\n\n\n", $s) ?? $s;
|
|
|
|
$s = trim($s);
|
|
|
|
// 5) 길이 자르기(멀티바이트 안전)
|
|
return Str::limit($s, $maxLen, '');
|
|
}
|
|
|
|
private function looksMalicious(string $s): bool
|
|
{
|
|
$lower = Str::lower($s);
|
|
|
|
// 너무 공격적으로 잡으면 정상 텍스트도 막을 수 있으니, “확실한 것만”
|
|
$patterns = [
|
|
'<script', '</script', '<iframe', '<object', '<embed',
|
|
'javascript:', 'data:text/html', 'vbscript:',
|
|
'onerror=', 'onload=', 'onclick=', 'onmouseover=',
|
|
];
|
|
|
|
foreach ($patterns as $p) {
|
|
if (str_contains($lower, $p)) return true;
|
|
}
|
|
|
|
// 특수한 XSS 벡터 (html entity 우회)도 일부 방어
|
|
if (preg_match('/&#x?0*3c;.*script/i', $s)) return true; // "<script" 엔티티 우회
|
|
|
|
return false;
|
|
}
|
|
}
|