Skip to content

Commit bb4e533

Browse files
dougqhclaude
andcommitted
Stop repeated NoSuchFieldError in Jackson 2.16 interner lookup
JsonParser216Helper reads package-private Jackson fields. On classpaths where they are missing (e.g. a mixed or repackaged Jackson) every getCurrentName() call threw and was reported. Rethrow the first NoSuchFieldError per class loader so it is still reported once, then assume interned field names. Assuming "interned" skips tainting the field name, trading a possible false negative for avoiding false positives from tainting shared interned strings. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent b20c1e3 commit bb4e533

2 files changed

Lines changed: 166 additions & 1 deletion

File tree

‎dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,44 @@
22

33
import com.fasterxml.jackson.core.sym.ByteQuadsCanonicalizer216Helper;
44

5+
/**
6+
* Reads whether a {@link UTF8StreamJsonParser} interns its field names.
7+
*
8+
* <p>This reads package-private Jackson fields ({@code _symbols}, {@code _interner}). Stock
9+
* jackson-core 2.16+ always has them, but a classpath that mixes Jackson builds can lack them,
10+
* which surfaces as a {@link NoSuchFieldError}.
11+
*
12+
* <p><b>IAST design note:</b> when the fields are missing we cannot tell whether names are
13+
* interned, and we answer {@code true} ("interned"). The caller then records the current field name
14+
* but does <em>not</em> taint the name string. This is a deliberate trade-off:
15+
*
16+
* <ul>
17+
* <li>Jackson interns field names by default, so "interned" is the likely answer.
18+
* <li>Tainting an interned {@code String} taints the one shared instance, so every occurrence of
19+
* that name (across requests) would look tainted. That is a false-positive risk.
20+
* <li>The cost is a possible false negative: on such classpaths, an attacker-controlled JSON
21+
* <em>key</em> reaching a sink is not detected. Values are still attributed to their field
22+
* name.
23+
* </ul>
24+
*
25+
* <p>The first failure per class loader is rethrown so the instrumentation exception handler still
26+
* reports it once. After that the failure is remembered and calls return {@code true} without
27+
* throwing, so a broken classpath does not cost an exception per parsed field name.
28+
*/
529
public final class JsonParser216Helper {
30+
private static volatile boolean fieldsUnavailable;
31+
632
private JsonParser216Helper() {}
733

834
public static boolean fetchInterner(UTF8StreamJsonParser jsonParser) {
9-
return ByteQuadsCanonicalizer216Helper.fetchInterner(jsonParser._symbols);
35+
if (fieldsUnavailable) {
36+
return true;
37+
}
38+
try {
39+
return ByteQuadsCanonicalizer216Helper.fetchInterner(jsonParser._symbols);
40+
} catch (NoSuchFieldError e) {
41+
fieldsUnavailable = true;
42+
throw e;
43+
}
1044
}
1145
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package datadog.trace.instrumentation.jackson_2_16.core;
2+
3+
import static java.nio.charset.StandardCharsets.UTF_8;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
5+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
6+
import static org.junit.jupiter.api.Assertions.assertThrows;
7+
import static org.junit.jupiter.api.Assertions.assertTrue;
8+
9+
import com.fasterxml.jackson.core.JsonFactory;
10+
import com.fasterxml.jackson.core.json.JsonParser216Helper;
11+
import com.fasterxml.jackson.core.json.UTF8StreamJsonParser;
12+
import java.io.ByteArrayOutputStream;
13+
import java.io.IOException;
14+
import java.io.InputStream;
15+
import java.lang.reflect.InvocationTargetException;
16+
import java.lang.reflect.Method;
17+
import net.bytebuddy.jar.asm.ClassReader;
18+
import net.bytebuddy.jar.asm.ClassWriter;
19+
import net.bytebuddy.jar.asm.commons.ClassRemapper;
20+
import net.bytebuddy.jar.asm.commons.SimpleRemapper;
21+
import org.junit.jupiter.api.Test;
22+
23+
class JsonParser216HelperTest {
24+
25+
private static final String JACKSON_CORE_PREFIX = "com.fasterxml.jackson.core.";
26+
private static final String CANONICALIZER =
27+
"com.fasterxml.jackson.core.sym.ByteQuadsCanonicalizer";
28+
29+
@Test
30+
void reportsInternedFieldNames() throws Exception {
31+
JsonFactory factory = new JsonFactory().enable(JsonFactory.Feature.INTERN_FIELD_NAMES);
32+
UTF8StreamJsonParser parser = (UTF8StreamJsonParser) factory.createParser(json());
33+
34+
assertTrue(JsonParser216Helper.fetchInterner(parser));
35+
}
36+
37+
@Test
38+
void reportsNonInternedFieldNames() throws Exception {
39+
JsonFactory factory = new JsonFactory().disable(JsonFactory.Feature.INTERN_FIELD_NAMES);
40+
UTF8StreamJsonParser parser = (UTF8StreamJsonParser) factory.createParser(json());
41+
42+
assertFalse(JsonParser216Helper.fetchInterner(parser));
43+
}
44+
45+
/**
46+
* Simulates a classpath whose {@code ByteQuadsCanonicalizer} has no {@code _interner} field. The
47+
* first failure is rethrown so it is reported once; later calls assume interned names.
48+
*/
49+
@Test
50+
void rethrowsFirstMissingFieldThenAssumesInterned() throws Exception {
51+
ClassLoader loader = new MissingInternerClassLoader();
52+
Object factory = loader.loadClass(JsonFactory.class.getName()).getConstructor().newInstance();
53+
Object parser =
54+
factory.getClass().getMethod("createParser", byte[].class).invoke(factory, (Object) json());
55+
Method fetchInterner =
56+
loader
57+
.loadClass(JsonParser216Helper.class.getName())
58+
.getMethod("fetchInterner", loader.loadClass(UTF8StreamJsonParser.class.getName()));
59+
60+
InvocationTargetException first =
61+
assertThrows(InvocationTargetException.class, () -> fetchInterner.invoke(null, parser));
62+
assertInstanceOf(NoSuchFieldError.class, first.getCause());
63+
64+
assertTrue((boolean) fetchInterner.invoke(null, parser));
65+
assertTrue((boolean) fetchInterner.invoke(null, parser));
66+
}
67+
68+
private static byte[] json() {
69+
return "{\"name\":\"value\"}".getBytes(UTF_8);
70+
}
71+
72+
/**
73+
* Loads jackson-core child-first, renaming {@code ByteQuadsCanonicalizer._interner} in the
74+
* bytecode. The class stays self-consistent, but a lookup of the original field name fails with
75+
* {@link NoSuchFieldError}, like a mixed or repackaged Jackson on the classpath.
76+
*/
77+
private static final class MissingInternerClassLoader extends ClassLoader {
78+
MissingInternerClassLoader() {
79+
super(JsonParser216HelperTest.class.getClassLoader());
80+
}
81+
82+
@Override
83+
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
84+
if (!name.startsWith(JACKSON_CORE_PREFIX)) {
85+
return super.loadClass(name, resolve);
86+
}
87+
synchronized (getClassLoadingLock(name)) {
88+
Class<?> clazz = findLoadedClass(name);
89+
if (clazz == null) {
90+
clazz = define(name);
91+
}
92+
if (resolve) {
93+
resolveClass(clazz);
94+
}
95+
return clazz;
96+
}
97+
}
98+
99+
private Class<?> define(String name) throws ClassNotFoundException {
100+
try (InputStream in = getParent().getResourceAsStream(name.replace('.', '/') + ".class")) {
101+
if (in == null) {
102+
throw new ClassNotFoundException(name);
103+
}
104+
byte[] bytes = readAll(in);
105+
if (name.equals(CANONICALIZER)) {
106+
bytes = renameInterner(bytes);
107+
}
108+
return defineClass(name, bytes, 0, bytes.length);
109+
} catch (IOException e) {
110+
throw new ClassNotFoundException(name, e);
111+
}
112+
}
113+
114+
private static byte[] renameInterner(byte[] bytes) {
115+
ClassWriter writer = new ClassWriter(0);
116+
SimpleRemapper remapper =
117+
new SimpleRemapper(CANONICALIZER.replace('.', '/') + "._interner", "_interner_renamed");
118+
new ClassReader(bytes).accept(new ClassRemapper(writer, remapper), 0);
119+
return writer.toByteArray();
120+
}
121+
122+
private static byte[] readAll(InputStream in) throws IOException {
123+
ByteArrayOutputStream out = new ByteArrayOutputStream();
124+
byte[] buffer = new byte[8192];
125+
for (int read = in.read(buffer); read != -1; read = in.read(buffer)) {
126+
out.write(buffer, 0, read);
127+
}
128+
return out.toByteArray();
129+
}
130+
}
131+
}

0 commit comments

Comments
 (0)