diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73ea72b..2e10812 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+## Unreleased
+
+### Maintenance
+
+- Update `jackson-databind` and `jackson-core` to 2.21.7 to fix CVE-2026-91776, retaining the 2.21 LTS line.
+
## 2.2.5
### Maintenance
diff --git a/pom.xml b/pom.xml
index f300afe..7fda9a7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -261,7 +261,7 @@
${java.version}
2.0.0
- 2.21.6
+ 2.21.7
2.21
UTF-8
diff --git a/src/test/java/com/laserfiche/api/client/unit/TokenClientObjectMapperTest.java b/src/test/java/com/laserfiche/api/client/unit/TokenClientObjectMapperTest.java
new file mode 100644
index 0000000..98735c2
--- /dev/null
+++ b/src/test/java/com/laserfiche/api/client/unit/TokenClientObjectMapperTest.java
@@ -0,0 +1,104 @@
+// Copyright (c) Laserfiche.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+package com.laserfiche.api.client.unit;
+
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.deser.impl.TypeWrappedDeserializer;
+import com.fasterxml.jackson.databind.jsontype.impl.TypeDeserializerBase;
+import com.laserfiche.api.client.deserialization.TokenClientObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class TokenClientObjectMapperTest {
+ private static final int REQUEST_COUNT = 10_000;
+ private static final int MAX_CACHED_TYPE_IDS = 1_000;
+
+ @Test
+ void readValue_DistinctUnknownTypeIdsHaveBoundedCache() throws ReflectiveOperationException {
+ TokenClientObjectMapper mapper = new TokenClientObjectMapper();
+ mapper.readValue("{\"type\":\"known\",\"value\":\"before\"}", Base.class);
+ Map, ?> cache = cachedTypeDeserializers(mapper);
+ int maximumCacheSize = cache.size();
+
+ for (int i = 0; i < REQUEST_COUNT; i++) {
+ Base result = mapper.readValue(
+ "{\"type\":\"unknown-" + i + "\",\"value\":\"fallback\"}", Base.class);
+ assertInstanceOf(Fallback.class, result);
+ assertEquals("fallback", result.value);
+ maximumCacheSize = Math.max(maximumCacheSize, cache.size());
+ }
+
+ Base known = mapper.readValue("{\"type\":\"known\",\"value\":\"after\"}", Base.class);
+ assertInstanceOf(Known.class, known);
+ assertEquals("after", known.value);
+ assertTrue(cache.containsKey("known"));
+ assertTrue(maximumCacheSize <= MAX_CACHED_TYPE_IDS,
+ "Maximum cache size after distinct unknown IDs: " + maximumCacheSize);
+ assertTrue(cache.size() <= MAX_CACHED_TYPE_IDS);
+ }
+
+ @Test
+ void readValue_RepeatedUnknownTypeIdRetainsOneCacheEntry() throws ReflectiveOperationException {
+ TokenClientObjectMapper mapper = new TokenClientObjectMapper();
+ for (int i = 0; i < REQUEST_COUNT; i++) {
+ Base result = mapper.readValue(
+ "{\"type\":\"unknown\",\"value\":\"fallback\"}", Base.class);
+ assertInstanceOf(Fallback.class, result);
+ assertEquals("fallback", result.value);
+ }
+
+ assertEquals(1, cachedTypeDeserializers(mapper).size());
+ }
+
+ @Test
+ void readValue_OverlongUnknownTypeIdIsNotCached() throws ReflectiveOperationException {
+ TokenClientObjectMapper mapper = new TokenClientObjectMapper();
+ String typeId = new String(new char[257]).replace('\0', 'x');
+
+ Base result = mapper.readValue(
+ "{\"type\":\"" + typeId + "\",\"value\":\"fallback\"}", Base.class);
+
+ assertInstanceOf(Fallback.class, result);
+ assertEquals("fallback", result.value);
+ assertTrue(cachedTypeDeserializers(mapper).isEmpty());
+ }
+
+ private static Map, ?> cachedTypeDeserializers(TokenClientObjectMapper mapper)
+ throws ReflectiveOperationException {
+ ObjectMapper jacksonMapper = (ObjectMapper) readField(
+ mapper, TokenClientObjectMapper.class, "jacksonMapper");
+ Map, ?> roots = (Map, ?>) readField(
+ jacksonMapper, ObjectMapper.class, "_rootDeserializers");
+ Object root = roots.get(jacksonMapper.constructType(Base.class));
+ assertInstanceOf(TypeWrappedDeserializer.class, root);
+ Object typeDeserializer = readField(
+ root, TypeWrappedDeserializer.class, "_typeDeserializer");
+ return (Map, ?>) readField(
+ typeDeserializer, TypeDeserializerBase.class, "_deserializers");
+ }
+
+ private static Object readField(Object target, Class> declaringClass, String name)
+ throws ReflectiveOperationException {
+ Field field = declaringClass.getDeclaredField(name);
+ field.setAccessible(true);
+ return field.get(target);
+ }
+
+ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", defaultImpl = Fallback.class)
+ @JsonSubTypes(@JsonSubTypes.Type(value = Known.class, name = "known"))
+ public abstract static class Base {
+ public String value;
+ }
+
+ public static class Fallback extends Base {
+ }
+
+ public static class Known extends Base {
+ }
+}