Cursor가 JWT를 검증(verify)하는 대신 디코딩(decode)하는 문제 (CWE-347)
요약
본 기사는 AI 에디터(Cursor)가 JWT 인증 미들웨어를 작성할 때 `jwt.verify()` 대신 서명을 검증하지 않는 `jwt.decode()`를 사용하게 만드는 위험성을 경고합니다. 이로 인해 공격자가 페이로드만 수정하여 관리자 권한을 획득하는 CWE-347 취약점이 발생할 수 있습니다.
핵심 포인트
- JWT 인증 시 반드시 `verify` 함수를 사용하여 서명을 검증해야 합니다.
- 서명 검증 없이 디코딩(`decode`)만 하는 것은 클라이언트 데이터를 신뢰하는 것과 같습니다.
- 안전한 JWT 처리를 위해 `jsonwebtoken` 대신 `jose` 라이브러리 사용을 권장합니다.
AI가 만든 JWT 인증 오류: jwt.decode()의 위험성
- AI 에디터들이 토큰 서명(signature)을 확인하지 않는
jwt.decode()를 호출하는 인증 미들웨어(auth middleware)를 작성하는 경우가 많습니다. 이 경우, 토큰의 서명이 절대 검사되지 않습니다. - 누구나 페이로드(payload)를 수정하여
"role": "admin"으로 설정한 후 접근할 수 있습니다. 위조된 토큰은 서명될 필요가 없습니다. - 해결책: 고정된 알고리즘 목록을 사용하여 모든 토큰을 검증하고,
jsonwebtoken대신jose라이브러리를 사용해야 합니다.
지난주에 작은 Next.js 앱의 라우트 보호(route protection)를 추가하도록 Cursor에게 요청했습니다. 첫 번째 버전에서는 middleware.ts 파일에 jsonwebtoken을 가져왔고, 엣지 런타임(Edge runtime)에 Node crypto 모듈이 없기 때문에 빌드가 실패했습니다.
그래서 저는 Cursor에게 빌드 오류를 수정하라고 지시했습니다. 그리고 그것은 수정했습니다. jwt.verify()를 jwt.decode()로 교체했고, 빌드는 성공적으로 완료되었으며, 보호된 페이지들은 여전히 로그아웃 사용자를 리디렉션(redirect)했지만, 수동 테스트 모든 항목이 통과했습니다.
결국 이 앱은 누구나 입력할 수 있는 어떤 토큰이라도 받아들이게 되었습니다.
취약한 코드
jwt.decode()는 토큰의 서명(signature)을 확인하지 않고 페이로드만 읽기 때문에, 이 함수를 사용하여 인증 결정을 내리는 모든 미들웨어는 클라이언트가 작성한 데이터를 신뢰하게 됩니다. 이는 CWE-347, 암호화 서명의 부적절한 검증(Improper Verification of Cryptographic Signature)에 해당합니다.
다음은 "수정"된 후 Cursor가 생성한 미들웨어 코드입니다:
// middleware.ts (CWE-347: 서명 절대 검증 안 함)
import { NextResponse } from 'next/server';
import { decode } from 'jsonwebtoken';
...
이 코드는 만료일(expiry)을 확인하고, 역할을 확인합니다. 인증 계층처럼 보입니다. 하지만 JWT는 단지 세 개의 base64url 세그먼트로 이루어진 것이며, 앞의 두 개는 누구나 읽고 수정할 수 있습니다. 실제 세션 토큰을 가져와서, 중간 세그먼트를 디코드(decode)하고 "role": "user"를 "role": "admin"으로 변경한 다음, 만료일을 1년 뒤로 설정하고, 다시 인코딩하여 쿠키에 붙여넣으세요. 서명 세그먼트는 더 이상 일치하지 않습니다. 아무것도 검사되지 않습니다.
jsonwebtoken의 README는 이 점을 명확하게 지적합니다. decode는 "서명이 유효한지 여부를 확인하지 않고" 페이로드를 반환하며, 신뢰할 수 없는 메시지에 대해서는 사용해서는 안 된다고 경고합니다. 모든 쿠키는 신뢰할 수 없는 메시지입니다.
Python 버전도 표시되는데, 보통 누군가 DecodeError를 만나서 에디터에게 사라지게 해달라고 요청한 후에 나타납니다:
# CWE-347: signature verification switched off
payload = jwt.decode(token, options={"verify_signature": False})
if payload.get("role") == "admin":
...
PyJWT는 이 옵션이 신뢰하지 않는 클레임을 읽을 때 사용된다고 문서화합니다. 심지어 서명 없이는 클레임의 무결성을 "신뢰할 수 없다"고 경고합니다. 에디터는 이 예외를 사라지게 만들기 위해 이를 사용합니다.
왜 이런 일이 계속 발생하는가
AI 에디터는 사용자가 보여준 오류가 사라지도록 최적화하며, decode()는 "이것은 오류를 발생시킨다"에서 "이것은 실행된다"로 가는 가장 짧은 경로입니다. 가져오는 모듈도 같고, 이름도 거의 같으며, 반환하는 객체도 같습니다. 에디터의 관점에서 보면 이 교체는 한 단어짜리 수정 사항일 뿐입니다.
여기에 세 가지 요인이 작용합니다.
실행 환경 제약은 현실적입니다. jsonwebtoken은 Node의 crypto에 의존하기 때문에 Next.js 미들웨어에서 역사적으로 사용해 온 Edge 런타임에는 번들링되지 않습니다. 올바른 해결책은 다른 라이브러리입니다. 쉬운 해결책은 같은 라이브러리의 다른 함수를 사용하는 것입니다.
테스트에서는 이 둘을 구분할 수 없습니다. 자연스럽게 실행하는 모든 테스트는 합법적으로 서명된 토큰을 사용하며, decode()와 verify()는 합법적인 토큰에 대해 동일한 페이로드를 반환합니다. 이들은 위조된 토큰일 때만 달라집니다. 보안 제어는 성공했을 때는 눈에 보이지 않으며, 빌드가 녹색으로 보이게 하려고 노력할 때 아무도 위조된 토큰 테스트를 작성하지 않습니다.
학습 데이터에는 decode()가 가득합니다. 디버그 코드 스니펫, "클라이언트에서 사용자 ID 읽기" 예제, 그리고 토큰 클레임을 엿보는 튜토리얼 모두 표시용으로 decode()를 올바르게 사용합니다. 에디터는 안전하게 만드는 맥락(context) 없이 이 호출을 학습했습니다.
편집자들이 여전히 오래된 버전을 고정하는 경우가 있기 때문에 알아둘 만한 구 버전의 취약점도 있습니다. jsonwebtoken 8.5.1 이하 버전에서는 algorithms 옵션 없이 그리고 거짓 값(예: 설정되지 않은 환경 변수)의 키를 사용하여 verify()를 호출할 경우, none 알고리즘으로 폴백하여 서명되지 않은 토큰을 수락할 수 있었습니다. 이는 CVE-2022-23540이며 9.0.0에서 수정되었습니다. 같은 범위의 별도 권고 사항인 CVE-2022-23541은 하나의 키 조회 함수가 두 가지 유형의 키를 모두 처리하는 과정에서 RS256 토큰이 HS256으로 검증되는 문제를 다루었습니다. 만약 편집자가 package.json에 "jsonwebtoken": "^8.5.1"을 작성한다면, 이 두 가지 취약점을 모두 얻게 됩니다.
해결 방법 (The Fix)
인증 결정을 내리는 모든 요청에서 서명을 검증하고, 실제로 사용하는 알고리즘을 고정하며, 오류 발생 시에는 반드시 실패(fail closed)하도록 구현해야 합니다. 디코딩은 표시용일 뿐이며, 절대 접근 제어 목적으로 사용되어서는 안 됩니다.
Edge 런타임의 Next.js 미들웨어에서는 Web Crypto에서 실행되는 jose를 사용하십시오:
// middleware.ts
import { NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
...
일반적인 Node 라우트 핸들러에서는 알고리즘을 고정하는 한 jsonwebtoken이 괜찮습니다 (9.x 버전 이상):
import jwt from 'jsonwebtoken'; // ^9.0.0
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
그리고 Python에서는:
payload = jwt.decode(
token,
key=os.environ["JWT_SECRET"],
...
겉보기보다 세 가지 세부 사항이 더 중요합니다:
- 누락된 시크릿에 대해 명확하게 실패 처리 (Fail loudly on a missing secret). CVE-2022-23540 경로는 거짓 값의 키가 필요했습니다. 시작 시점에 시크릿이 설정되지 않았을 때 오류를 발생시키면, 이러한 종류의 예상치 못한 문제를 완전히 제거할 수 있습니다.
- 라이브러리가 안전한 기본값을 가지고 있더라도
algorithms를 고정하십시오. 한 줄의 코드가 추가될 뿐이며, 파일을 편집하는 다음 사람이나 다음 모델에게 의도를 문서화해 줍니다. - 위조 토큰 테스트(forged-token test)를 작성하십시오. 유효한 토큰을 가져와 클레임 하나를 변경하고 다시 인코딩한 후, 401 또는 리디렉션이 발생하는지 확인하는 주장을 추가해야 합니다. 이는
decode()와verify()를 구분할 수 있는 유일한 테스트이며, 따라서 회귀(regression)를 감지할 수 있는 유일한 테스트입니다.
안내드릴 점이 하나 더 있습니다. 저장소에서 decode(와 verify_signature를 검색해 보세요. 만약 이 두 함수 중 어느 것이 접근 결정(access decision)을 내리는 파일에 나타난다면, 바로 이 버그를 발견하신 것입니다.
FAQ
질문: jwt.decode()는 사용해도 안전한가요?
답변: 네, 사용자 이름처럼 UI에 표시하거나 토큰 ID를 로깅하는 등 신뢰하지 않아도 되는 클레임(claims)을 읽어오는 용도로는 안전합니다. 하지만 누군가가 누구인지 또는 무엇에 접근할 수 있는지 결정하는 데 사용하기에는 절대 안전하지 않습니다.
질문: jsonwebtoken이 Next.js 미들웨어에서 실패하는 이유는 무엇인가요?
답변: 이는 Node의 crypto 모듈에 의존하는데, Edge 런타임(Edge runtime)에서는 해당 모듈을 제공하지 않기 때문입니다. 대신 jose와 그 안에 있는 jwtVerify() 함수를 사용하세요. decode()로 대체하는 것은 피해야 합니다.
질문: jsonwebtoken 9는 여전히 alg none을 허용하나요?
답변: 기본적으로는 아닙니다. 버전 9.0.0에서는 verify()에서 none에 대한 기본 지원을 제거했으며, 이제 허용되는 알고리즘은 키 유형(key type)을 기반으로 기본 설정됩니다. 위험한 버전은 8.5.1 이하입니다.
저는 Cursor와 Claude Code 내부에서 SafeWeave를 MCP 서버로 실행하여, 코드가 CI(지속적 통합)에 올라가서 며칠 뒤의 것이 아니라 아직 머릿속에 신선할 때 보안 검사를 하도록 했습니다. 하지만 이 특정 버그의 경우 가장 중요한 제어는 저렴하고 도구에 구애받지 않는 것입니다. 테스트 스위트에서 위조된 토큰(forged-token) 테스트를 하나 추가하고, 경로를 보호하는 모든 코드에서 decode(를 grep 검색해 보세요. 어떤 것을 사용하든 일찍 잡아내는 것이 중요합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기