From 7a54c590def8da9ecba0ca3204241c2943f316b2 Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Wed, 28 Jan 2026 13:45:12 -0500 Subject: [PATCH 1/3] fix: Deserialization checks valid class types for HttpTransportFactory --- .../auth/appengine/AppEngineCredentials.java | 26 +- .../AppEngineDeserializationSecurityTest.java | 104 +++++++ .../auth/oauth2/ComputeEngineCredentials.java | 3 + ...ernalAccountAuthorizedUserCredentials.java | 3 + .../oauth2/ExternalAccountCredentials.java | 3 + .../google/auth/oauth2/GdchCredentials.java | 3 + .../auth/oauth2/ImpersonatedCredentials.java | 3 + .../google/auth/oauth2/OAuth2Credentials.java | 60 +++- .../oauth2/ServiceAccountCredentials.java | 3 + .../google/auth/oauth2/UserCredentials.java | 3 + .../oauth2/DeserializationSecurityTest.java | 289 ++++++++++++++++++ 11 files changed, 495 insertions(+), 5 deletions(-) create mode 100644 appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java diff --git a/appengine/java/com/google/auth/appengine/AppEngineCredentials.java b/appengine/java/com/google/auth/appengine/AppEngineCredentials.java index 16d046907..4bf3f2614 100644 --- a/appengine/java/com/google/auth/appengine/AppEngineCredentials.java +++ b/appengine/java/com/google/auth/appengine/AppEngineCredentials.java @@ -43,6 +43,8 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; import java.io.ObjectInputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.util.Collection; import java.util.Date; import java.util.Objects; @@ -132,7 +134,29 @@ public boolean equals(Object obj) { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { input.defaultReadObject(); - appIdentityService = newInstance(appIdentityServiceClassName); + try { + // Load the class without initializing it (second argument: false) to prevent + // static initializers from running, which could potentially be used for + // malicious purposes. Use the class loader of AppIdentityService (third + // argument) to ensure the class is loaded from the same context as the library, + // preventing class loading manipulation. + Class clazz = + Class.forName( + appIdentityServiceClassName, false, AppIdentityService.class.getClassLoader()); + if (!AppIdentityService.class.isAssignableFrom(clazz)) { + throw new IOException( + String.format( + "The class, %s, is not assignable from %s.", + appIdentityServiceClassName, AppIdentityService.class.getName())); + } + Constructor constructor = clazz.getConstructor(); + appIdentityService = (AppIdentityService) constructor.newInstance(); + } catch (InstantiationException + | IllegalAccessException + | NoSuchMethodException + | InvocationTargetException e) { + throw new IOException(e); + } } public static Builder newBuilder() { diff --git a/appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java b/appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java new file mode 100644 index 000000000..63ed9781d --- /dev/null +++ b/appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java @@ -0,0 +1,104 @@ +package com.google.auth.appengine; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.lang.reflect.Field; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +class AppEngineDeserializationSecurityTest { + + static class ArbitraryClass { + public ArbitraryClass() {} + } + + @Test + void testArbitraryClassInstantiationPrevented() throws Exception { + // 1. Create valid credentials + AppEngineCredentials credentials = + AppEngineCredentials.newBuilder().setScopes(Collections.singleton("scope")).build(); + + // 2. Use reflection to set appIdentityServiceClassName to ArbitraryClass + // as the setter must be of AppIdentityService + Field classNameField = + AppEngineCredentials.class.getDeclaredField("appIdentityServiceClassName"); + classNameField.setAccessible(true); + classNameField.set(credentials, ArbitraryClass.class.getName()); + + // 3. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 4. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + // 5. Assert that IOException is thrown (validation failure) + assertThrows(IOException.class, ois::readObject); + } + + @Test + void testValidServiceDeserialization() throws Exception { + // 1. Create valid credentials with MockAppIdentityService + MockAppIdentityService mockService = new MockAppIdentityService(); + AppEngineCredentials credentials = + AppEngineCredentials.newBuilder() + .setScopes(Collections.singleton("scope")) + .setAppIdentityService(mockService) + .build(); + + // 2. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 3. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + AppEngineCredentials deserialized = (AppEngineCredentials) ois.readObject(); + + // 4. Verify deserialization success and field type + assertNotNull(deserialized); + Field serviceField = AppEngineCredentials.class.getDeclaredField("appIdentityService"); + serviceField.setAccessible(true); + Object service = serviceField.get(deserialized); + assertEquals(MockAppIdentityService.class, service.getClass()); + } + + @Test + void testNonExistentClassDeserialization() throws Exception { + // 1. Create valid credentials + AppEngineCredentials credentials = + AppEngineCredentials.newBuilder().setScopes(Collections.singleton("scope")).build(); + + // 2. Use reflection to set appIdentityServiceClassName to non-existent class + Field classNameField = + AppEngineCredentials.class.getDeclaredField("appIdentityServiceClassName"); + classNameField.setAccessible(true); + classNameField.set(credentials, "com.google.nonexistent.Class"); + + // 3. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 4. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + // 5. Assert ClassNotFoundException + assertThrows(ClassNotFoundException.class, ois::readObject); + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index d36b5c3df..c190a62ab 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -673,6 +673,9 @@ public boolean equals(Object obj) { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { input.defaultReadObject(); + // Use Oauth2Credential's newInstance() to try to safely instantiate the transport factory, + // with best-effort prevention against RCE attacks by validating the class name and loading + // behavior. transportFactory = newInstance(transportFactoryClassName); } diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java index eb2d64e09..fab66c79d 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java @@ -355,6 +355,9 @@ static ExternalAccountAuthorizedUserCredentials fromJson( private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { input.defaultReadObject(); + // Use Oauth2Credential's newInstance() to try to safely instantiate the transport factory, + // with best-effort prevention against RCE attacks by validating the class name and loading + // behavior. transportFactory = newInstance(transportFactoryClassName); } diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 7f9f0c207..fc0b4c72f 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -605,6 +605,9 @@ public CredentialSource getCredentialSource() { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { // Properly deserialize the transient transportFactory. input.defaultReadObject(); + // Use Oauth2Credential's newInstance() to try to safely instantiate the transport factory, + // with best-effort prevention against RCE attacks by validating the class name and loading + // behavior. transportFactory = newInstance(transportFactoryClassName); } diff --git a/oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java b/oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java index c5e8bd576..7dc7766e4 100644 --- a/oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java @@ -378,6 +378,9 @@ public Builder toBuilder() { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { // properly deserialize the transient transportFactory. input.defaultReadObject(); + // Use Oauth2Credential's newInstance() to try to safely instantiate the transport factory, + // with best-effort prevention against RCE attacks by validating the class name and loading + // behavior. transportFactory = newInstance(transportFactoryClassName); } diff --git a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 5b3df6fec..66192f328 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -911,6 +911,9 @@ public ImpersonatedCredentials build() { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { input.defaultReadObject(); + // Use Oauth2Credential's newInstance() to try to safely instantiate the transport factory, + // with best-effort prevention against RCE attacks by validating the class name and loading + // behavior. transportFactory = newInstance(transportFactoryClassName); } } diff --git a/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java b/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java index dfeb5966a..e4cfa945c 100644 --- a/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java @@ -35,6 +35,7 @@ import com.google.auth.Credentials; import com.google.auth.RequestMetadataCallback; import com.google.auth.http.AuthHttpConstants; +import com.google.auth.http.HttpTransportFactory; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; @@ -51,6 +52,8 @@ import java.io.IOException; import java.io.ObjectInputStream; import java.io.Serializable; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.net.URI; import java.time.Duration; import java.util.ArrayList; @@ -475,11 +478,60 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou refreshTask = null; } - @SuppressWarnings("unchecked") - protected static T newInstance(String className) throws IOException, ClassNotFoundException { + /** + * Best-effort safe mechanism to attempt to instantiate an {@link HttpTransportFactory} from a + * class name. + * + *

This method attempts to avoid Arbitrary Code Execution (ACE) vulnerabilities by: + * + *

    + *
  1. Checking if the class name matches the default or ServiceLoader-provided factory, and + * returning that instance if so. + *
  2. If not, loading the class using reflection without running static initializers. + *
  3. Verifying that the loaded class is assignable to {@link HttpTransportFactory}. + *
  4. Only after verification, instantiating the class using its default constructor. + *
+ * + * @param className The fully qualified name of the class to instantiate. + * @return An instance of {@link HttpTransportFactory}. + * @throws IOException If the class cannot be loaded, is the wrong type, or cannot be + * instantiated. + * @throws ClassNotFoundException If the class cannot be found. + */ + protected static HttpTransportFactory newInstance(String className) + throws IOException, ClassNotFoundException { + // Check if the requested class matches the default or ServiceLoader-provided + // factory. This avoids unsafe reflection for the most common use cases. + // This check runs first to replicate the logic in Credential constructor. + HttpTransportFactory currentFactory = + getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + // It is possible that there is a custom implementation of HttpTransportFactory + if (className.equals(currentFactory.getClass().getName())) { + return currentFactory; + } + + // Fallback to reflection if the requested class differs from the ServiceLoader + // default. This handles cases where a custom factory was used during + // serialization but is not the currently active ServiceLoader provider. try { - return (T) Class.forName(className).newInstance(); - } catch (InstantiationException | IllegalAccessException e) { + // Load the class without initializing it (second argument: false) to prevent + // static initializers from running, which could potentially be used for + // malicious purposes. Use the class loader of HttpTransportFactory (third argument) + // to ensure the class is loaded from the same context as the library, preventing + // class loading manipulation. + Class clazz = Class.forName(className, false, HttpTransportFactory.class.getClassLoader()); + if (!HttpTransportFactory.class.isAssignableFrom(clazz)) { + throw new IOException( + String.format( + "The class, %s, is not assignable from %s.", + className, HttpTransportFactory.class.getName())); + } + Constructor constructor = clazz.getDeclaredConstructor(); + return (HttpTransportFactory) constructor.newInstance(); + } catch (InstantiationException + | IllegalAccessException + | NoSuchMethodException + | InvocationTargetException e) { throw new IOException(e); } } diff --git a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java index 15700dd7f..db846e780 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java @@ -1126,6 +1126,9 @@ private Map> getRequestMetadataWithSelfSignedJwt(URI uri) private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { // properly deserialize the transient transportFactory input.defaultReadObject(); + // Use Oauth2Credential's newInstance() to try to safely instantiate the transport factory, + // with best-effort prevention against RCE attacks by validating the class name and loading + // behavior. transportFactory = newInstance(transportFactoryClassName); } diff --git a/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java b/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java index fa4399765..303cf6c32 100644 --- a/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java @@ -389,6 +389,9 @@ public boolean equals(Object obj) { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { input.defaultReadObject(); + // Use Oauth2Credential's newInstance() to try to safely instantiate the transport factory, + // with best-effort prevention against RCE attacks by validating the class name and loading + // behavior. transportFactory = newInstance(transportFactoryClassName); } diff --git a/oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java b/oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java new file mode 100644 index 000000000..7dddc8b45 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java @@ -0,0 +1,289 @@ +package com.google.auth.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.auth.http.HttpTransportFactory; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.lang.reflect.Field; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.Callable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class DeserializationSecurityTest { + + /** A class that does not implement HttpTransportFactory. */ + static class ArbitraryClass { + public ArbitraryClass() {} + } + + /** A custom implementation of HttpTransportFactory that should be allowed. */ + static class CustomTransportFactory implements HttpTransportFactory { + public HttpTransport create() { + return new NetHttpTransport(); + } + } + + /** + * Implementation that is registered via + * META-INF/services/com.google.auth.http.HttpTransportFactory. + */ + public static class TestServiceLoaderFactory implements HttpTransportFactory { + public TestServiceLoaderFactory() {} + + @Override + public HttpTransport create() { + return new NetHttpTransport(); + } + } + + private ServiceAccountCredentials createCredentials() throws Exception { + String json = + "{" + + "\"type\": \"service_account\"," + + "\"project_id\": \"project-id\"," + + "\"private_key_id\": \"private-key-id\"," + + "\"private_key\": \"" + + getSAPrivateKey() + + "\"," + + "\"client_email\": \"client-email\"," + + "\"client_id\": \"client-id\"," + + "\"auth_uri\": \"https://accounts.google.com/o/oauth2/auth\"," + + "\"token_uri\": \"https://accounts.google.com/o/oauth2/token\"," + + "\"auth_provider_x509_cert_url\": \"https://www.googleapis.com/oauth2/v1/certs\"," + + "\"client_x509_cert_url\": \"https://www.googleapis.com/robot/v1/metadata/x509/client-email\"" + + "}"; + return ServiceAccountCredentials.fromStream(new ByteArrayInputStream(json.getBytes())); + } + + private static String getSAPrivateKey() { + return ServiceAccountCredentialsTest.PRIVATE_KEY_PKCS8.replace("\n", "\\n"); + } + + @Test + void testArbitraryClassInstantiationPrevented() throws Exception { + // 1. Create a valid ServiceAccountCredentials + ServiceAccountCredentials credentials = createCredentials(); + + // 2. Use reflection to set transportFactoryClassName to our arbitrary class. + // We expect verification failure because ArbitraryClass does not implement + // HttpTransportFactory. We use reflection here to verify that we cannot + // deserialize into an arbitrary class, simulating a malicious stream which + // could not be created via the public Builder API. + Field transportFactoryClassNameField = + ServiceAccountCredentials.class.getDeclaredField("transportFactoryClassName"); + transportFactoryClassNameField.setAccessible(true); + transportFactoryClassNameField.set(credentials, ArbitraryClass.class.getName()); + + // 3. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 4. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + // 5. Assert that an exception is thrown because ArbitraryClass is not a valid + // HttpTransportFactory + assertThrows(IOException.class, ois::readObject); + } + + @Test + void testValidTransportFactoryDeserialization() throws Exception { + // 1. Create a valid ServiceAccountCredentials + ServiceAccountCredentials credentials = createCredentials(); + + // 2. Use the builder to set the transport factory. + // This will set the transportFactoryClassName field used during serialization. + credentials = + credentials.toBuilder() + .setHttpTransportFactory(new OAuth2Utils.DefaultHttpTransportFactory()) + .build(); + + // 3. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 4. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + ServiceAccountCredentials deserialized = (ServiceAccountCredentials) ois.readObject(); + + // 5. Assert that it deserialized correctly and has the correct factory type + assertNotNull(deserialized); + + // Use reflection to get the field as there is no getter for transportFactory. + Field transportFactoryField = + ServiceAccountCredentials.class.getDeclaredField("transportFactory"); + transportFactoryField.setAccessible(true); + Object factory = transportFactoryField.get(deserialized); + + assertEquals( + OAuth2Utils.DefaultHttpTransportFactory.class.getName(), factory.getClass().getName()); + } + + @Test + void testNonExistentClassDeserialization() throws Exception { + // 1. Create a valid ServiceAccountCredentials + ServiceAccountCredentials credentials = createCredentials(); + + // 2. Use reflection to set transportFactoryClassName to a non-existent class. + // We use reflection here to verify that we cannot deserialize into a + // non-existent class, simulating a malicious stream which could not be created + // via the public Builder API. + Field transportFactoryClassNameField = + ServiceAccountCredentials.class.getDeclaredField("transportFactoryClassName"); + transportFactoryClassNameField.setAccessible(true); + transportFactoryClassNameField.set(credentials, "com.malicious.NonExistentClass"); + + // 3. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 4. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + // 5. Assert that ClassNotFoundException is thrown + assertThrows(ClassNotFoundException.class, ois::readObject); + } + + @Test + void testCustomTransportFactory() throws Exception { + // 1. Create a valid ServiceAccountCredentials + ServiceAccountCredentials credentials = createCredentials(); + + // 2. Use the builder to set our custom transport factory. + // This will set the transportFactoryClassName field used during serialization. + credentials = + credentials.toBuilder().setHttpTransportFactory(new CustomTransportFactory()).build(); + + // 3. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 4. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + ServiceAccountCredentials deserialized = (ServiceAccountCredentials) ois.readObject(); + + // 5. Assert that it IS instantiated (by verifying the factory type) + assertNotNull(deserialized); + + // Use reflection to get the field as there is no getter for transportFactory. + Field transportFactoryField = + ServiceAccountCredentials.class.getDeclaredField("transportFactory"); + transportFactoryField.setAccessible(true); + Object factory = transportFactoryField.get(deserialized); + + assertEquals(CustomTransportFactory.class.getName(), factory.getClass().getName()); + } + + /** + * Helper method to run a task within a temporary ServiceLoader environment. + * + *

This method prevents test pollution by isolating the ServiceLoader configuration. It creates + * a temporary directory structure for META-INF/services, writes the service provider + * configuration file, and creates a URLClassLoader that includes this temporary directory. It + * then sets the current thread's context class loader (TCCL) to this custom class loader before + * executing the task. This ensures that ServiceLoader.load() calls within the task will find the + * specific service provider defined for the test, without affecting other tests or the global + * environment. + * + * @param task The task to execute within the isolated environment. + * @param tempDir The temporary directory to use for ServiceLoader configuration. + * @throws Exception If any error occurs during setup, execution, or cleanup. + */ + private void runWithTempServiceLoader(Callable task, Path tempDir) throws Exception { + ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); + try { + Path servicesDir = tempDir.resolve("META-INF").resolve("services"); + Files.createDirectories(servicesDir); + Path serviceFile = servicesDir.resolve(HttpTransportFactory.class.getName()); + Files.write( + serviceFile, TestServiceLoaderFactory.class.getName().getBytes(StandardCharsets.UTF_8)); + + URL[] urls = new URL[] {tempDir.toUri().toURL()}; + try (URLClassLoader testClassLoader = new URLClassLoader(urls, originalClassLoader)) { + Thread.currentThread().setContextClassLoader(testClassLoader); + task.call(); + } + } finally { + Thread.currentThread().setContextClassLoader(originalClassLoader); + } + } + + @Test + void testServiceLoaderPathDeserialization(@TempDir Path tempDir) throws Exception { + runWithTempServiceLoader( + () -> { + // 1. Create a ServiceAccountCredentials using the builder WITHOUT setting the + // transport factory. The constructor should automatically look up the factory + // via ServiceLoader. + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail("client-email") + .setPrivateKeyId("private-key-id") + .setPrivateKeyString(ServiceAccountCredentialsTest.PRIVATE_KEY_PKCS8) + .setProjectId("project-id") + .build(); + + // 2. Verify that the credentials were created with the + // TestServiceLoaderFactory. + // This confirms that the ServiceLoader mechanism in the constructor is working. + Field transportFactoryClassNameField = + ServiceAccountCredentials.class.getDeclaredField("transportFactoryClassName"); + transportFactoryClassNameField.setAccessible(true); + assertEquals( + TestServiceLoaderFactory.class.getName(), + transportFactoryClassNameField.get(credentials)); + + // 3. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 4. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + ServiceAccountCredentials deserialized = (ServiceAccountCredentials) ois.readObject(); + + // 5. Assert that it deserialized correctly and has the correct factory type. + // This confirms that newInstance() found it via the ServiceLoader path. + assertNotNull(deserialized); + + Field transportFactoryField = + ServiceAccountCredentials.class.getDeclaredField("transportFactory"); + transportFactoryField.setAccessible(true); + Object factory = transportFactoryField.get(deserialized); + + assertEquals(TestServiceLoaderFactory.class.getName(), factory.getClass().getName()); + return null; + }, + tempDir); + } +} From b6c041fdc21e4683a5f2ecffe25917ac89820edc Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Wed, 28 Jan 2026 15:10:31 -0500 Subject: [PATCH 2/3] chore: Add headers for newly added files --- .../AppEngineDeserializationSecurityTest.java | 31 +++++++++++++++++++ .../oauth2/DeserializationSecurityTest.java | 31 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java b/appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java index 63ed9781d..ab48dc1fb 100644 --- a/appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java +++ b/appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java @@ -1,3 +1,34 @@ +/* + * Copyright 2026, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + package com.google.auth.appengine; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java b/oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java index 7dddc8b45..770de3e77 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java @@ -1,3 +1,34 @@ +/* + * Copyright 2026, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + package com.google.auth.oauth2; import static org.junit.jupiter.api.Assertions.assertEquals; From de645ecd90f35b4c2eda465a6e90bfedd92800b9 Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Wed, 28 Jan 2026 15:18:59 -0500 Subject: [PATCH 3/3] chore: Fix sonatype complaints --- .../AppEngineDeserializationSecurityTest.java | 5 +- .../oauth2/DeserializationSecurityTest.java | 79 +++++++++---------- 2 files changed, 40 insertions(+), 44 deletions(-) diff --git a/appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java b/appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java index ab48dc1fb..42b6a6f08 100644 --- a/appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java +++ b/appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java @@ -46,9 +46,8 @@ class AppEngineDeserializationSecurityTest { - static class ArbitraryClass { - public ArbitraryClass() {} - } + /** A class that does not implement HttpTransportFactory. */ + static class ArbitraryClass {} @Test void testArbitraryClassInstantiationPrevented() throws Exception { diff --git a/oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java b/oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java index 770de3e77..633d1e5a8 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java @@ -56,24 +56,21 @@ class DeserializationSecurityTest { /** A class that does not implement HttpTransportFactory. */ - static class ArbitraryClass { - public ArbitraryClass() {} - } + static class ArbitraryClass {} /** A custom implementation of HttpTransportFactory that should be allowed. */ static class CustomTransportFactory implements HttpTransportFactory { + @Override public HttpTransport create() { return new NetHttpTransport(); } } /** - * Implementation that is registered via - * META-INF/services/com.google.auth.http.HttpTransportFactory. + * An implementation of HttpTransportFactory that can registered to the ServiceLoader. Used in + * {@link #runWithTempServiceLoader} to load to `META_INF/services/*`. */ public static class TestServiceLoaderFactory implements HttpTransportFactory { - public TestServiceLoaderFactory() {} - @Override public HttpTransport create() { return new NetHttpTransport(); @@ -103,6 +100,40 @@ private static String getSAPrivateKey() { return ServiceAccountCredentialsTest.PRIVATE_KEY_PKCS8.replace("\n", "\\n"); } + /** + * Helper method to run a task within a temporary ServiceLoader environment. + * + *

This method prevents test pollution by isolating the ServiceLoader configuration. It creates + * a temporary directory structure for META-INF/services, writes the service provider + * configuration file, and creates a URLClassLoader that includes this temporary directory. It + * then sets the current thread's context class loader (TCCL) to this custom class loader before + * executing the task. This ensures that ServiceLoader.load() calls within the task will find the + * specific service provider defined for the test, without affecting other tests or the global + * environment. + * + * @param task The task to execute within the isolated environment. + * @param tempDir The temporary directory to use for ServiceLoader configuration. + * @throws Exception If any error occurs during setup, execution, or cleanup. + */ + private void runWithTempServiceLoader(Callable task, Path tempDir) throws Exception { + ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); + try { + Path servicesDir = tempDir.resolve("META-INF").resolve("services"); + Files.createDirectories(servicesDir); + Path serviceFile = servicesDir.resolve(HttpTransportFactory.class.getName()); + Files.write( + serviceFile, TestServiceLoaderFactory.class.getName().getBytes(StandardCharsets.UTF_8)); + + URL[] urls = new URL[] {tempDir.toUri().toURL()}; + try (URLClassLoader testClassLoader = new URLClassLoader(urls, originalClassLoader)) { + Thread.currentThread().setContextClassLoader(testClassLoader); + task.call(); + } + } finally { + Thread.currentThread().setContextClassLoader(originalClassLoader); + } + } + @Test void testArbitraryClassInstantiationPrevented() throws Exception { // 1. Create a valid ServiceAccountCredentials @@ -232,40 +263,6 @@ void testCustomTransportFactory() throws Exception { assertEquals(CustomTransportFactory.class.getName(), factory.getClass().getName()); } - /** - * Helper method to run a task within a temporary ServiceLoader environment. - * - *

This method prevents test pollution by isolating the ServiceLoader configuration. It creates - * a temporary directory structure for META-INF/services, writes the service provider - * configuration file, and creates a URLClassLoader that includes this temporary directory. It - * then sets the current thread's context class loader (TCCL) to this custom class loader before - * executing the task. This ensures that ServiceLoader.load() calls within the task will find the - * specific service provider defined for the test, without affecting other tests or the global - * environment. - * - * @param task The task to execute within the isolated environment. - * @param tempDir The temporary directory to use for ServiceLoader configuration. - * @throws Exception If any error occurs during setup, execution, or cleanup. - */ - private void runWithTempServiceLoader(Callable task, Path tempDir) throws Exception { - ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); - try { - Path servicesDir = tempDir.resolve("META-INF").resolve("services"); - Files.createDirectories(servicesDir); - Path serviceFile = servicesDir.resolve(HttpTransportFactory.class.getName()); - Files.write( - serviceFile, TestServiceLoaderFactory.class.getName().getBytes(StandardCharsets.UTF_8)); - - URL[] urls = new URL[] {tempDir.toUri().toURL()}; - try (URLClassLoader testClassLoader = new URLClassLoader(urls, originalClassLoader)) { - Thread.currentThread().setContextClassLoader(testClassLoader); - task.call(); - } - } finally { - Thread.currentThread().setContextClassLoader(originalClassLoader); - } - } - @Test void testServiceLoaderPathDeserialization(@TempDir Path tempDir) throws Exception { runWithTempServiceLoader(