Skip to content

Commit 6279b00

Browse files
committed
fix(security): add class loading allowlist to prevent arbitrary code execution from YAML configs
Add package-level allowlist validation for all dynamic class loading paths in ToolResolver and ComponentRegistry to prevent arbitrary class instantiation via malicious YAML agent configurations. Vulnerability (Java equivalent of CVE-2026-4810): ToolResolver.resolveToolFromClass(), resolveToolsetFromClass(), resolveInstanceViaReflection(), and resolveToolsetInstanceViaReflection() all call Thread.currentThread().getContextClassLoader().loadClass() with class names directly from YAML config, with no validation on which packages can be loaded. An attacker can specify any class on the classpath (e.g., java.lang.Runtime, java.lang.ProcessBuilder) to achieve arbitrary code execution. Fix: 1. Add ALLOWED_CLASS_PREFIXES allowlist (com.google.adk., google.adk.) to restrict dynamic class loading to trusted ADK packages only 2. Add isAllowedClassForLoading() validation before every loadClass() call 3. Remove dangerous setAccessible(true) that bypasses access controls 4. Log blocked attempts at WARN level for security monitoring
1 parent 1685a4e commit 6279b00

3 files changed

Lines changed: 82 additions & 3 deletions

File tree

core/src/main/java/com/google/adk/agents/ToolResolver.java

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import com.google.adk.tools.BaseToolset;
2626
import com.google.adk.utils.ComponentRegistry;
2727
import com.google.common.collect.ImmutableList;
28+
import com.google.common.collect.ImmutableSet;
2829
import java.lang.reflect.Constructor;
2930
import java.lang.reflect.Field;
3031
import java.lang.reflect.InvocationTargetException;
@@ -41,6 +42,35 @@ final class ToolResolver {
4142

4243
private static final Logger logger = LoggerFactory.getLogger(LlmAgent.class);
4344

45+
/**
46+
* Allowlist of trusted package prefixes for dynamic class loading from YAML configs.
47+
*
48+
* <p>Security: Only classes from these packages can be loaded via reflection when specified in
49+
* YAML agent configurations. This prevents arbitrary class loading attacks where a malicious YAML
50+
* config could specify dangerous classes (e.g., Runtime, ProcessBuilder) to achieve code
51+
* execution. This is the Java equivalent of CVE-2026-4810 in adk-python.
52+
*/
53+
private static final ImmutableSet<String> ALLOWED_CLASS_PREFIXES =
54+
ImmutableSet.of("com.google.adk.", "google.adk.");
55+
56+
/**
57+
* Validates that a class name is from an allowed package before dynamic loading.
58+
*
59+
* @param className the fully qualified class name to validate
60+
* @return true if the class is from an allowed package
61+
*/
62+
static boolean isAllowedClassForLoading(String className) {
63+
if (isNullOrEmpty(className)) {
64+
return false;
65+
}
66+
for (String prefix : ALLOWED_CLASS_PREFIXES) {
67+
if (className.startsWith(prefix)) {
68+
return true;
69+
}
70+
}
71+
return false;
72+
}
73+
4474
private ToolResolver() {}
4575

4676
/**
@@ -270,6 +300,11 @@ static BaseToolset resolveToolsetFromClass(
270300
if (toolsetClassOpt.isPresent()) {
271301
toolsetClass = toolsetClassOpt.get();
272302
} else if (isJavaQualifiedName(className)) {
303+
// Security: Only allow class loading from trusted ADK packages
304+
if (!isAllowedClassForLoading(className)) {
305+
logger.warn("Blocked dynamic class loading from untrusted package: {}", className);
306+
return null;
307+
}
273308
// Try reflection to get class
274309
try {
275310
Class<?> clazz = Thread.currentThread().getContextClassLoader().loadClass(className);
@@ -345,6 +380,12 @@ static BaseToolset resolveToolsetInstanceViaReflection(String toolsetName)
345380
String className = toolsetName.substring(0, lastDotIndex);
346381
String fieldName = toolsetName.substring(lastDotIndex + 1);
347382

383+
// Security: Only allow class loading from trusted ADK packages
384+
if (!isAllowedClassForLoading(className)) {
385+
logger.warn("Blocked dynamic class loading from untrusted package: {}", className);
386+
return null;
387+
}
388+
348389
Class<?> clazz = Thread.currentThread().getContextClassLoader().loadClass(className);
349390

350391
try {
@@ -395,6 +436,11 @@ static BaseTool resolveToolFromClass(String className, ToolArgsConfig args, Stri
395436
if (classOpt.isPresent()) {
396437
toolClass = classOpt.get();
397438
} else if (isJavaQualifiedName(className)) {
439+
// Security: Only allow class loading from trusted ADK packages
440+
if (!isAllowedClassForLoading(className)) {
441+
logger.warn("Blocked dynamic class loading from untrusted package: {}", className);
442+
return null;
443+
}
398444
// Try reflection to get class
399445
try {
400446
Class<?> clazz = Thread.currentThread().getContextClassLoader().loadClass(className);
@@ -435,7 +481,8 @@ static BaseTool resolveToolFromClass(String className, ToolArgsConfig args, Stri
435481
// No args provided or empty args, try default constructor
436482
try {
437483
Constructor<? extends BaseTool> constructor = toolClass.getDeclaredConstructor();
438-
constructor.setAccessible(true);
484+
// Security: Do not call setAccessible(true) — only use public constructors
485+
// to prevent bypassing access controls on non-public classes.
439486
return constructor.newInstance();
440487
} catch (NoSuchMethodException e) {
441488
throw new ConfigurationException(
@@ -491,6 +538,12 @@ static BaseTool resolveInstanceViaReflection(String toolName)
491538
String className = toolName.substring(0, lastDotIndex);
492539
String fieldName = toolName.substring(lastDotIndex + 1);
493540

541+
// Security: Only allow class loading from trusted ADK packages
542+
if (!isAllowedClassForLoading(className)) {
543+
logger.warn("Blocked dynamic class loading from untrusted package: {}", className);
544+
return null;
545+
}
546+
494547
Class<?> clazz = Thread.currentThread().getContextClassLoader().loadClass(className);
495548

496549
try {

core/src/main/java/com/google/adk/utils/ComponentRegistry.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,33 @@ private static <T> Optional<Class<? extends T>> getType(String name, Class<T> ty
437437
.map(clazz -> clazz.asSubclass(type));
438438
}
439439

440+
/**
441+
* Allowlist of trusted package prefixes for dynamic class loading.
442+
*
443+
* <p>Security: Only classes from these packages can be loaded via reflection when specified in
444+
* YAML agent configurations. This prevents arbitrary class loading attacks (CVE-2026-4810).
445+
*/
446+
private static final Set<String> ALLOWED_CLASS_PREFIXES =
447+
Set.of("com.google.adk.", "google.adk.");
448+
449+
private static boolean isAllowedClassForLoading(String className) {
450+
if (isNullOrEmpty(className)) {
451+
return false;
452+
}
453+
for (String prefix : ALLOWED_CLASS_PREFIXES) {
454+
if (className.startsWith(prefix)) {
455+
return true;
456+
}
457+
}
458+
return false;
459+
}
460+
440461
private static Optional<Class<? extends BaseToolset>> loadToolsetClass(String className) {
462+
// Security: Only allow class loading from trusted ADK packages
463+
if (!isAllowedClassForLoading(className)) {
464+
logger.warn("Blocked dynamic class loading from untrusted package: {}", className);
465+
return Optional.empty();
466+
}
441467
try {
442468
Class<?> clazz = Thread.currentThread().getContextClassLoader().loadClass(className);
443469
if (BaseToolset.class.isAssignableFrom(clazz)) {

core/src/test/java/com/google/adk/agents/ToolResolverTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ public void testResolveToolsetInstanceViaReflection_fieldNotFound_returnsNull()
149149

150150
@Test
151151
public void testResolveToolsetInstanceViaReflection_classNotFound_throwsException() {
152-
String toolsetName = "com.nonexistent.package.NonExistentClass.FIELD";
152+
String toolsetName = "com.google.adk.nonexistent.NonExistentClass.FIELD";
153153

154154
assertThrows(
155155
ClassNotFoundException.class,
@@ -194,7 +194,7 @@ public void testResolveInstanceViaReflection_fieldNotFound_returnsNull() throws
194194

195195
@Test
196196
public void testResolveInstanceViaReflection_classNotFound_throwsException() {
197-
String toolName = "com.nonexistent.package.NonExistentClass.FIELD";
197+
String toolName = "com.google.adk.nonexistent.NonExistentClass.FIELD";
198198

199199
assertThrows(
200200
ClassNotFoundException.class, () -> ToolResolver.resolveInstanceViaReflection(toolName));

0 commit comments

Comments
 (0)