Cursor가 속도 제한(Rate Limiting) 없는 로그인 엔드포인트를 작성하는 이유
요약
Cursor와 Claude Code가 생성한 로그인 엔드포인트가 무차별 대입 공격(CWE-307)에 취약할 수 있음을 경고합니다. AI는 요청된 기능 구현에 집중하느라 인프라 계층의 속도 제한(Rate Limiting) 설정을 누락하는 경향이 있습니다.
핵심 포인트
- AI 생성 코드는 기능적으로 완벽해도 보안 설정(Rate Limiting)이 누락될 수 있음
- bcrypt는 속도 조절 도구일 뿐, 무차별 대입 공격을 막는 벽이 아님
- 보안을 위해 경로별 속도 제한기와 계정별 실패 카운터 도입이 필수적임
- AI 모델의 학습 데이터 특성상 운영 환경의 보안 고려 사항이 생략되기 쉬움
요약 (TL;DR)
- Cursor와 Claude Code가 생성한 모든 로그인 엔드포인트에는 무차별 대입 공격 방지(CWE-307) 기능이 전혀 없었습니다.
- 유출된 비밀번호 목록을 가진 공격자는 완벽하게 올바른 엔드포인트를 대상으로 분당 수천 번의 추측을 실행할 수 있으며, 아무것도 이를 막거나 발생 사실을 알려주지 않습니다.
- 해결 방법은 약 10줄 정도면 충분합니다: 경로(route)에 속도 제한기(rate limiter)를 추가하고, IP 교체(IP rotation)에도 유지되는 계정별 실패 카운터를 도입하는 것입니다.
지난달 저는 Claude Code에게 로그인 엔드포인트를 구축해 달라고 요청했습니다. 결과물은 적절한 비용 인자(cost factor)를 가진 bcrypt, 만료 시간이 포함된 서명된 JWT, 그리고 이메일 존재 여부를 유출하지 않는 일반적인 "잘못된 자격 증명(invalid credentials)" 메시지를 포함하고 있었습니다.
견고한 코드였습니다. 밤 11시에 제가 직접 작성했을 코드보다 더 나았습니다.
그런 다음 저는 스크립트를 해당 엔드포인트에 겨냥하여 2분도 채 되지 않아 4,000번의 비밀번호 추측을 실행했습니다. 엔드포인트는 그 모든 요청에 응답했습니다.
취약한 코드
버그는 이 코드가 무엇을 하느냐에 있는 것이 아닙니다. 이 코드가 '결코 하지 않는 것'에 있습니다. 즉, 동일한 클라이언트가 호출할 수 있는 횟수를 제한하는 것이 어디에도 없다는 점입니다. 이것이 바로 과도한 인증 시도에 대한 부적절한 제한인 CWE-307입니다.
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
...
실수를 찾기 위해 이 코드를 읽어봐도 찾을 수 없을 것입니다. 해싱(hashing)은 올바릅니다. 에러 메시지는 사용자 존재 여부를 유출하지 않습니다. 토큰은 만료됩니다. 단지 시도 횟수에 대한 상한선(ceiling)이 없을 뿐입니다.
사람들은 종종 bcrypt가 여기서 방어 수단이라고 주장하며, 실제로 도움이 되기는 합니다. 각 추측은 서버의 CPU를 약 250ms 정도 소모합니다. 하지만 그것은 조절 장치(throttle)이지 벽(wall)이 아닙니다. 50개의 요청을 병렬로 날리면 초당 수백 번의 추측이 가능해지며, 다만 이제는 모든 추측이 귀하의 CPU까지 갉아먹게 될 뿐입니다. 보호되지 않은 로그인 경로는 동시에 서비스 거부(denial-of-service) 공격의 지렛대가 됩니다.
이런 일이 계속 발생하는 이유
Rate limiting (속도 제한)은 당신이 요청한 파일과는 다른 파일에 존재하며, 모델은 당신이 요청한 파일만을 작성합니다. "로그인 엔드포인트(login endpoint)를 만들어줘"라고 프롬프트를 입력하면, 당신은 로그인 엔드포인트를 얻게 됩니다. 즉, 자격 증명(credentials)을 받아 토큰(token)을 반환하는 라우트 핸들러(route handler)를 얻는 것이죠. Rate limiting은 라우트를 감싸는 인프라(infrastructure)이며, 어떤 튜토리얼도 이를 핸들러 내부에 넣지 않습니다.
학습 데이터가 어디서 오는지 살펴보십시오. 인증(Auth) 튜토리얼은 독자가 마지막 문단에 도달했을 때 작동하는 로그인을 갖도록 최적화되어 있습니다. Rate limiting은 의존성(dependency), 공유 저장소(shared store), 그리고 프로세스를 하나 이상 실행하는 순간 인메모리 카운터(in-memory counters)가 왜 깨지는지에 대한 어색한 부연 설명을 추가합니다. 그래서 이 내용은 삭제되거나, 대부분의 독자가 건너뛰고 대부분의 스크레이퍼(scraper)가 관련 없는 콘텐츠로 취급하는 하단의 "운영 시 고려 사항(production considerations)" 섹션으로 추방됩니다.
두 번째 이유가 있으며, 이는 다소 불편한 사실입니다. Rate limiting이 누락되어도 아무런 증상이 나타나지 않습니다. 하드코딩된 비밀키(secret)는 diff(차이점)에 나타납니다. SQL 인젝션(SQL injection)은 작은따옴표(')에서 깨집니다. 무차별 대입 공격(brute-force) 보호가 없는 엔드포인트는 완벽하게 작동하며, 당신이 작성한 모든 테스트를 통과하고, 당신이 실제 사용자인지 아니면 자동화된 스크립트의 만 번째 추측인지 여부와 상관없이 동일하게 동작합니다. 당신은 테스트 스위트(test suite)가 아니라 자격 증명 스터핑(credential-stuffing) 사고를 통해서야 이 사실을 알게 됩니다.
해결 방법 (The Fix)
두 개의 계층이 필요합니다. 라우트에 적용된 rate limiter는 빠른 공격을 차단하고, 계정당 실패 카운터(per-account failure counter)는 IP를 교체하며 진행되는 느린 공격을 차단합니다.
라우트 리미터(The route limiter):
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
...
skipSuccessfulRequests 옵션은 보기보다 중요합니다. 이 옵션이 없으면, 공유 오피스 IP를 사용하는 실제 사용자 한 명이 해당 NAT 뒤에 있는 다른 모든 사람의 할당량을 소진해 버립니다.
IP 리미터만으로는 충분하지 않습니다. 자격 증명 스터핑 공격은 주거용 프록시(residential proxies)를 통해 순환하며 주소를 재사용하는 경우가 드물기 때문에, IP당 카운팅(per-IP counting)은 단지 만 번의 첫 번째 시도만을 보게 될 뿐입니다. 계정당 실패 횟수도 함께 카운트하십시오:
const key = `login:fail:${user.id}`;
const fails = await redis.incr(key);
if (fails === 1) await redis.expire(key, 900);
...
Python의 경우도 구조는 동일합니다:
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
...
사람들이 흔히 놓치는 주의사항이 하나 있습니다. 두 라이브러리 모두 기본값인 인메모리 저장소(in-memory store)는 재시작 시 초기화되며 프로세스 간에 아무것도 공유하지 않습니다. 로드 밸런서(load balancer) 뒤에서 네 개의 인스턴스를 실행한다면, 공격자는 조용히 제한치의 4배를 사용할 수 있으며, 매 배포(deploy)마다 공격자에게 새로운 할당량을 제공하게 됩니다. 반드시 Redis를 사용하십시오.
FAQ
Q: bcrypt가 자체적으로 무차별 대입 공격(brute force)을 방어하나요?
A: 아니요. bcrypt는 각 시도의 비용을 높여주지만(cost 12 기준 약 250ms), 공격자가 요청을 병렬로 실행하면 여전히 초당 수백 번의 추측을 수행할 수 있습니다. 또한 이는 모든 시도가 CPU를 소모한다는 것을 의미하므로, 제한 없는 로그인 경로는 서비스 거부(denial-of-service, DoS) 벡터로도 작용할 수 있습니다.
Q: IP 기반의 속도 제한(rate limiting)만으로 충분한가요?
A: 그것만으로는 부족합니다. 자격 증명 스터핑(credential-stuffing) 도구들은 주거용 프록시 풀(residential proxy pools)을 통해 IP를 교체하므로, IP당 카운터는 중복된 주소를 거의 식별하지 못합니다. IP 제한을 계정당 실패 카운터와 결합하여 약 5회 실패 시 계정을 잠그도록 설정하고, 재시작이나 다중 프로세스 환경에서도 유지될 수 있도록 Redis와 같은 공유 저장소에 카운트를 보관하십시오.
Q: 로그인 외에 어떤 엔드포인트(endpoints)에 이것이 필요한가요?
A: 비밀번호 재설정, 이메일 인증, OTP 또는 2FA(2단계 인증) 검증, 그리고 회원가입입니다. 비밀 값을 확인하거나 이메일 또는 SMS를 트리거하는 모든 곳이 해당됩니다. OTP 엔드포인트가 가장 최악의 사례입니다. 6자리 코드는 백만 개의 조합을 가지며, 제한이 없다면 단 몇 분 만에 모든 조합을 추측할 수 있습니다.
저는 이를 위해 SafeWeave를 실행해 왔습니다. 이 도구는 MCP (Model Context Protocol) 서버로서 Cursor 및 Claude Code에 연결되며, 제가 다음 단계로 넘어가기 전에 속도 제한 (Rate Limiting)이 없는 인증 경로 (auth routes)를 보안 상태 점검 (posture check)을 통해 식별해 냅니다. 이것은 대부분의 취약점보다 grep 방식의 도구로 잡아내기가 더 어려운데, 왜냐하면 일치하는 '나쁜 패턴'이 있는 것이 아니라, 있어야 할 패턴이 '부재'하는 것이기 때문입니다. 어떤 도구를 사용하든 중요한 것은 자격 증명 스터핑 (credential-stuffing) 경고가 발생한 후가 아니라, 엔드포인트가 작성되는 시점에 확인하는 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기