Cloudflare의 AI가 작성한 OAuth 라이브러리에 대한 리뷰
TMT오늘 Cloudflare의 새로운 OAuth 제공자 라이브러리를 살펴보았습니다. 이 라이브러리는 대부분 Anthropic의 Claude LLM을 사용해 작성되었다고 합니다.
이 라이브러리(스키마 문서 포함)는 대부분 Anthropic의 AI 모델인 Claude의 도움으로 작성되었습니다. Claude의 출력물은 Cloudflare 엔지니어들이 보안과 표준 준수를 꼼꼼히 검토했습니다. 대부분의 개선도 Claude를 다시 프롬프트하여 이루어졌고, 결과를 리뷰했습니다. Claude가 어떻게 프롬프트되었고 어떤 코드를 생성했는지 커밋 히스토리를 확인해보세요.
[…]
강조하자면, 이건 “바이브코딩(vibe coded)” 것이 아닙니다. 모든 줄이 관련 RFC와 교차 검토되었고, 해당 RFC 경험이 있는 보안 전문가들이 꼼꼼히 리뷰했습니다. 저는 회의적인 시각을 검증하려 했지만, 오히려 제 생각이 틀렸음을 증명하게 됐습니다.
저도 최근 LLM을 활용한 “에이전트식(agentic)” 코딩을 꽤 해봤습니다. 저는 OAuth 전문가로, 『API Security in Action』을 집필했고, IETF의 OAuth 워킹그룹에서 활동했으며, 주요 OAuth 제공자의 기술 리드와 보안 아키텍트도 맡았었습니다. (AI 박사 학위도 있지만, 현재의 머신러닝 붐 이전 이야기입니다.) 그래서 이 라이브러리가 어떻게 만들어졌는지 매우 궁금했습니다. 회의 중에 잠깐 살펴봤고, 전체 리뷰는 아니지만 몇 가지 버그를 발견했습니다.
처음에는 코드가 꽤 인상적이었습니다. 코드가 한 파일에 다 들어있긴 하지만(LLM 코딩에서 흔한 일), 구조가 잘 잡혀 있고 LLM이 흔히 남발하는 쓸데없는 주석도 많지 않았으며, 실제 클래스와 상위 구조도 있었습니다.
테스트도 있긴 한데, 중요한 인증 서비스에 기대하는 수준에는 한참 못 미칩니다. 스펙의 모든 MUST와 MUST NOT을 테스트하는 것이 최소한인데, 그런 건 보이지 않고 기본 기능 테스트만 있습니다. (코드를 대충 봤을 때, 파라미터 검증 등에서 누락된 MUST 체크가 꽤 있을 것 같습니다.)
가장 먼저 눈에 띈 것은 제가 “YOLO CORS”라고 부르는 부분입니다. 이는 드물지 않게 보이는데, 모든 오리진에 대해 동일 출처 정책을 거의 무력화하는 CORS 헤더를 설정하는 방식입니다.
private addCorsHeaders(response: Response, request: Request): Response {
// Get the Origin header from the request
const origin = request.headers.get('Origin');
// If there's no Origin header, return the original response
if (!origin) {
return response;
}
// Create a new response that copies all properties from the original response
// This makes the response mutable so we can modify its headers
const newResponse = new Response(response.body, response);
// Add CORS headers
newResponse.headers.set('Access-Control-Allow-Origin', origin);
newResponse.headers.set('Access-Control-Allow-Methods', '*');
// Include Authorization explicitly since it's not included in * for security reasons
newResponse.headers.set('Access-Control-Allow-Headers', 'Authorization, *');
newResponse.headers.set('Access-Control-Max-Age', '86400'); // 24 hours
return newResponse;
}
이런 방식이 괜찮은 경우도 있지만, 왜 이렇게 했는지 자세히 보진 않았습니다. 다만 매우 의심스럽습니다. 이런 식으로 할 필요는 거의 없습니다. 커밋 로그를 보면 이 부분은 LLM이 아니라 사람이 결정한 것으로 보입니다. 적어도 credentials는 활성화하지 않았으니, 보통 이런 설정에서 발생하는 문제는 해당되지 않을 것 같습니다.
헤더 얘기가 나와서 말인데, 응답에 표준 보안 헤더가 거의 없습니다. 많은 헤더가 API에는 적용되지 않지만, 일부는 적용됩니다(그리고 종종 예상치 못한 방식으로). 예를 들어, 제 책에서는 JSON API에 대한 XSS 취약점을 어떻게 악용할 수 있는지 보여줍니다. 잘 만들어진 JSON을 반환한다고 해서 브라우저가 반드시 그렇게 해석하는 것은 아닙니다. Cloudflare Workers에 익숙하지 않아서, 혹시 기본적으로 이런 헤더를 추가하는지 모르겠지만, 최소한 X-Content-Type-Options: nosniff
헤더와 HTTP Strict Transport Security는 기대했을 것 같습니다.
코드에는 이상한 선택들도 있고, OAuth 스펙에 익숙하지 않은 사람들이 작성한 것 같은 부분도 있습니다. 예를 들어, 이 커밋에서는 public client 지원을 추가했는데, OAuth 2.1에서 제거된 “implicit” grant(암시적 승인)를 구현했습니다. public client를 지원하려면 이게 필요하지 않은데, 나머지 코드는 PKCE를 구현하고 CORS도 완화했으니까요. 커밋 메시지를 보면, 무엇이 필요한지 몰라서 Claude에게 물었고, Claude가 implicit grant를 제안한 것으로 보입니다. implicit grant는 기능 플래그 뒤에 숨겨져 있지만, 그 플래그는 토큰 발급 시점이 아니라 요청 파싱을 위한 보조 메서드에서만 체크됩니다.
또한, OAuth에 익숙하지 않은 사람들이 작성했다는 또 다른 단서는 Basic 인증 지원을 잘못 구현했다는 점입니다. 이는 OAuth 제공자 구현에서 흔한 버그인데, 사람들이(그리고 LLM도) 그냥 일반 Basic 인증이라고 생각하지만, OAuth에서는 모든 것을 URL-인코딩해야 합니다(문자셋이 복잡해서). 또한, 클라이언트 시크릿에 콜론이 들어갈 경우(스펙상 허용됨) 2차적인 버그도 있습니다. 이 구현에서는 항상 클라이언트 ID와 시크릿을 생성하므로 포맷을 제어할 수 있어 실제로는 문제가 없을 것 같지만, 자세히 보진 않았습니다.
더 심각한 버그는 토큰 ID를 생성하는 코드가 안전하지 않다는 점입니다. 편향된 출력을 생성하는데, 이는 사람들이 무작위 문자열을 생성할 때 흔히 저지르는 실수입니다. LLM이 첫 커밋에서 바로 이런 코드를 냈습니다. 이로 인해 토큰의 엔트로피가 줄어들지만, 무차별 대입이 가능할 정도는 아닙니다. 하지만 AI가 생성한 코드를 경험 많은 보안 전문가가 모두 리뷰했다는 주장에는 의문이 듭니다. 만약 정말로 리뷰했다면, LLM의 역량을 너무 신뢰한 것입니다. (실제로는 그렇지 않은 것 같습니다. 커밋 히스토리를 보면, 첫날 한 개발자가 메인 브랜치에 21번 직접 커밋했고, 코드 리뷰 흔적은 없습니다.)
토큰 저장소의 암호화 구현도 잠깐 봤습니다. 설계는 꽤 마음에 들었습니다! 꽤 똑똑한 방식입니다. 커밋 메시지를 보면 설계는 사람이 했고, 구현은 LLM이 했습니다. 인상적이었습니다. 이 작업의 커밋 메시지를 재현해보면 다음과 같습니다.
Claude에게 props를 암호화해서 저장하라고 요청함.
프롬프트: Grant와 Token 레코드에 저장된 props를 암호화하고 싶다. 유효한 토큰이 있어야만 복호화할 수 있어야 한다. 여러 개의 유효한 토큰이 있을 수 있으니, props를 반복적으로 재암호화하고 싶지 않다. 대신 대칭키로 한 번만 암호화하고, 각 토큰에 대해 그 키를 “래핑”해서 저장하자. 모든 암호화는 WebCrypto를 사용하라.
Claude가 잘못된 방향으로 시작해서, 내가 중요한 설계 고려사항을 빠뜨렸다는 걸 깨달음:
프롬프트: 한 가지 빼먹은 게 있다. listUserGrants() 헬퍼 함수는 더 이상 props를 반환할 수 없다. 복호화할 토큰이 없기 때문이다. 괜찮다. props는 인증된 API 요청에서만 앱에 전달하면 된다. listUserGrants()는 grant의 좁은 표현만 반환하도록 바꾸자. 지금은 저장소의 전체 grant 레코드를 반환하는데, id, clientId, userId, scope, metadata, createdAt만 반환하면 된다. 토큰 ID나 코드 챌린지 정보는 필요 없다.
Claude가 멋진 코드를 만들었지만, 한 가지 큰 결함이 있었다.
프롬프트: 토큰을 위한 키 래핑 방식에 보안 결함이 있다. 토큰의 SHA-256 해시를 키 재료로 썼는데, SHA-256은 토큰 ID를 계산하는 데도 쓰인다. 이 구조에서는 토큰 ID만 있으면 키를 풀 수 있으니, 모든 키가 쉽게 풀린다. 이를 고치려면, 키 재료를 다르게 계산해야 한다.
Claude는 처음에 PBKDF2 100,000회 반복을 사용하자고 제안했다.
프롬프트: PBKDF2 100,000회 반복은 너무 비싸다. 입력이 저엔트로피 비밀번호라면 중요하지만, 고엔트로피 입력에는 필요 없다. 대신 SHA-256 HMAC을 사용하자. 고정 HMAC 키(사실상 “솔트” 역할)를 쓰자.
Claude는 HMAC 키로 문자열 “OAUTH_PROVIDER_WRAPPING_KEY_HMAC_v1”을 사용했다.
프롬프트: 성능을 위해 WRAPPING_KEY_HMAC_KEY를 32바이트 배열로 정의하자. 이렇게 하면 인코딩이나 해싱 없이 바로 쓸 수 있다. 여기 무작위로 고른 32바이트 헥스가 있다: 22 7e 26 86 8d f1 e1 6d 80 70 ea 17 97 5b 47 a6 82 18 fa 87 28 ae de 85 b5 1d 4a d9 96 ca ca 43
(참고: 여기서 하드코딩된 “키”를 쓰는 건 괜찮습니다. 사실상 고정 랜덤 솔트를 쓴 HKDF-Extract와 같습니다. 이 용도에는 적합합니다. 저는 토큰 ID 생성에도 같은 방식을 쓸 것 같지만, 다른 솔트를 쓰면 됩니다.)
이 상호작용은 LLM과 협업할 때 얼마나 많은 지식이 필요한지 보여줍니다. Claude가 만든 “큰 결함”은 암호화 코드에 익숙하지 않은 사람이라면 알아채지 못했을 것입니다.. 마찬가지로, PBKDF2로 바꾸자는 제안도 의심하지 않았을 것입니다.. LLM은 실제로 “추론”하지 않습니다.
마무리 생각 
OAuth 라이브러리의 첫 버전으로는 나쁘지 않지만, 아직 실제 사용을 권장하진 않겠습니다. 올바르고 안전한 OAuth 제공자 구현은 매우 어렵고, 이 프로젝트에 투입된 시간과 노력보다 훨씬 더 많은 것이 필요합니다. 경험상, LLM을 이런 분야에 시험 삼아 쓰는 건 적절하지 않습니다. ForgeRock에서는 OAuth 구현에 수백 개의 보안 버그가 있었고, 매 커밋마다 수십만 개의 자동화 테스트, 위협 모델링, 최고 수준의 SAST/DAST, 전문가의 꼼꼼한 보안 리뷰가 있었습니다. LLM이 뚝딱 만들어낼 수 있다는 생각은 진지하지 않습니다.
이 프로젝트의 커밋 히스토리는 정말 흥미롭습니다. 엔지니어들이 설계의 많은 부분을 잘 알고 있었고, LLM을 엄격히 통제해 괜찮은 코드를 만들었습니다. (이런 방식의 코딩에 LLM은 확실히 유용합니다.) 하지만 여전히 멍청한 짓도 했고, 일부는 엔지니어가 잡아냈고, 일부는 그렇지 않았습니다. 아직도 남아 있을 것입니다. 사람이 했을 때보다 더 나쁜가? 꼭 그렇진 않습니다. 이런 실수는 인기 있는 Stack Overflow 답변에도 흔합니다. LLM도 거기서 배웠을 것입니다. 하지만 더 꼼꼼하게 했을 엔지니어들도 많습니다. 이런 코드는 세심한 주의가 필요합니다. 디테일이 중요합니다. README에서 뭐라 하든, 이 코드도 결국 “바이브코딩” 느낌이 있습니다. LLM이든 사람이든, 우리는 신경 써야 합니다.
LLM과 협업하거나 이 프로젝트를 리뷰하며 얻은 교훈은 이렇습니다: LLM이 어떤 코드를 만들어야 하는지 머릿속에 명확한 그림이 있어야, 그 결과물이 괜찮은지 판단할 수 있습니다. 그게 어떤 모습이어야 하는지 정말 알려면, 직접 만들어본 경험이 필요합니다. 사소한 일이라면 LLM이 어떻게 하든 상관없지만, 중요한 것, 예를 들어 내 인증 시스템 같은 건 내가 직접 꼼꼼히 생각해서 만드는 게 낫습니다.