giftcon_dev/app/Http/Controllers/Web/Cs/CsQnaController.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;
}
}