Origin/UA 검증, 1회용 CSRF, 레이트리밋, Turnstile, reCAPTCHA v3. 한 겹이 뚫릴 때마다 다음 겹을 덧댄 기록과, 그 과정에서 만난 502와 IP 스푸핑 이야기입니다.
도입: 문의 폼 하나에 다섯 겹이 붙기까지
aivance.kr 메인을 개편하면서 문의 폼을 새로 달았습니다. 이름, 이메일, 문의 유형, 내용, 개인정보 동의만 받는 평범한 폼입니다. 접수되면 contact_inquiries 테이블에 저장하고 관리자에게 메일을 보냅니다.
처음 명세서에는 CSRF 필터, 허니팟 필드, IP당 Throttler(시간당 5회) 정도만 적었습니다. 1인 회사 홈페이지 문의 폼에 그 이상이 필요할 거라고는 생각하지 않았습니다.
폼이 공개되자 상황이 달라졌습니다. 스팸 문의가 들어올 때마다 관리자 메일도 함께 날아왔습니다. 막으면 다음 날 다른 방식으로 들어왔습니다. 결국 방어층이 다섯 겹이 됐습니다. 아래는 뚫린 순서대로 정리한 기록이고, 마지막에 실제로 검사하는 순서를 따로 정리했습니다. 두 순서가 다르다는 것이 이 글의 핵심 중 하나입니다.
1겹: Origin/UA 검증 — 헤더는 상대가 쓰는 값이다
첫 스팸은 브라우저를 거치지 않은 단순 POST였습니다. Origin이 비어 있거나 다른 도메인이었고, User-Agent는 python-requests나 curl 같은 값이었습니다. 그래서 가장 싼 방어부터 걸었습니다.
// app/Filters/ContactOriginFilter.php
public function before(RequestInterface $request, $arguments = null)
{
$origin = $request->getHeaderLine('Origin');
$ua = $request->getUserAgent()->getAgentString();
$allowed = ['https://aivance.kr', 'https://www.aivance.kr'];
if (! in_array($origin, $allowed, true)
|| $ua === ''
|| preg_match('/curl|python|wget|httpclient|go-http/i', $ua)) {
return service('response')->setStatusCode(403);
}
}
며칠은 조용했습니다. 그다음 들어온 요청은 Origin: https://aivance.kr에 평범한 크롬 UA를 달고 있었습니다.
뚫린 이유: Origin과 UA는 브라우저가 붙여 주는 값이지만, 브라우저가 아닌 클라이언트는 아무 값이나 넣을 수 있습니다. 이 검사는 "아무 설정 없이 쏘는 스크립트"만 거릅니다. 그래도 지우지 않았습니다. 비용이 거의 0이고, 가장 게으른 봇을 맨 앞에서 떨궈 뒤쪽 검사의 부하를 줄여 주기 때문입니다.
2겹: 1회용 CSRF 토큰 — 폼을 먼저 열어야만 보낼 수 있게
헤더를 위조하는 봇이라면, 적어도 폼 페이지를 실제로 열어야만 보낼 수 있게 만들자고 생각했습니다. CI4 기본 CSRF는 세션 단위 토큰이라 한 번 얻으면 여러 번 재사용할 수 있습니다. 그래서 폼을 렌더할 때마다 새 토큰을 발급하고, 제출 시 한 번 쓰면 지우는 방식으로 바꿨습니다. 발급 시각도 함께 넣어, 사람이 입력할 수 없을 만큼 빠른 제출은 거절했습니다.
// 폼 렌더 시
$token = bin2hex(random_bytes(32));
session()->set('contact_form', ['token' => $token, 'issued' => time()]);
// 제출 시
$saved = session('contact_form');
session()->remove('contact_form'); // 성공·실패와 무관하게 즉시 소모
if ($saved === null
|| ! hash_equals($saved['token'], (string) $this->request->getPost('form_token'))
|| time() - $saved['issued'] < 3 // 3초 안에 다 쓰는 사람은 없다
|| time() - $saved['issued'] > 1800) { // 30분 지난 폼은 만료
return $this->failInvalidForm();
}
재사용과 직접 POST는 이걸로 막혔습니다. 그런데 얼마 뒤, 토큰이 정상이고 입력 시간도 몇 초 이상인 스팸이 다시 들어왔습니다.
뚫린 이유: 봇이 사람처럼 움직이기 시작했습니다. GET으로 폼을 받아 토큰을 긁고, 쿠키를 유지한 채 몇 초 기다렸다가 POST합니다. 헤드리스 브라우저라면 이 과정이 아예 자동입니다. CSRF는 원래 **"다른 사이트가 사용자 몰래 요청을 보내는 것"**을 막는 장치입니다. "우리 사이트를 직접 연 자동화 도구"는 애초에 막도록 만들어진 게 아닙니다. 그래도 1회용 토큰 덕분에 한 번의 제출마다 한 번의 GET이 강제됐고, 이 비용이 다음 겹에서 쓸모가 있었습니다.
3겹: 레이트리밋 — 그리고 IP는 누가 정하는가
토큰 검사를 통과하는 봇이라면 횟수로 막으면 됩니다. CI4 Throttler로 IP당 시간당 5회를 걸었습니다.
$ip = $this->request->getIPAddress();
$key = 'contact_' . md5($ip); // 캐시 키에 쓸 수 없는 문자 제거
if (! service('throttler')->check($key, 5, HOUR)) {
return $this->failTooManyRequests();
}
한동안은 이걸로 잠잠했습니다. 그런데 로그를 보다가 이상한 점을 발견했습니다. 같은 패턴의 스팸이 요청마다 다른 IP로 기록되고 있었습니다. 프록시 풀을 돌리는 봇이라고 생각했는데, 찍힌 IP 중에 1.1.1.1, 127.0.0.1 같은 값이 섞여 있었습니다.
원인: CF-Connecting-IP를 무조건 믿었다
당시 서버는 Cloudflare 뒤에 있었고, 실제 방문자 IP를 얻으려고 CF-Connecting-IP 헤더를 읽고 있었습니다. 문제는 그 헤더가 Cloudflare를 거친 요청에만 붙는다는 보장이 없다는 것입니다.
오리진 IP를 아는 공격자는 Cloudflare를 건너뛰고 서버에 직접 요청을 보낼 수 있습니다. 그때 CF-Connecting-IP: 아무값을 직접 넣으면, 서버는 그 값을 방문자 IP로 믿습니다. 요청마다 값을 바꾸면 IP당 레이트리밋은 아무것도 세지 못합니다. 레이트리밋이 뚫린 게 아니라, 레이트리밋의 기준(IP)이 위조되고 있었던 것입니다.
수정: 헤더를 믿을 조건을 붙인다
원칙은 하나입니다. 직전 홉(REMOTE_ADDR)이 Cloudflare 대역일 때만 CF-Connecting-IP를 믿는다. CI4 4.4부터는 proxyIPs에 "이 대역에서 온 요청이면 이 헤더를 믿어라"를 직접 적을 수 있습니다.
// app/Config/App.php
public array $proxyIPs = [
'173.245.48.0/20' => 'CF-Connecting-IP',
'103.21.244.0/22' => 'CF-Connecting-IP',
// ... https://www.cloudflare.com/ips/ 의 IPv4·IPv6 대역 전체
];
이렇게 하면 $this->request->getIPAddress()가 알아서 판단합니다. Cloudflare 대역이 아닌 곳에서 온 요청은 헤더를 무시하고 실제 접속 IP를 씁니다. 직접 $_SERVER['HTTP_CF_CONNECTING_IP']를 읽던 코드는 전부 지웠습니다.
여기에 두 가지를 더했습니다.
- 방화벽에서 80/443을 Cloudflare 대역에만 연다. 오리진 직접 접속 자체를 막는 것이 근본 대책입니다. nginx를 쓴다면
set_real_ip_from에 Cloudflare 대역을 적고real_ip_header CF-Connecting-IP를 함께 쓰는 것이 같은 원리입니다. - 대역 목록을 갱신한다. Cloudflare 대역은 바뀔 수 있으므로, 목록을 받아 설정을 다시 만드는 스크립트를 정기 작업으로 돌립니다.
참고로 이후 jblove.net은 한국 트래픽이 해외 PoP로 빠지는 문제 때문에 Cloudflare를 DNS only로 돌렸습니다. 이렇게 프록시를 끄면
CF-Connecting-IP설정도 함께 걷어내야 합니다. 프록시가 없는데 헤더를 믿는 설정이 남아 있으면, 위와 똑같은 구멍이 다시 열립니다.
4겹: Turnstile — 막았더니 서버가 먼저 쓰러졌다
IP를 제대로 세게 됐어도, 실제 프록시 풀을 쓰는 봇은 IP당 5회 안에서 계속 들어옵니다. 이제는 "사람인가"를 직접 물어야 했습니다. 사용자 마찰이 거의 없는 Cloudflare Turnstile을 붙였습니다. 프론트에서 위젯이 토큰을 만들고, 서버는 그 토큰을 siteverify로 확인합니다.
$res = service('curlrequest')->post(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
['form_params' => [
'secret' => env('turnstile.secret'),
'response' => $this->request->getPost('cf-turnstile-response'),
'remoteip' => $ip,
]]
);
$body = json_decode($res->getBody(), true);
if (($body['success'] ?? false) !== true
|| ($body['hostname'] ?? '') !== 'aivance.kr') {
return $this->failBot();
}
스팸은 눈에 띄게 줄었습니다. 그런데 며칠 뒤 사이트 전체가 간헐적으로 502 Bad Gateway를 내기 시작했습니다. 문의 폼만이 아니라 메인 페이지까지였습니다.
502의 원인: 검사 순서
당시 컨트롤러는 이 순서로 검사하고 있었습니다.
- Origin/UA
- 1회용 CSRF
- Turnstile
siteverify(외부 HTTP 호출) - 레이트리밋
레이트리밋을 "사람인지 확인한 뒤 횟수를 센다"는 생각으로 뒤에 둔 것이 실수였습니다. 봇이 몰려오면 모든 요청이 레이트리밋에 닿기 전에 외부 API 호출부터 했습니다.
- PHP-FPM 워커 하나가 요청 하나를 맡고, 외부 응답이 올 때까지 그대로 묶여 있습니다.
- 타임아웃을 따로 주지 않아, 응답이 늦으면 워커가 몇 초씩 잡혀 있었습니다.
- 워커 풀이 바닥나자 웹서버가 FPM에 요청을 넘기지 못했고, 사이트 전체가 502를 냈습니다.
봇 차단 장치가 오히려 봇에게 서버를 마비시키는 손잡이를 쥐여 준 꼴이었습니다. 실패할 요청일수록 가장 비싼 일을 하고 있었습니다.
수정: 싼 검사를 앞으로, 외부 호출에는 타임아웃
- 레이트리밋을 외부 호출보다 먼저 검사한다. 레이트리밋은 로컬 캐시 조회 한 번이면 끝납니다. 횟수를 넘긴 요청은 외부 API를 부르기 전에 429로 돌려보냅니다.
- 외부 호출에 짧은 타임아웃을 준다.
['timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false]를 넣었습니다. 검증 서버가 느리면 기다리지 않고 실패로 처리합니다. - 실패한 시도도 횟수에 넣는다. 통과한 제출만 세면 봇은 실패를 공짜로 반복합니다. 레이트리밋은 결과와 상관없이 "시도"를 셉니다.
바꾼 뒤로 502는 다시 나지 않았습니다. 이 경험이 마지막에 정리한 검사 순서의 원칙이 됐습니다.
5겹: reCAPTCHA v3 — 통과/실패가 아니라 점수로
Turnstile을 통과한 스팸도 드물게 남았습니다. 헤드리스 브라우저로 챌린지를 푸는 봇이나 사람이 대신 풀어 주는 서비스를 쓰면, 통과/실패 한 번의 판정으로는 다 걸러지지 않습니다.
그래서 성격이 다른 검사를 하나 더 겹쳤습니다. reCAPTCHA v3는 사용자에게 아무것도 묻지 않고, 페이지에서의 행동을 보고 0.0~1.0 사이의 점수를 돌려줍니다. 서버에서 확인할 것은 세 가지입니다.
$body = $this->verify('https://www.google.com/recaptcha/api/siteverify', [
'secret' => env('recaptcha.secret'),
'response' => $this->request->getPost('g-recaptcha-response'),
]);
$ok = ($body['success'] ?? false) === true
&& ($body['action'] ?? '') === 'contact_submit' // 다른 페이지 토큰 재활용 차단
&& ($body['hostname'] ?? '') === 'aivance.kr'
&& ($body['score'] ?? 0) >= 0.5;
점수를 다루면서 정한 원칙이 있습니다.
- 낮은 점수를 곧바로 차단하지 않는다. 경계 구간(예: 0.3~0.5)은 저장은 하되 관리자 메일을 보내지 않고 "검토 필요"로 표시합니다. 진짜 고객의 문의를 버리는 것이 스팸 몇 건보다 비쌉니다.
- 임계값은 로그를 보고 정한다. 처음 2주는 차단 없이 점수만 기록했습니다. 실제 문의와 스팸의 점수 분포를 본 뒤에 기준을 정했습니다.
- action 이름을 반드시 확인한다. 이걸 빼면 점수가 높게 나오는 다른 페이지에서 받은 토큰을 문의 폼에 재사용할 수 있습니다.
두 캡차를 함께 쓰는 것이 과하다고 볼 수도 있습니다. 하지만 둘은 하는 일이 다릅니다. Turnstile은 "이 요청이 챌린지를 통과했나"를 묻고, reCAPTCHA v3는 "이 세션이 사람처럼 행동했나"를 묻습니다. 한쪽을 뚫는 방법이 다른 쪽까지 뚫지는 못합니다.
최종 구성: 뚫린 순서와 검사 순서는 다르다
방어층은 뚫린 순서대로 쌓였지만, 검사는 싼 것부터, 외부 호출은 맨 뒤에 둡니다. 실패할 요청이 비싼 일을 하지 않게 하는 것이 원칙입니다.
|
검사 순서 |
방어층 |
추가된 순서 |
비용 |
걸러내는 대상 |
|---|---|---|---|---|
|
1 |
Origin/UA 검증 |
1 |
헤더 비교 |
설정 없이 쏘는 스크립트 |
|
2 |
레이트리밋 (신뢰 IP 기준) |
3 |
로컬 캐시 1회 |
반복 시도, 폭주 |
|
3 |
1회용 CSRF + 입력 시간 |
2 |
세션 조회 |
직접 POST, 토큰 재사용, 즉시 제출 |
|
4 |
Turnstile |
4 |
외부 HTTP |
챌린지를 못 푸는 자동화 |
|
5 |
reCAPTCHA v3 점수 |
5 |
외부 HTTP |
사람처럼 굴지 않는 세션 |
컨트롤러에 흩어져 있던 검사를 서비스 하나로 모았습니다. 순서가 코드 한 곳에서 보여야 다음에 누가(AI든 사람이든) 고쳐도 순서가 깨지지 않습니다.
// app/Services/ContactGuard.php
final class ContactGuard
{
public function check(IncomingRequest $request): ?string
{
// 1. 헤더 — 비용 0
if (! $this->originAndUaOk($request)) {
return 'origin';
}
// 2. 레이트리밋 — 외부 호출보다 반드시 먼저, 결과와 무관하게 시도를 센다
$ip = $request->getIPAddress(); // proxyIPs 설정을 거친 신뢰 IP
if (! service('throttler')->check('contact_' . md5($ip), 5, HOUR)) {
return 'rate';
}
// 3. 1회용 폼 토큰 + 입력 시간
if (! $this->consumeFormToken($request)) {
return 'token';
}
// 4~5. 외부 검증 — 짧은 타임아웃, 실패 시 거절
if (! $this->turnstileOk($request, $ip)) {
return 'turnstile';
}
$score = $this->recaptchaScore($request);
if ($score < 0.3) {
return 'recaptcha';
}
$request->setGlobal('post', [...$request->getPost(), '_needs_review' => $score < 0.5]);
return null; // 통과
}
}
반환값은 어느 겹에서 걸렸는지를 나타냅니다. 이 값을 로그에 남기면 "요즘은 몇 번째 겹에서 걸리는가"를 볼 수 있습니다. 다음 겹이 필요해지는 시점을 가장 먼저 알려 주는 지표입니다.
테스트도 겹마다 하나씩 둡니다. 특히 아래 두 가지는 꼭 넣었습니다.
- 레이트리밋을 넘긴 요청에서 Turnstile·reCAPTCHA 클라이언트가 한 번도 호출되지 않는다 (목 객체 호출 횟수 0)
- Cloudflare 대역이 아닌 IP에서
CF-Connecting-IP를 보내면 그 값이 무시된다
돌아보며
- 어느 한 겹도 완벽하지 않다. 다섯 겹 모두 뚫는 방법이 있습니다. 목표는 완벽한 차단이 아니라, 봇 한 건을 보내는 비용을 문의 한 건의 가치보다 비싸게 만드는 것입니다.
- 뚫린 겹도 지우지 않는다. Origin/UA 검증은 가장 먼저 뚫렸지만 지금도 맨 앞에서 가장 많은 요청을 거릅니다. 싼 검사가 앞에서 걸러 줄수록 뒤쪽의 비싼 검사가 편해집니다.
- 검사 순서는 비용 순서다. 추가한 순서대로 코드를 붙이면, 나중에 붙인 비싼 검사가 앞에 오기 쉽습니다. 502는 그렇게 생겼습니다. 외부 호출은 항상 마지막이고, 항상 타임아웃이 있어야 합니다.
- 레이트리밋은 기준값이 정확할 때만 의미가 있다. IP 하나를 잘못 믿으면 레이트리밋 전체가 무력해집니다. 프록시 헤더는 직전 홉이 그 프록시일 때만 믿습니다. 인프라를 바꿀 때(Cloudflare를 켜고 끌 때) 이 설정도 같이 바꿔야 합니다.
- 어디서 걸렸는지 남긴다. 겹별 차단 로그가 있으니 다음 공격의 모양을 미리 볼 수 있었습니다. 로그가 없었다면 502의 원인을 찾는 데 훨씬 오래 걸렸을 것입니다.
문의 폼은 작은 기능이지만, 외부에 열린 입력은 크기와 상관없이 공격 표면입니다. 같은 ContactGuard 구조는 이후 다른 제품의 회원가입·비밀번호 재설정 폼에도 그대로 옮겨 쓸 수 있습니다.
댓글 0개