jjwt 이슈를 읽고 새로운 PR을 내는 과정
안녕하세요, 오늘은 2023년 12월에 시작된 issue #877 (Option to enable strict duplicate detection)를 하나 가져왔습니다. 이번 글은 jjwt와 Jackson의 동작 과정과 PR을 생성하기까지의 기록입니다.
아마 백엔드 개발을 하시는 분들에게는 jjwt와 Jackson 모두 익숙하실 것 같습니다. Spring Boot가 JSON 변환 라이브러리로 Jackson을 기본으로 쓰기 때문에 더욱 그럴 것이며, jjwt는 이름 그대로 java-jwt라 이 라이브러리를 몰라도 JWT와 관련된 것임을 알 수 있습니다.
그래도 이 글의 이야기가 둘의 경계에서 벌어지니, 각자 무슨 일을 하는지부터 짧게 짚고 가겠습니다.
먼저 JWT입니다. 로그인한 사용자가 누구인지를 서버가 매번 세션에서 찾지 않고, 토큰 하나로 확인할 수 있게 해주는 규격입니다. 생김새는 점 두 개로 나뉜 세 조각이고 각각 헤더, 페이로드, 시그니처입니다. 헤더와 페이로드는 그냥 JSON을 Base64로 인코딩한 것이고, 시그니처는 그 헤더와 페이로드를 더해 해시 알고리즘으로 잠근 값입니다. 그래서 내용을 몰래 바꾸면 검증에서 걸립니다. 반대로 말하면 내용 자체는 누구나 열어볼 수 있는 평범한 JSON입니다.
jwtk/jjwt는 그 JWT를 자바에서 만들고 검증하는 라이브러리입니다. 별이 1만 개가 넘는, 자바 진영에서 사실상 표준처럼 쓰이는 오픈소스입니다. 토큰을 만들고, 서명을 확인하고, 만료됐는지 보고, 클레임을 꺼내주는 일을 합니다.
Jackson은 자바에서 JSON을 객체로 바꾸고 객체를 JSON으로 바꾸는 라이브러리입니다. Spring Boot를 쓰신다면 이미 매일 쓰고 계십니다. 컨트롤러에 @RequestBody를 붙였을 때 요청 본문을 DTO로 만들어주는 게 Jackson입니다.
이 둘이 같이 나오는 이유는 jjwt가 JSON을 직접 읽지 않기 때문입니다. 토큰의 페이로드를 파싱하는 일은 외부 라이브러리에 맡기고 자기는 JWT 규격에 집중하겠다는 것이죠. 그 자리에 가장 흔히 꽂히는 것이 Jackson이고요.
이제 이슈로 다시 돌아가서 요청을 요약하면 다음과 같습니다. 토큰 페이로드에 같은 이름이 두 번 들어 있으면 그걸 거부할 수 있게 해달라.
2023년 12월 4일에 laurids라는 사용자가 연 이슈이고, 제목은 Option to enable strict duplicate detection입니다. 우리말로 옮기면 중복 검출을 엄격하게 켤 수 있게 해달라는 요청입니다.
Jackson은 기본적으로 중복된 이름에 대해서 후에 입력된 값을 덮어 씁니다.
{
"k": "A",
"k": "B"
}
→ {k=B}
혹시 정렬 같은 다른 규칙이 있나 싶어 값을 오름차순으로도, 내림차순으로도 넣어봤습니다. 결과는 정렬과 아무 상관이 없었고 그냥 원문에 쓰인 순서로 결정되더군요.
오름차순으로 넣은 경우 { "k": "aaa", "k": "zzz" } → {k=zzz} 내림차순으로 넣은 경우 { "k": "zzz", "k": "aaa" } → {k=aaa}
그러면 이게 어떤 문제를 발생시킬 수 있을까요? k 자리에 실제 클레임을 넣어보겠습니다.
{
"sub": "alice",
"sub": "attacker",
"role": "user",
"role": "admin"
}
→ sub=attacker role=admin
주체가 alice에서 attacker로, 권한이 user에서 admin으로 바뀌고 Jackson은 이 동작을 허용합니다. 따라서 중복을 검출할 수 있도록 만들어달라는 요청입니다.
그런데 Jackson은 이미 JsonParser.Feature.STRICT_DUPLICATE_DETECTION 옵션으로 중복을 막는 기능을 갖고 있습니다. 켜두면 덮어쓰는 대신 예외를 던집니다.
JsonParseException: Duplicate field 'sub'
이슈를 연 laurids도 이 옵션에 대해 언급합니다.
This can be done by enabling JsonParser.Feature.STRICT_DUPLICATE_DETECTION on the underlying ObjectMapper. The problem is that there is no way to access the objectMapper in JacksonDeserializer.
밑에 깔린 ObjectMapper에 그 기능을 켜면 되는데, JacksonDeserializer 안의 objectMapper에 접근할 방법이 없다는 이야기입니다. 이미 있는 스위치에 손이 닿게 해달라는 요청인 셈입니다.
여기에 의견이 둘 달립니다. 먼저 bdemers가 답합니다. 그러면 ObjectMapper를 직접 만들어 넣으면 되지 않느냐는 겁니다.
You can create a new instance of JacksonDeserializer, and customize the ObjectMapper
그럴 수 있는 생성자는 이미 있습니다. 그가 코멘트에 링크로 짚은 세 줄을 당시 릴리스인 0.12.3에서 그대로 열어보면 이렇습니다.
// extensions/jackson/…/io/JacksonDeserializer.java · 0.12.3 · L91-93 public JacksonDeserializer(ObjectMapper objectMapper) { this(objectMapper, (Class<T>) Object.class); }
설정을 마친 ObjectMapper를 만들어 여기 넘기면 중복 검출도 같이 켜진다는 답입니다. 세 시간 뒤에는 메인테이너 lhazlewood가 훨씬 무거운 반문을 답니다.
It is unclear to me if we should change the default behavior of implicitly-created ObjectMapper instances since the RFCs explicitly allow this behavior. Assuming signature verification or AEAD decryption are successful before accessing the payload, what vulnerabilities are you referring to?
암묵적으로 만들어지는 ObjectMapper의 기본 동작을 바꿔야 하는지 모르겠다, RFC가 이 동작을 명시적으로 허용하기 때문이다. 그리고 페이로드를 읽기 전에 서명 검증이 먼저 성공한다고 치면 대체 어떤 취약점을 말하는 거냐는 겁니다.
짧은 대화인데 확인할 게 여럿 남았습니다. RFC는 정확히 뭐라고 적었길래 "명시적으로 허용"이라는 말이 나오는지, ObjectMapper를 직접 만들어 넣으라는 답으로는 왜 부족했는지, 그리고 서명 검증이 먼저인데도 정말 위험한지. 하나씩 들어가 보겠습니다.
JSON 표준 자체는 이름이 유일해야 한다고 권고만 하지만, JWT 쪽 RFC는 그 수위를 올렸습니다. RFC 7515 §4와 RFC 7519 §4가 같은 문장을 반복합니다.
The Header Parameter names within the JOSE Header1 MUST be unique; JWS2 parsers MUST either reject JWSs with duplicate Header Parameter names or use a JSON parser that returns only the lexically last duplicate member name.
1JOSE — Javascript Object Signing and Encryption. JWS·JWE·JWK·JWT 규격을 한 세트로 만든 IETF 작업반 이름입니다. JOSE 헤더는 그 세트가 공통으로 쓰는 헤더, 그러니까 앞에서 본 토큰의 첫 번째 조각입니다.
2JWS — JSON Web Signature. 서명된 토큰의 형식 자체를 가리킵니다. 헤더·페이로드·시그니처 세 조각 구조가 바로 JWS이고, 암호화된 형식은 JWE로 따로 있습니다. 우리가 JWT라고 부르는 것은 대개 그 페이로드에 클레임을 담은 JWS입니다.
한 문장 안에 규칙이 둘입니다. 만드는 쪽은 이름이 유일해야 하고, 읽는 쪽은 거부하거나 마지막 값을 쓰거나 둘 중 하나를 반드시 해야 합니다. 그러니까 중복이 든 토큰은 그 자체로 이미 규격 위반이지만, 그걸 읽는 파서에게는 두 선택지가 모두 적법합니다.
뒤에 쓴 값이 이기는 Jackson의 동작이 RFC가 말하는 lexically last입니다. 사전순이 아니라 원문에 쓰인 순서상 마지막이라는 뜻이고, RFC가 굳이 ECMAScript 5.1의 절을 인용해 붙여둔 것도 이 모호함을 없애기 위해서입니다.
그리고 선택지가 둘이라는 사실 자체가 위험이라는 걸 RFC 7515 §10.12가 직접 적어뒀습니다.
Ambiguous and potentially exploitable situations could arise if the JSON parser used does not enforce the uniqueness of member names or returns an unpredictable value for duplicate member names.
추가적으로 메인테이너 lhazlewood는 이렇게 말합니다. DefaultJwtParser에서 서명 검증이 클레임 파싱보다 먼저 일어나므로, 중복 키가 파서까지 왔다면 그건 이미 신뢰하는 키로 서명된 토큰이고 공격자가 임의로 만들 수 없다는 뜻입니다.
그런데 다음 날 laurids가 위협을 다른 방향으로 전개합니다.
In general, I would think duplicate properties in a JWT is a sign of a buggy or compromised authorization server. … If multiple parsers are used in a stack including JJWT, and some parsers are wrongly implemented to use the first value instead of the last, that could lead to vulnerabilities. However, if JJWT rejects it, this type of vulnerability is not possible.
정리하면 위협은 토큰을 위조하는 게 아니라 같은 토큰을 계층마다 다르게 읽는 것입니다. 토큰이 정상인 것과 발급자가 정상인 것은 다르니까요. 또한 근거로 Bishop Fox의 JSON 상호운용성 취약점 분석과 Black Hat US-23의 JWT 공격 발표를 붙였습니다.
그리고 같은 코멘트에서 자기가 원한 게 무엇인지도 다시 짚습니다.
Yes, I did notice the constructor taking an ObjectMapper. However, I wanted to use the MappedTypeDeserializer as well, since it was conveniently implemented there:) Maybe I was not clear enough about that part. So my request was for an option to configure the ObjectMapper, specifically when using the JacksonDeserializer(Map<String, Class<?>> claimTypeMap) constructor.
ObjectMapper를 받는 생성자는 자기도 봤지만 MappedTypeDeserializer도 같이 쓰고 싶었다는 겁니다. 정리하자면 JacksonDeserializer(Map<String, Class<?>> claimTypeMap) 생성자를 쓸 때 ObjectMapper를 설정할 수 있게 해달라는 것이었습니다.
이틀 뒤 lhazlewood의 입장이 바뀝니다.
A more targeted solution … would be to just default to enabling STRICT_DUPLICATE_DETECTION. I agree this would likely be a better default, and, since the RFC allows it, application developers can override/unset that feature if they desire.
수정은 2024년 1월 17일 커밋 86e0655, 릴리스로는 0.12.4에 실렸습니다. 요청받은 API를 만들어주는 대신 기본 ObjectMapper에 중복 검출을 켰습니다.
static ObjectMapper newObjectMapper() { return new ObjectMapper() .registerModule(MODULE) .configure(JsonParser.Feature.STRICT_DUPLICATE_DETECTION, true) // issues/877 ... }
요청받은 생성자도 같은 커밋에 함께 들어가긴 했습니다. 다만 잠긴 채였습니다.
//TODO: Make this public on a minor release private JacksonDeserializer(ObjectMapper objectMapper, Map<String, Class<?>> claimTypeMap) {
semver 때문입니다. 0.12.x는 패치 릴리스라 public API를 늘리면 규약 위반이 되니, 요청받은 API를 당장 열어주는 대신 기본값에 중복 검출을 켜는 쪽을 택한 것입니다.
모호함에서 오는 취약점은 사라졌지만 아직 의문이 남습니다. laurids가 원한 claimTypeMap은 어떤 역할을 하는 걸까요? 답을 보려면 jjwt가 JSON을 어떻게 읽는지부터 봐야 합니다.
jjwt는 토큰이 어떤 클레임을 담고 올지 미리 알 수 없습니다. 그건 발급자가 런타임에 정하니까요. 그래서 Object.class로 읽습니다.
{
"sub": "alice",
"user": {
"first": "Jill",
"last": "Coder"
}
}
readValue(json, Object.class)
→ {sub=alice, user={first=Jill, last=Coder}} ← user 도 그냥 Map
문제는 이게 불편하다는 겁니다. user를 User 객체로 쓰려면 맵에서 하나씩 꺼내 손으로 조립해야 하고, 선언 타입이 Object라 층마다 캐스팅이 붙습니다.
Map claims = readValue(json, Object.class); Map um = (Map) claims.get("user"); User u = new User(); u.first = (String) um.get("first"); u.last = (String) um.get("last");
claimTypeMap은 그 불편함을 해결해줍니다. ["user": User.class]처럼 이름과 클래스를 짝지어 넘기면, jjwt가 JSON을 읽어 내려가다가 지금 읽는 이름이 그 명부에 있는지 확인하고, 있으면 그 자리의 값만 따로 객체로 만들어 줍니다.
// jjwt · JacksonDeserializer.MappedTypeDeserializer public Object deserialize(JsonParser parser, DeserializationContext context) throws IOException { String name = parser.currentName(); // ← 지금 클레임의 이름 if (claimTypeMap != null && name != null && claimTypeMap.containsKey(name)) { Class<?> type = claimTypeMap.get(name); // ← 이름으로 클래스를 찾는다 return parser.readValueAsTree().traverse(parser.getCodec()).readValueAs(type); } return super.deserialize(parser, context); // 아니면 평소대로 Map }
가운데 한 줄에서 실제로 무슨 일이 일어나는지가 이 기능의 성격을 보여줍니다. 이름이 걸리면 그 값의 서브트리를 메모리에 통째로 담고, 그 트리 위에 새 파서를 얹어 다시 읽어 객체로 만듭니다. 읽는 도중에야 타입이 정해지므로 스트림을 되돌릴 수 없어서, 그 조각만 한 번 더 읽는 겁니다.
판정 기준이 안쪽 필드 구성이 아니라 클레임 이름 하나라는 점도 여기서 나옵니다. 안쪽 필드가 똑같아도 이름이 명부에 없으면 그냥 Map입니다.
laurids가 놓지 못한 게 이 편의였습니다. 맵에서 값을 하나씩 꺼내 조립하던 코드를 지운 채로 중복 키까지 막고 싶었던 거죠.
그런데 여기서 의문이 하나 생기지 않나요? 같은 Jackson을 사용하는 SpringBoot에서는 @RequestBody만 지정하면 readValue(json, Dto.class)처럼 한 번에 읽어줍니다.
jjwt는 왜 Object.class로 고정해두고, 처음부터 사용자가 만든 타입으로 읽게 하지 않는 걸까요?
차이는 라이브러리가 아니라 타입을 언제 아느냐에 있습니다.
@PostMapping("/api/orders")
public OrderResponse create(@RequestBody OrderRequest request) {
// ↑ 타입이 여기 박혀 있다
컨트롤러는 받을 타입을 메서드 시그니처에 적어둡니다. 계약이 컴파일 시점에 고정돼 있으니 Jackson은 스트림을 읽으면서 곧바로 DTO를 채웁니다. 반면 jjwt는 토큰이 어떤 클레임을 담고 올지 미리 알 수 없습니다. 그건 발급자가 런타임에 정하니까요. 그래서 Object.class로 읽고, 결과는 POJO가 아니라 중첩된 Map이 됩니다.
Spring도 타입 명시가 되어있지 않다면 똑같습니다.
@RequestBody OrderRequest request → OrderRequest @RequestBody Map<String, Object> body → LinkedHashMap
같은 매퍼에 같은 바이트를 넣었는데 결과가 갈립니다. 둘을 가르는 건 라이브러리가 아니라 타입을 알려줬는가입니다.
jjwt도 발급자에 맞추어 타입을 지정해 읽게 하는 옵션을 주면 되지 않나 싶은데, 그럴 수 없는 이유가 있습니다. 이런 토큰 둘을 생각해 보겠습니다.
A · 발급자가 만료를 넣었다 { "sub": "alice", "exp": 1786000000 } B · 발급자가 안 넣었다 { "sub": "alice" }
exp를 선언하지 않은 DTO로 이 둘을 받으면 결과가 똑같아집니다. 내용이 다른 두 토큰이 같아지는 겁니다. Map으로 받으면 containsKey("exp")가 각각 참과 거짓이라 구별이 됩니다.
Map에서 exp가 없다는 건 발급자가 안 보냈다는 뜻 하나뿐이지만, DTO에서 exp가 없다는 건 발급자가 안 보냈거나 소비자가 필드를 안 적었거나 둘 중 무엇인지 알 수 없습니다.
지금까지의 이야기는 전부 Jackson 위에서 벌어졌습니다. 그런데 jjwt가 쓰는 JSON 라이브러리는 Jackson만 있는 게 아닙니다. jjwt-jackson·jjwt-gson·jjwt-orgjson 셋 중 하나를 사용자가 골라 넣게 돼 있고, 그 선택이 JSON 처리 동작을 결정합니다.
그래서 셋을 같은 페이로드로 직접 돌려봤습니다. 앞에서 본 그 중복 클레임입니다.
{
"sub": "alice",
"sub": "attacker",
"role": "user",
"role": "admin"
}
Jackson은 앞서 본 그 이슈로 막혔습니다. 0.12.4부터 jjwt가 기본 ObjectMapper에 STRICT_DUPLICATE_DETECTION을 켜두니까요.
org.json은 jjwt가 손댄 적이 없는데도 막힙니다. 라이브러리 자체가 중복 키를 만나면 예외를 던집니다.
org.json 20250517
JSONException: Duplicate key "sub" at 21 [character 22 line 1]
그런데 Gson은 그냥 통과시킵니다.
gson 2.13.2 · jjwt 기본 설정(LONG_OR_DOUBLE · disableHtmlEscaping)
{sub=attacker, role=admin}
그래서 이 부분을 기여해보기로 했습니다. 새로 설득할 것이 없는, 이미 선언된 기준이 아직 닿지 않은 자리니까요.
처음 계획은 Gson의 Object 담당 어댑터를 제 것으로 갈아끼우는 것이었습니다. 그런데 문제가 있었습니다. Object 담당 어댑터를 바꾸지 못하도록 강제한다는 것입니다.
① 보통 타입(Person) 가로채기 → 된다 ② Map 타입 가로채기 → 된다 ③ Object 타입 가로채기 → IllegalArgumentException ④ 팩토리로 우회 → 예외는 없는데 호출조차 안 된다
이유는 Object가 Gson이 타입을 모를 때 마지막으로 찾는 담당자이기 때문입니다. Object.class로 읽을 때만 쓰이는 게 아니라, 맵이나 배열 안에 들어 있어 타입을 알 수 없는 값도 전부 이 담당자에게 갑니다. 다시 말해 변경의 영향 범위가 너무 넓다는 겁니다.
private static boolean hasNonOverridableAdapter(Type type) { return type == Object.class; }
더 깊숙이 들어가기로 했고, 최종적으로는 JsonReader를 변경하는 것을 선택했습니다. Jackson 또한 STRICT_DUPLICATE_DETECTION 기능은 JsonParser의 기능이고 ObjectMapper는 진입점을 노출할 뿐이라는 것에서 착안했습니다.
private static final class DuplicateNameRejectingJsonReader extends JsonReader { private final Deque<Set<String>> names = new ArrayDeque<>(); @Override public void beginObject() throws IOException { super.beginObject(); names.push(new HashSet<String>()); } @Override public void endObject() throws IOException { super.endObject(); names.pop(); } @Override public String nextName() throws IOException { String name = super.nextName(); Set<String> seen = this.names.peek(); if (seen != null && !seen.add(name)) { throw new JsonParseException("Duplicate JSON member name '" + name + "' at " + getPath()); } return name; } }
깊이마다 집합이 따로라는 점이 형제 객체의 같은 이름은 통과시키고 중첩 객체나 배열 원소 안의 중복은 거부하게 만듭니다.
기존에 있던 테스트는 전부 통과했습니다. 다만 통과했다는 게 안전하다는 뜻은 아닙니다. 테스트는 누군가 미리 적어둔 경우만 확인해주니까요.
그래서 여러 입력을 직접 만들어 변경 전 코드와 변경 후 코드에 나란히 넣어봤습니다. 중복이 든 입력만 결과가 갈리고 나머지는 전부 같아야 정상인데, 하나가 더 걸렸습니다.
{
"sub": "alice"
}trailing
기존 거부 변경 후 통과
원인을 보려면 Reader와 JsonReader가 다른 층이라는 것부터 짚어야 합니다.
jjwt가 확장 모듈에 넘겨주는 건 java.io.Reader입니다. 문자를 순서대로 흘려주는 JDK 표준 스트림이고, JSON이 뭔지는 모르는 물건입니다. jjwt 입장에서는 Jackson·Gson·org.json 중 무엇이 꽂힐지 모르니 특정 라이브러리의 타입을 계약에 쓸 수가 없거든요.
// jjwt-api public interface Deserializer<T> { T deserialize(Reader reader); // java.io.Reader }
Gson은 그 Reader를 받아 안에서 자기 JsonReader를 만들어 씁니다.
그런데 중복 검사를 넣으려면 nextName()에 끼어들어야 하고, 그러려면 그 JsonReader를 제가 만들어 넘겨야 합니다. 그 순간 호출되는 메서드가 바뀝니다.
// 변경 전 — 인자가 Reader gson.fromJson(reader, returnType); → fromJson(Reader, Class) Gson 이 리더를 만들고, 다 읽은 뒤 남은 게 있는지 확인한다 // 변경 후 — 인자가 JsonReader gson.fromJson(new DuplicateNameRejectingJsonReader(reader), returnType); → fromJson(JsonReader, Type) 남이 준 리더라 그 확인을 하지 않는다
뒤엣것이 확인을 안 하는 데는 이유가 있습니다. 한 스트림에서 문서를 여러 개 연달아 읽는 경우가 있어서, 문서 하나를 읽었다고 거기가 끝이라고 단정할 수 없기 때문입니다. 바이트코드로 확인해 보니 그대로였습니다.
fromJson(Reader, ...) assertFullConsumption 호출 1회 fromJson(JsonReader, ...) assertFullConsumption 호출 0회
중복을 막자고 넣은 변경이 다른 쪽 입력 검증을 푸는 꼴이었습니다. 이런 입력을 확인하는 테스트가 원래 없었으니 통과 여부로는 알 길이 없었고요. 파싱 후에 같은 검사를 직접 수행하도록 고치고 테스트 둘을 더 붙였습니다.
이런 작업이 가장 중요한 것 같습니다. 새로운 기능을 넣을 때는 기존 기능과 완벽히 호환되는지, 동작의 정합이 바뀌지 않는지가 중요한 것 같습니다.
성능상 오버헤드가 없는지도 살펴봤습니다. 객체 하나당 HashSet 하나가 늘어나는 구조라 그 값을 실제로 재봐야 했습니다. JMH로 측정했고 코드와 원본 결과 파일은 별도 저장소에 남겼습니다.
PR을 적용한 jjwt를 직접 빌드해 변경 전후를 나란히 돌렸더니, 서명이 붙은 토큰 하나를 파싱하는 데 136ns가 늘었습니다. 서명 검증과 Base64 디코딩이 종단 시간의 대부분을 가져가는 덕에 전체의 1.8%였고 메모리 할당은 2.3% 늘었는데, 전체에서 차지하는 몫이 작아 우려할 수준은 아닌 것 같습니다.
PR #1073 (Align the Gson extension with Jackson's duplicate member name rejection)은 제출하였으며 글을 쓰는 시점에는 아직 리뷰를 기다리는 중입니다.
돌아보면 재밌는 건 제가 실제로 쓴 코드가 30줄 남짓이라는 점입니다. 시간의 대부분은 이슈 하나를 처음부터 거슬러 읽고, RFC 문장을 뜯어보고, 이미 내려진 결정이 어디까지 적용됐는지 세는 데 들어갔습니다.
코드는 싸고 검증은 비싼 시대가 되었음을 다시 한번 느낍니다.
읽어주셔서 감사합니다.