diff --git a/binder/build.gradle b/binder/build.gradle index 3390e02fce7..7ac23750a2a 100644 --- a/binder/build.gradle +++ b/binder/build.gradle @@ -72,6 +72,7 @@ dependencies { androidTestImplementation testFixtures(project(':grpc-core')) testFixturesImplementation libraries.guava.testlib + testFixturesImplementation testFixtures(project(':grpc-core')) } import net.ltgt.gradle.errorprone.CheckSeverity diff --git a/binder/src/androidTest/java/io/grpc/binder/internal/BinderClientTransportTest.java b/binder/src/androidTest/java/io/grpc/binder/internal/BinderClientTransportTest.java index 2af7210e9a7..fe2fd587453 100644 --- a/binder/src/androidTest/java/io/grpc/binder/internal/BinderClientTransportTest.java +++ b/binder/src/androidTest/java/io/grpc/binder/internal/BinderClientTransportTest.java @@ -172,6 +172,12 @@ public BinderClientTransportBuilder setReadyTimeoutMillis(int timeoutMillis) { return this; } + @CanIgnoreReturnValue + public BinderClientTransportBuilder setPreAuthorizeServer(boolean preAuthorizeServer) { + factoryBuilder.setPreAuthorizeServers(preAuthorizeServer); + return this; + } + public BinderTransport.BinderClientTransport build() { return factoryBuilder .buildClientTransportFactory() @@ -372,11 +378,12 @@ public void testBlackHoleEndpointConnectTimeout() throws Exception { } @Test - public void testBlackHoleSecurityPolicyConnectTimeout() throws Exception { + public void testBlackHoleSecurityPolicyAuthTimeout() throws Exception { SettableAsyncSecurityPolicy securityPolicy = new SettableAsyncSecurityPolicy(); transport = new BinderClientTransportBuilder() .setSecurityPolicy(securityPolicy) + .setPreAuthorizeServer(false) .setReadyTimeoutMillis(1_234) .build(); transport.start(transportListener).run(); @@ -387,15 +394,39 @@ public void testBlackHoleSecurityPolicyConnectTimeout() throws Exception { assertThat(transportStatus.getCode()).isEqualTo(Code.DEADLINE_EXCEEDED); assertThat(transportStatus.getDescription()).contains("1234"); transportListener.awaitTermination(); - // If the transport gave up waiting on auth, it should cancel its request. assertThat(authRequest.isCancelled()).isTrue(); } @Test - public void testAsyncSecurityPolicyFailure() throws Exception { + public void testBlackHoleSecurityPolicyPreAuthTimeout() throws Exception { SettableAsyncSecurityPolicy securityPolicy = new SettableAsyncSecurityPolicy(); - transport = new BinderClientTransportBuilder().setSecurityPolicy(securityPolicy).build(); + transport = + new BinderClientTransportBuilder() + .setSecurityPolicy(securityPolicy) + .setPreAuthorizeServer(true) + .setReadyTimeoutMillis(1_234) + .build(); + transport.start(transportListener).run(); + // Take the next authRequest but don't respond to it, in order to trigger the ready timeout. + AuthRequest preAuthRequest = securityPolicy.takeNextAuthRequest(TIMEOUT_SECONDS, SECONDS); + + Status transportStatus = transportListener.awaitShutdown(); + assertThat(transportStatus.getCode()).isEqualTo(Code.DEADLINE_EXCEEDED); + assertThat(transportStatus.getDescription()).contains("1234"); + transportListener.awaitTermination(); + // If the transport gave up waiting on auth, it should cancel its request. + assertThat(preAuthRequest.isCancelled()).isTrue(); + } + + @Test + public void testAsyncSecurityPolicyAuthFailure() throws Exception { + SettableAsyncSecurityPolicy securityPolicy = new SettableAsyncSecurityPolicy(); + transport = + new BinderClientTransportBuilder() + .setPreAuthorizeServer(false) + .setSecurityPolicy(securityPolicy) + .build(); RuntimeException exception = new NullPointerException(); transport.start(transportListener).run(); securityPolicy.takeNextAuthRequest(TIMEOUT_SECONDS, SECONDS).setResult(exception); @@ -406,15 +437,55 @@ public void testAsyncSecurityPolicyFailure() throws Exception { } @Test - public void testAsyncSecurityPolicySuccess() throws Exception { + public void testAsyncSecurityPolicyPreAuthFailure() throws Exception { SettableAsyncSecurityPolicy securityPolicy = new SettableAsyncSecurityPolicy(); - transport = new BinderClientTransportBuilder().setSecurityPolicy(securityPolicy).build(); + transport = + new BinderClientTransportBuilder() + .setPreAuthorizeServer(true) + .setSecurityPolicy(securityPolicy) + .build(); + RuntimeException exception = new NullPointerException(); + transport.start(transportListener).run(); + securityPolicy.takeNextAuthRequest(TIMEOUT_SECONDS, SECONDS).setResult(exception); + Status transportStatus = transportListener.awaitShutdown(); + assertThat(transportStatus.getCode()).isEqualTo(Code.INTERNAL); + assertThat(transportStatus.getCause()).isEqualTo(exception); + transportListener.awaitTermination(); + } + + @Test + public void testAsyncSecurityPolicyAuthSuccess() throws Exception { + SettableAsyncSecurityPolicy securityPolicy = new SettableAsyncSecurityPolicy(); + transport = + new BinderClientTransportBuilder() + .setPreAuthorizeServer(false) + .setSecurityPolicy(securityPolicy) + .build(); + transport.start(transportListener).run(); + securityPolicy + .takeNextAuthRequest(TIMEOUT_SECONDS, SECONDS) + .setResult(Status.PERMISSION_DENIED.withDescription("xyzzy")); + Status transportStatus = transportListener.awaitShutdown(); + assertThat(transportStatus.getCode()).isEqualTo(Code.PERMISSION_DENIED); + assertThat(transportStatus.getDescription()).contains("xyzzy"); + transportListener.awaitTermination(); + } + + @Test + public void testAsyncSecurityPolicyPreAuthSuccess() throws Exception { + SettableAsyncSecurityPolicy securityPolicy = new SettableAsyncSecurityPolicy(); + transport = + new BinderClientTransportBuilder() + .setPreAuthorizeServer(true) + .setSecurityPolicy(securityPolicy) + .build(); transport.start(transportListener).run(); securityPolicy .takeNextAuthRequest(TIMEOUT_SECONDS, SECONDS) - .setResult(Status.PERMISSION_DENIED); + .setResult(Status.PERMISSION_DENIED.withDescription("xyzzy")); Status transportStatus = transportListener.awaitShutdown(); assertThat(transportStatus.getCode()).isEqualTo(Code.PERMISSION_DENIED); + assertThat(transportStatus.getDescription()).contains("xyzzy"); transportListener.awaitTermination(); } diff --git a/binder/src/main/java/io/grpc/binder/ApiConstants.java b/binder/src/main/java/io/grpc/binder/ApiConstants.java index 05950a9eaec..462586311c6 100644 --- a/binder/src/main/java/io/grpc/binder/ApiConstants.java +++ b/binder/src/main/java/io/grpc/binder/ApiConstants.java @@ -18,6 +18,8 @@ import android.content.Intent; import android.os.UserHandle; +import io.grpc.Attributes; +import io.grpc.EquivalentAddressGroup; import io.grpc.ExperimentalApi; import io.grpc.NameResolver; @@ -46,4 +48,16 @@ private ApiConstants() {} @ExperimentalApi("https://github.com/grpc/grpc-java/issues/10173") public static final NameResolver.Args.Key TARGET_ANDROID_USER = NameResolver.Args.Key.create("target-android-user"); + + /** + * Lets you override a Channel's pre-auth configuration (see {@link + * BinderChannelBuilder#preAuthorizeServers(boolean)}) for a given {@link EquivalentAddressGroup}. + * + *

A {@link NameResolver} that discovers servers from an untrusted source like PackageManager + * can use this to force server pre-auth and prevent abuse. + */ + @EquivalentAddressGroup.Attr + @ExperimentalApi("https://github.com/grpc/grpc-java/issues/12191") + public static final Attributes.Key PRE_AUTH_SERVER_OVERRIDE = + Attributes.Key.create("pre-auth-server-override"); } diff --git a/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java b/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java index f996ee7cdbf..233ec6eac4f 100644 --- a/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java +++ b/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java @@ -279,6 +279,35 @@ public BinderChannelBuilder strictLifecycleManagement() { return this; } + /** + * Checks servers against this Channel's {@link SecurityPolicy} *before* binding. + * + *

Android users can be tricked into installing a malicious app with the same package name as a + * legitimate server. That's why we don't send calls to a server until it has been authorized by + * an appropriate {@link SecurityPolicy}. But merely binding to a malicious server can enable + * "keep-alive" and "background activity launch" abuse, even if it's ultimately unauthorized. + * Pre-authorization mitigates these threats by performing a preliminary {@link SecurityPolicy} + * check against a server app's PackageManager-registered identity without actually creating an + * instance of it. This is especially important for security when the server's direct address + * isn't known in advance but rather resolved via target URI or discovered by other means. + * + *

Note that, unlike ordinary authorization, pre-authorization is performed against the server + * app's UID, not the UID of the process hosting the bound Service. These can be different, most + * commonly due to services that set `android:isolatedProcess=true`. + * + *

Pre-authorization is strongly recommended but it remains optional for now because of this + * behavior change and the small performance cost. + * + *

The default value of this property is false but it will become true in a future release. + * Clients that require a particular behavior should configure it explicitly using this method + * rather than relying on the default. + */ + @ExperimentalApi("https://github.com/grpc/grpc-java/issues/12191") + public BinderChannelBuilder preAuthorizeServers(boolean preAuthorize) { + transportFactoryBuilder.setPreAuthorizeServers(preAuthorize); + return this; + } + @Override public BinderChannelBuilder idleTimeout(long value, TimeUnit unit) { checkState( diff --git a/binder/src/main/java/io/grpc/binder/internal/Bindable.java b/binder/src/main/java/io/grpc/binder/internal/Bindable.java index 8e1af64b63d..ae0c7284faf 100644 --- a/binder/src/main/java/io/grpc/binder/internal/Bindable.java +++ b/binder/src/main/java/io/grpc/binder/internal/Bindable.java @@ -16,10 +16,12 @@ package io.grpc.binder.internal; +import android.content.pm.ServiceInfo; import android.os.IBinder; import androidx.annotation.AnyThread; import androidx.annotation.MainThread; import io.grpc.Status; +import io.grpc.StatusException; /** An interface for managing a {@code Binder} connection. */ interface Bindable { @@ -45,6 +47,19 @@ interface Observer { void onUnbound(Status reason); } + /** + * Fetches details about the remote Service from PackageManager without binding to it. + * + *

Resolving an untrusted address before binding to it lets you screen out problematic servers + * before giving them a chance to run. However, note that the identity/existence of the resolved + * Service can change between the time this method returns and the time you actually bind/connect + * to it. For example, suppose the target package gets uninstalled or upgraded right after this + * method returns. In {@link Observer#onBound}, you should verify that the server you resolved is + * the same one you connected to. + */ + @AnyThread + ServiceInfo resolve() throws StatusException; + /** * Attempt to bind with the remote service. * diff --git a/binder/src/main/java/io/grpc/binder/internal/BinderClientTransportFactory.java b/binder/src/main/java/io/grpc/binder/internal/BinderClientTransportFactory.java index 3852b21d5c3..ef00f70e35d 100644 --- a/binder/src/main/java/io/grpc/binder/internal/BinderClientTransportFactory.java +++ b/binder/src/main/java/io/grpc/binder/internal/BinderClientTransportFactory.java @@ -55,6 +55,7 @@ public final class BinderClientTransportFactory implements ClientTransportFactor final InboundParcelablePolicy inboundParcelablePolicy; final OneWayBinderProxy.Decorator binderDecorator; final long readyTimeoutMillis; + final boolean preAuthorizeServers; // TODO(jdcormie): Default to true. ScheduledExecutorService executorService; Executor offloadExecutor; @@ -75,6 +76,7 @@ private BinderClientTransportFactory(Builder builder) { inboundParcelablePolicy = checkNotNull(builder.inboundParcelablePolicy); binderDecorator = checkNotNull(builder.binderDecorator); readyTimeoutMillis = builder.readyTimeoutMillis; + preAuthorizeServers = builder.preAuthorizeServers; executorService = scheduledExecutorPool.getObject(); offloadExecutor = offloadExecutorPool.getObject(); @@ -128,6 +130,7 @@ public static final class Builder implements ClientTransportFactoryBuilder { InboundParcelablePolicy inboundParcelablePolicy = InboundParcelablePolicy.DEFAULT; OneWayBinderProxy.Decorator binderDecorator = OneWayBinderProxy.IDENTITY_DECORATOR; long readyTimeoutMillis = 60_000; + boolean preAuthorizeServers; @Override public BinderClientTransportFactory buildClientTransportFactory() { @@ -216,5 +219,11 @@ public Builder setReadyTimeoutMillis(long readyTimeoutMillis) { this.readyTimeoutMillis = readyTimeoutMillis; return this; } + + /** Whether to check server addresses against the SecurityPolicy *before* binding to them. */ + public Builder setPreAuthorizeServers(boolean preAuthorizeServers) { + this.preAuthorizeServers = preAuthorizeServers; + return this; + } } } diff --git a/binder/src/main/java/io/grpc/binder/internal/BinderTransport.java b/binder/src/main/java/io/grpc/binder/internal/BinderTransport.java index 92a58b91cf0..d87cfb74044 100644 --- a/binder/src/main/java/io/grpc/binder/internal/BinderTransport.java +++ b/binder/src/main/java/io/grpc/binder/internal/BinderTransport.java @@ -19,9 +19,11 @@ import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; import static com.google.common.util.concurrent.Futures.immediateFuture; +import static io.grpc.binder.ApiConstants.PRE_AUTH_SERVER_OVERRIDE; import static java.util.concurrent.TimeUnit.MILLISECONDS; import android.content.Context; +import android.content.pm.ServiceInfo; import android.os.Binder; import android.os.DeadObjectException; import android.os.IBinder; @@ -78,7 +80,6 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.Nullable; @@ -574,6 +575,7 @@ public static final class BinderClientTransport extends BinderTransport private final long readyTimeoutMillis; private final PingTracker pingTracker; + private final boolean preAuthorizeServer; @Nullable private ManagedClientTransport.Listener clientTransportListener; @@ -585,6 +587,10 @@ public static final class BinderClientTransport extends BinderTransport @GuardedBy("this") @Nullable private ListenableFuture authResultFuture; // null before we check auth. + @GuardedBy("this") + @Nullable + private ListenableFuture preAuthResultFuture; // null before we pre-auth. + /** * Constructs a new transport instance. * @@ -609,6 +615,9 @@ public BinderClientTransport( this.securityPolicy = factory.securityPolicy; this.offloadExecutor = offloadExecutorPool.getObject(); this.readyTimeoutMillis = factory.readyTimeoutMillis; + Boolean preAuthServerOverride = options.getEagAttributes().get(PRE_AUTH_SERVER_OVERRIDE); + this.preAuthorizeServer = + preAuthServerOverride != null ? preAuthServerOverride : factory.preAuthorizeServers; numInUseStreams = new AtomicInteger(); pingTracker = new PingTracker(Ticker.systemTicker(), (id) -> sendPing(id)); @@ -650,7 +659,16 @@ public synchronized Runnable start(ManagedClientTransport.Listener clientTranspo synchronized (BinderClientTransport.this) { if (inState(TransportState.NOT_STARTED)) { setState(TransportState.SETUP); - serviceBinding.bind(); + try { + if (preAuthorizeServer) { + preAuthorize(serviceBinding.resolve()); + } else { + serviceBinding.bind(); + } + } catch (StatusException e) { + shutdownInternal(e.getStatus(), true); + return; + } if (readyTimeoutMillis >= 0) { readyTimeoutFuture = getScheduledExecutorService() @@ -664,6 +682,43 @@ public synchronized Runnable start(ManagedClientTransport.Listener clientTranspo }; } + @GuardedBy("this") + private void preAuthorize(ServiceInfo serviceInfo) { + // It's unlikely, but the identity/existence of this Service could change by the time we + // actually connect. It doesn't matter though, because: + // - If pre-auth fails (but would succeed against the server's new state), the grpc-core layer + // will eventually retry using a new transport instance that will see the Service's new state. + // - If pre-auth succeeds (but would fail against the server's new state), we might give an + // unauthorized server a chance to run, but the connection will still fail by SecurityPolicy + // check later in handshake. Pre-auth remains effective at mitigating abuse because malware + // can't typically control the exact timing of its installation. + preAuthResultFuture = checkServerAuthorizationAsync(serviceInfo.applicationInfo.uid); + Futures.addCallback( + preAuthResultFuture, + new FutureCallback() { + @Override + public void onSuccess(Status result) { + handlePreAuthResult(result); + } + + @Override + public void onFailure(Throwable t) { + handleAuthResult(t); + } + }, + offloadExecutor); + } + + private synchronized void handlePreAuthResult(Status authorization) { + if (inState(TransportState.SETUP)) { + if (!authorization.isOk()) { + shutdownInternal(authorization, true); + } else { + serviceBinding.bind(); + } + } + } + private synchronized void onReadyTimeout() { if (inState(TransportState.SETUP)) { readyTimeoutFuture = null; @@ -758,6 +813,9 @@ void notifyTerminated() { readyTimeoutFuture.cancel(false); readyTimeoutFuture = null; } + if (preAuthResultFuture != null) { + preAuthResultFuture.cancel(false); // No effect if already complete. + } if (authResultFuture != null) { authResultFuture.cancel(false); // No effect if already complete. } @@ -780,11 +838,7 @@ protected void handleSetupTransport(Parcel parcel) { shutdownInternal( Status.UNAVAILABLE.withDescription("Malformed SETUP_TRANSPORT data"), true); } else { - authResultFuture = - (securityPolicy instanceof AsyncSecurityPolicy) - ? ((AsyncSecurityPolicy) securityPolicy).checkAuthorizationAsync(remoteUid) - : Futures.submit( - () -> securityPolicy.checkAuthorization(remoteUid), offloadExecutor); + authResultFuture = checkServerAuthorizationAsync(remoteUid); Futures.addCallback( authResultFuture, new FutureCallback() { @@ -803,6 +857,12 @@ public void onFailure(Throwable t) { } } + private ListenableFuture checkServerAuthorizationAsync(int remoteUid) { + return (securityPolicy instanceof AsyncSecurityPolicy) + ? ((AsyncSecurityPolicy) securityPolicy).checkAuthorizationAsync(remoteUid) + : Futures.submit(() -> securityPolicy.checkAuthorization(remoteUid), offloadExecutor); + } + private synchronized void handleAuthResult(IBinder binder, Status authorization) { if (inState(TransportState.SETUP)) { if (!authorization.isOk()) { diff --git a/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java b/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java index ee171140045..f0cbe9ec56b 100644 --- a/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java +++ b/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java @@ -23,14 +23,22 @@ import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.ServiceInfo; +import android.os.Build; import android.os.IBinder; import android.os.UserHandle; import androidx.annotation.AnyThread; import androidx.annotation.MainThread; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.VerifyException; import com.google.errorprone.annotations.concurrent.GuardedBy; import io.grpc.Status; +import io.grpc.StatusException; import io.grpc.binder.BinderChannelCredentials; +import java.lang.reflect.Method; +import java.util.List; import java.util.concurrent.Executor; import java.util.logging.Level; import java.util.logging.Logger; @@ -85,6 +93,8 @@ public String methodName() { private final Observer observer; private final Executor mainThreadExecutor; + private static volatile Method queryIntentServicesAsUserMethod; + @GuardedBy("this") private State state; @@ -247,6 +257,71 @@ void unbindInternal(Status reason) { } } + // Sadly the PackageManager#resolveServiceAsUser() API we need isn't part of the SDK or even a + // @SystemApi as of this writing. Modern Android prevents even system apps from calling it, by any + // means (https://developer.android.com/guide/app-compatibility/restrictions-non-sdk-interfaces). + // So instead we call queryIntentServicesAsUser(), which does more than we need but *is* a + // @SystemApi in all the SDK versions where we support cross-user Channels. + @Nullable + private static ResolveInfo resolveServiceAsUser( + PackageManager packageManager, Intent intent, int flags, UserHandle targetUserHandle) { + List results = + queryIntentServicesAsUser(packageManager, intent, flags, targetUserHandle); + // The first query result is "what would be returned by resolveService", per the javadoc. + return (results != null && !results.isEmpty()) ? results.get(0) : null; + } + + // The cross-user Channel feature requires the client to be a system app so we assume @SystemApi + // queryIntentServicesAsUser() is visible to us at runtime. It would be visible at build time too, + // if our host system app were written to call it directly. We only have to use reflection here + // because grpc-java is a library built outside the Android source tree where the compiler can't + // see the "non-SDK" @SystemApis that we need. + @Nullable + @SuppressWarnings("unchecked") // Safe by PackageManager#queryIntentServicesAsUser spec in AOSP. + private static List queryIntentServicesAsUser( + PackageManager packageManager, Intent intent, int flags, UserHandle targetUserHandle) { + try { + if (queryIntentServicesAsUserMethod == null) { + synchronized (ServiceBinding.class) { + if (queryIntentServicesAsUserMethod == null) { + queryIntentServicesAsUserMethod = + PackageManager.class.getMethod( + "queryIntentServicesAsUser", Intent.class, int.class, UserHandle.class); + } + } + } + return (List) + queryIntentServicesAsUserMethod.invoke(packageManager, intent, flags, targetUserHandle); + } catch (ReflectiveOperationException e) { + throw new VerifyException(e); + } + } + + @AnyThread + @Override + public ServiceInfo resolve() throws StatusException { + checkState(sourceContext != null); + PackageManager packageManager = sourceContext.getPackageManager(); + int flags = 0; + if (Build.VERSION.SDK_INT >= 29) { + // Filter out non-'directBootAware' s when 'targetUserHandle' is locked. Here's why: + // Callers want 'bindIntent' to #resolve() to the same thing a follow-up call to #bind() will. + // But bindService() *always* ignores services that can't presently be created for lack of + // 'directBootAware'-ness. This flag explicitly tells resolveService() to act the same way. + flags |= PackageManager.MATCH_DIRECT_BOOT_AUTO; + } + ResolveInfo resolveInfo = + targetUserHandle != null + ? resolveServiceAsUser(packageManager, bindIntent, flags, targetUserHandle) + : packageManager.resolveService(bindIntent, flags); + if (resolveInfo == null) { + throw Status.UNIMPLEMENTED // Same status code as when bindService() returns false. + .withDescription("resolveService(" + bindIntent + " / " + targetUserHandle + ") was null") + .asException(); + } + return resolveInfo.serviceInfo; + } + @MainThread private void clearReferences() { sourceContext = null; diff --git a/binder/src/test/java/io/grpc/binder/RobolectricBinderSecurityTest.java b/binder/src/test/java/io/grpc/binder/RobolectricBinderSecurityTest.java index 16f06ad81c9..ffd1d89e69c 100644 --- a/binder/src/test/java/io/grpc/binder/RobolectricBinderSecurityTest.java +++ b/binder/src/test/java/io/grpc/binder/RobolectricBinderSecurityTest.java @@ -22,7 +22,13 @@ import static org.robolectric.Shadows.shadowOf; import android.app.Application; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.ServiceInfo; import androidx.test.core.app.ApplicationProvider; +import androidx.test.core.content.pm.ApplicationInfoBuilder; +import androidx.test.core.content.pm.PackageInfoBuilder; +import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; @@ -46,11 +52,13 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; import org.robolectric.annotation.LooperMode; import org.robolectric.annotation.LooperMode.Mode; -@RunWith(RobolectricTestRunner.class) +@RunWith(ParameterizedRobolectricTestRunner.class) @LooperMode(Mode.INSTRUMENTATION_TEST) public final class RobolectricBinderSecurityTest { @@ -62,10 +70,33 @@ public final class RobolectricBinderSecurityTest { private ManagedChannel channel; private Server server; + @Parameter public boolean preAuthServersParam; + + @Parameters(name = "preAuthServersParam={0}") + public static ImmutableList data() { + return ImmutableList.of(true, false); + } + @Before public void setUp() { + ApplicationInfo serverAppInfo = + ApplicationInfoBuilder.newBuilder().setPackageName(context.getPackageName()).build(); + serverAppInfo.uid = android.os.Process.myUid(); + PackageInfo serverPkgInfo = + PackageInfoBuilder.newBuilder() + .setPackageName(serverAppInfo.packageName) + .setApplicationInfo(serverAppInfo) + .build(); + shadowOf(context.getPackageManager()).installPackage(serverPkgInfo); + + ServiceInfo serviceInfo = new ServiceInfo(); + serviceInfo.name = "SomeService"; + serviceInfo.packageName = serverAppInfo.packageName; + serviceInfo.applicationInfo = serverAppInfo; + shadowOf(context.getPackageManager()).addOrUpdateService(serviceInfo); + AndroidComponentAddress listenAddress = - AndroidComponentAddress.forRemoteComponent(context.getPackageName(), "HostService"); + AndroidComponentAddress.forRemoteComponent(serviceInfo.packageName, serviceInfo.name); MethodDescriptor methodDesc = getMethodDescriptor(); ServerCallHandler callHandler = @@ -110,6 +141,7 @@ public ListenableFuture checkAuthorizationAsync(int uid) { checkNotNull(binderReceiver.get())); channel = BinderChannelBuilder.forAddress(listenAddress, context) + .preAuthorizeServers(preAuthServersParam) .build(); } diff --git a/binder/src/test/java/io/grpc/binder/internal/RobolectricBinderTransportTest.java b/binder/src/test/java/io/grpc/binder/internal/RobolectricBinderTransportTest.java index 7d336067842..7275c47d51c 100644 --- a/binder/src/test/java/io/grpc/binder/internal/RobolectricBinderTransportTest.java +++ b/binder/src/test/java/io/grpc/binder/internal/RobolectricBinderTransportTest.java @@ -16,13 +16,29 @@ package io.grpc.binder.internal; +import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; import static org.robolectric.Shadows.shadowOf; import android.app.Application; import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.ServiceInfo; import androidx.test.core.app.ApplicationProvider; +import androidx.test.core.content.pm.ApplicationInfoBuilder; +import androidx.test.core.content.pm.PackageInfoBuilder; +import com.google.common.collect.ImmutableList; +import io.grpc.Attributes; import io.grpc.ServerStreamTracer; +import io.grpc.Status; import io.grpc.binder.AndroidComponentAddress; +import io.grpc.binder.ApiConstants; +import io.grpc.binder.AsyncSecurityPolicy; +import io.grpc.binder.internal.SettableAsyncSecurityPolicy.AuthRequest; import io.grpc.internal.AbstractTransportTest; import io.grpc.internal.ClientTransportFactory.ClientTransportOptions; import io.grpc.internal.GrpcUtil; @@ -33,12 +49,20 @@ import java.util.List; import java.util.concurrent.Executor; import java.util.concurrent.ScheduledExecutorService; +import org.junit.Before; import org.junit.Ignore; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; import org.robolectric.annotation.LooperMode; import org.robolectric.annotation.LooperMode.Mode; +import org.robolectric.shadows.ShadowBinder; /** * All of the AbstractTransportTest cases applied to {@link BinderTransport} running in a @@ -52,7 +76,7 @@ * meaning test cases don't run on the main thread. This supports the AbstractTransportTest approach * where the test thread frequently blocks waiting for transport state changes to take effect. */ -@RunWith(RobolectricTestRunner.class) +@RunWith(ParameterizedRobolectricTestRunner.class) @LooperMode(Mode.INSTRUMENTATION_TEST) public final class RobolectricBinderTransportTest extends AbstractTransportTest { @@ -64,14 +88,57 @@ public final class RobolectricBinderTransportTest extends AbstractTransportTest private final ObjectPool serverExecutorPool = SharedResourcePool.forResource(GrpcUtil.SHARED_CHANNEL_EXECUTOR); + @Rule public MockitoRule mocks = MockitoJUnit.rule(); + + @Mock AsyncSecurityPolicy mockClientSecurityPolicy; + + ApplicationInfo serverAppInfo; + PackageInfo serverPkgInfo; + ServiceInfo serviceInfo; + private int nextServerAddress; + @Parameter public boolean preAuthServersParam; + + @Parameters(name = "preAuthServersParam={0}") + public static ImmutableList data() { + return ImmutableList.of(true, false); + } + + @Override + public void setUp() { + serverAppInfo = + ApplicationInfoBuilder.newBuilder().setPackageName("the.server.package").build(); + serverAppInfo.uid = android.os.Process.myUid(); + serverPkgInfo = + PackageInfoBuilder.newBuilder() + .setPackageName(serverAppInfo.packageName) + .setApplicationInfo(serverAppInfo) + .build(); + shadowOf(application.getPackageManager()).installPackage(serverPkgInfo); + + serviceInfo = new ServiceInfo(); + serviceInfo.name = "SomeService"; + serviceInfo.packageName = serverAppInfo.packageName; + serviceInfo.applicationInfo = serverAppInfo; + shadowOf(application.getPackageManager()).addOrUpdateService(serviceInfo); + + super.setUp(); + } + + @Before + public void requestRealisticBindServiceBehavior() { + shadowOf(application).setBindServiceCallsOnServiceConnectedDirectly(false); + shadowOf(application).setUnbindServiceCallsOnServiceDisconnected(false); + } + @Override protected InternalServer newServer(List streamTracerFactories) { - AndroidComponentAddress listenAddr = AndroidComponentAddress.forBindIntent( - new Intent() - .setClassName(application.getPackageName(), "HostService") - .setAction("io.grpc.action.BIND." + nextServerAddress++)); + AndroidComponentAddress listenAddr = + AndroidComponentAddress.forBindIntent( + new Intent() + .setClassName(serviceInfo.packageName, serviceInfo.name) + .setAction("io.grpc.action.BIND." + nextServerAddress++)); BinderServer binderServer = new BinderServer.Builder() @@ -81,6 +148,7 @@ protected InternalServer newServer(List streamTracer .setStreamTracerFactories(streamTracerFactories) .build(); + shadowOf(application.getPackageManager()).addServiceIfNotPresent(listenAddr.getComponent()); shadowOf(application) .setComponentNameAndServiceForBindServiceForIntent( listenAddr.asBindIntent(), listenAddr.getComponent(), binderServer.getHostBinder()); @@ -97,22 +165,30 @@ protected InternalServer newServer( return newServer(streamTracerFactories); } + BinderClientTransportFactory.Builder newClientTransportFactoryBuilder() { + return new BinderClientTransportFactory.Builder() + .setPreAuthorizeServers(preAuthServersParam) + .setSourceContext(application) + .setScheduledExecutorPool(executorServicePool) + .setOffloadExecutorPool(offloadExecutorPool); + } + + BinderClientTransportBuilder newClientTransportBuilder() { + return new BinderClientTransportBuilder() + .setFactory(newClientTransportFactoryBuilder().buildClientTransportFactory()) + .setServerAddress(server.getListenSocketAddress()); + } + @Override protected ManagedClientTransport newClientTransport(InternalServer server) { - BinderClientTransportFactory.Builder builder = - new BinderClientTransportFactory.Builder() - .setSourceContext(application) - .setScheduledExecutorPool(executorServicePool) - .setOffloadExecutorPool(offloadExecutorPool); - ClientTransportOptions options = new ClientTransportOptions(); options.setEagAttributes(eagAttrs()); options.setChannelLogger(transportLogger()); - return new BinderTransport.BinderClientTransport( - builder.buildClientTransportFactory(), - (AndroidComponentAddress) server.getListenSocketAddress(), - options); + return newClientTransportBuilder() + .setServerAddress(server.getListenSocketAddress()) + .setOptions(options) + .build(); } @Override @@ -120,6 +196,74 @@ protected String testAuthority(InternalServer server) { return ((AndroidComponentAddress) server.getListenSocketAddress()).getAuthority(); } + @Test + public void clientAuthorizesServerUidsInOrder() throws Exception { + // TODO(jdcormie): In real Android, Binder#getCallingUid is thread-local but Robolectric only + // lets us fake value this *globally*. So the ShadowBinder#setCallingUid() here unrealistically + // affects the server's view of the client's uid too. For now this doesn't matter because this + // test never exercises server SecurityPolicy. + ShadowBinder.setCallingUid(11111); // UID of the server *process*. + + serverPkgInfo.applicationInfo.uid = 22222; // UID of the server *app*, which can be different. + shadowOf(application.getPackageManager()).installPackage(serverPkgInfo); + shadowOf(application.getPackageManager()).addOrUpdateService(serviceInfo); + server = newServer(ImmutableList.of()); + server.start(serverListener); + + SettableAsyncSecurityPolicy securityPolicy = new SettableAsyncSecurityPolicy(); + client = + newClientTransportBuilder() + .setFactory( + newClientTransportFactoryBuilder() + .setSecurityPolicy(securityPolicy) + .buildClientTransportFactory()) + .build(); + runIfNotNull(client.start(mockClientTransportListener)); + + if (preAuthServersParam) { + AuthRequest preAuthRequest = securityPolicy.takeNextAuthRequest(TIMEOUT_MS, MILLISECONDS); + assertThat(preAuthRequest.uid).isEqualTo(22222); + verify(mockClientTransportListener, never()).transportReady(); + preAuthRequest.setResult(Status.OK); + } + + AuthRequest authRequest = securityPolicy.takeNextAuthRequest(TIMEOUT_MS, MILLISECONDS); + assertThat(authRequest.uid).isEqualTo(11111); + verify(mockClientTransportListener, never()).transportReady(); + authRequest.setResult(Status.OK); + + verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportReady(); + } + + @Test + public void eagAttributeCanOverrideChannelPreAuthServerSetting() throws Exception { + server.start(serverListener); + SettableAsyncSecurityPolicy securityPolicy = new SettableAsyncSecurityPolicy(); + ClientTransportOptions options = new ClientTransportOptions(); + options.setEagAttributes( + Attributes.newBuilder().set(ApiConstants.PRE_AUTH_SERVER_OVERRIDE, true).build()); + client = + newClientTransportBuilder() + .setOptions(options) + .setFactory( + newClientTransportFactoryBuilder() + .setPreAuthorizeServers(preAuthServersParam) // To be overridden. + .setSecurityPolicy(securityPolicy) + .buildClientTransportFactory()) + .build(); + runIfNotNull(client.start(mockClientTransportListener)); + + AuthRequest preAuthRequest = securityPolicy.takeNextAuthRequest(TIMEOUT_MS, MILLISECONDS); + verify(mockClientTransportListener, never()).transportReady(); + preAuthRequest.setResult(Status.OK); + + AuthRequest authRequest = securityPolicy.takeNextAuthRequest(TIMEOUT_MS, MILLISECONDS); + verify(mockClientTransportListener, never()).transportReady(); + authRequest.setResult(Status.OK); + + verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportReady(); + } + @Test @Ignore("See BinderTransportTest#socketStats.") @Override diff --git a/binder/src/test/java/io/grpc/binder/internal/ServiceBindingTest.java b/binder/src/test/java/io/grpc/binder/internal/ServiceBindingTest.java index b0ad35e6806..bd51c522d15 100644 --- a/binder/src/test/java/io/grpc/binder/internal/ServiceBindingTest.java +++ b/binder/src/test/java/io/grpc/binder/internal/ServiceBindingTest.java @@ -19,6 +19,7 @@ import static android.content.Context.BIND_AUTO_CREATE; import static android.os.Looper.getMainLooper; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; import static org.robolectric.Shadows.shadowOf; @@ -27,6 +28,7 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.pm.ServiceInfo; import android.os.IBinder; import android.os.Parcel; import android.os.UserHandle; @@ -34,6 +36,7 @@ import androidx.test.core.app.ApplicationProvider; import io.grpc.Status; import io.grpc.Status.Code; +import io.grpc.StatusException; import io.grpc.binder.BinderChannelCredentials; import io.grpc.binder.internal.Bindable.Observer; import java.util.Arrays; @@ -59,6 +62,7 @@ public final class ServiceBindingTest { private Application appContext; private ComponentName serviceComponent; + private ServiceInfo serviceInfo = new ServiceInfo(); private ShadowApplication shadowApplication; private TestObserver observer; private ServiceBinding binding; @@ -67,13 +71,17 @@ public final class ServiceBindingTest { public void setUp() { appContext = ApplicationProvider.getApplicationContext(); serviceComponent = new ComponentName("DUMMY", "SERVICE"); + serviceInfo.packageName = serviceComponent.getPackageName(); + serviceInfo.name = serviceComponent.getClassName(); observer = new TestObserver(); shadowApplication = shadowOf(appContext); shadowApplication.setComponentNameAndServiceForBindService(serviceComponent, mockBinder); + shadowOf(appContext.getPackageManager()).addOrUpdateService(serviceInfo); // Don't call onServiceDisconnected() upon unbindService(), just like the real Android doesn't. shadowApplication.setUnbindServiceCallsOnServiceDisconnected(false); + shadowApplication.setBindServiceCallsOnServiceConnectedDirectly(false); binding = newBuilder().build(); shadowOf(getMainLooper()).idle(); @@ -276,6 +284,49 @@ public void testBindWithTargetUserHandle() throws Exception { assertThat(binding.isSourceContextCleared()).isFalse(); } + @Test + public void testResolve() throws Exception { + serviceInfo.processName = "x"; // ServiceInfo has no equals() so look for one distinctive field. + shadowOf(appContext.getPackageManager()).addOrUpdateService(serviceInfo); + ServiceInfo resolvedServiceInfo = binding.resolve(); + assertThat(resolvedServiceInfo.processName).isEqualTo(serviceInfo.processName); + } + + @Test + @Config(sdk = 33) + public void testResolveWithTargetUserHandle() throws Exception { + serviceInfo.processName = "x"; // ServiceInfo has no equals() so look for one distinctive field. + // Robolectric just ignores the user arg to resolveServiceAsUser() so this is all we can do. + shadowOf(appContext.getPackageManager()).addOrUpdateService(serviceInfo); + binding = newBuilder().setTargetUserHandle(generateUserHandle(/* userId= */ 0)).build(); + ServiceInfo resolvedServiceInfo = binding.resolve(); + assertThat(resolvedServiceInfo.processName).isEqualTo(serviceInfo.processName); + } + + @Test + public void testResolveNonExistentServiceThrows() throws Exception { + ComponentName doesNotExistService = new ComponentName("does.not.exist", "NoService"); + binding = newBuilder().setTargetComponent(doesNotExistService).build(); + StatusException statusException = assertThrows(StatusException.class, binding::resolve); + assertThat(statusException.getStatus().getCode()).isEqualTo(Code.UNIMPLEMENTED); + assertThat(statusException.getStatus().getDescription()).contains("does.not.exist"); + } + + @Test + @Config(sdk = 33) + public void testResolveNonExistentServiceWithTargetUserThrows() throws Exception { + ComponentName doesNotExistService = new ComponentName("does.not.exist", "NoService"); + binding = + newBuilder() + .setTargetUserHandle(generateUserHandle(/* userId= */ 12345)) + .setTargetComponent(doesNotExistService) + .build(); + StatusException statusException = assertThrows(StatusException.class, binding::resolve); + assertThat(statusException.getStatus().getCode()).isEqualTo(Code.UNIMPLEMENTED); + assertThat(statusException.getStatus().getDescription()).contains("does.not.exist"); + assertThat(statusException.getStatus().getDescription()).contains("12345"); + } + @Test @Config(sdk = 30) public void testBindWithDeviceAdmin() throws Exception { @@ -284,7 +335,7 @@ public void testBindWithDeviceAdmin() throws Exception { allowBindDeviceAdminForUser(appContext, adminComponent, /* userId= */ 0); binding = newBuilder() - .setTargetUserHandle(UserHandle.getUserHandleForUid(/* userId= */ 0)) + .setTargetUserHandle(UserHandle.getUserHandleForUid(/* uid= */ 0)) .setTargetUserHandle(generateUserHandle(/* userId= */ 0)) .setChannelCredentials(BinderChannelCredentials.forDevicePolicyAdmin(adminComponent)) .build(); diff --git a/binder/src/testFixtures/java/io/grpc/binder/internal/BinderClientTransportBuilder.java b/binder/src/testFixtures/java/io/grpc/binder/internal/BinderClientTransportBuilder.java new file mode 100644 index 00000000000..e98daeec3ed --- /dev/null +++ b/binder/src/testFixtures/java/io/grpc/binder/internal/BinderClientTransportBuilder.java @@ -0,0 +1,61 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.binder.internal; + +import static com.google.common.base.Preconditions.checkNotNull; + +import io.grpc.ChannelLogger; +import io.grpc.internal.ClientTransportFactory.ClientTransportOptions; +import io.grpc.internal.TestUtils.NoopChannelLogger; +import java.net.SocketAddress; + +/** + * Helps unit tests create {@link BinderTransport.BinderClientTransport} instances without having to + * mention irrelevant details (go/tott/719). + */ +public class BinderClientTransportBuilder { + private BinderClientTransportFactory factory; + private SocketAddress serverAddress; + private ChannelLogger channelLogger = new NoopChannelLogger(); + private io.grpc.internal.ClientTransportFactory.ClientTransportOptions options = + new ClientTransportOptions(); + + public BinderClientTransportBuilder setServerAddress(SocketAddress serverAddress) { + this.serverAddress = checkNotNull(serverAddress); + return this; + } + + public BinderClientTransportBuilder setChannelLogger(ChannelLogger channelLogger) { + this.channelLogger = checkNotNull(channelLogger); + return this; + } + + public BinderClientTransportBuilder setOptions(ClientTransportOptions options) { + this.options = checkNotNull(options); + return this; + } + + public BinderClientTransportBuilder setFactory(BinderClientTransportFactory factory) { + this.factory = checkNotNull(factory); + return this; + } + + public BinderTransport.BinderClientTransport build() { + return factory.newClientTransport( + checkNotNull(serverAddress), checkNotNull(options), checkNotNull(channelLogger)); + } +} diff --git a/core/src/testFixtures/java/io/grpc/internal/AbstractTransportTest.java b/core/src/testFixtures/java/io/grpc/internal/AbstractTransportTest.java index ea7f1723e64..c60174b2faf 100644 --- a/core/src/testFixtures/java/io/grpc/internal/AbstractTransportTest.java +++ b/core/src/testFixtures/java/io/grpc/internal/AbstractTransportTest.java @@ -92,7 +92,7 @@ public abstract class AbstractTransportTest { */ public static final int TEST_FLOW_CONTROL_WINDOW = 65 * 1024; - private static final int TIMEOUT_MS = 5000; + protected static final int TIMEOUT_MS = 5000; protected static final String GRPC_EXPERIMENTAL_SUPPORT_TRACING_MESSAGE_SIZES = "GRPC_EXPERIMENTAL_SUPPORT_TRACING_MESSAGE_SIZES"; @@ -2163,7 +2163,7 @@ static boolean waitForFuture(Future future, long timeout, TimeUnit unit) return true; } - private static void runIfNotNull(Runnable runnable) { + protected static void runIfNotNull(Runnable runnable) { if (runnable != null) { runnable.run(); }