From 7d6dfd52b270dd26cbc26f4e6f93365af1aabd68 Mon Sep 17 00:00:00 2001 From: Patrick Rohr Date: Thu, 5 Dec 2024 04:06:53 +0000 Subject: [PATCH] CaptivePortal: implement setDelegateUid API. This API is designed to set the UID of the app that is allowed to perform network traffic for captive protal login, communication will bypass network access restrictions such as VPNs and private DNS settings when a portal page is launched by Android Custom Tabs and bind the upcoming traffic to that portal network. Bug: 373773542 Test: atest ConnectivityCoverageTests Change-Id: I9992671634b8e88f5a647455811a6157fb15a44b --- framework/Android.bp | 2 + framework/src/android/net/CaptivePortal.java | 70 ++++++++++++++++++ framework/src/android/net/ICaptivePortal.aidl | 4 + .../android/server/ConnectivityService.java | 74 ++++++++++++++++++- .../server/connectivity/NetworkAgentInfo.java | 58 +++++++++++++++ .../java/android/net/CaptivePortalTest.java | 5 ++ 6 files changed, 212 insertions(+), 1 deletion(-) diff --git a/framework/Android.bp b/framework/Android.bp index a93a5329a6..73516483b6 100644 --- a/framework/Android.bp +++ b/framework/Android.bp @@ -75,6 +75,7 @@ java_defaults { // the module builds against API (the parcelable declarations exist in framework.aidl) "frameworks/base/core/java", // For framework parcelables "frameworks/native/aidl/binder", // For PersistableBundle.aidl + "packages/modules/Connectivity/Tethering/common/TetheringLib/src", ], }, stub_only_libs: [ @@ -334,6 +335,7 @@ droidstubs { aidl: { include_dirs: [ "packages/modules/Connectivity/framework/aidl-export", + "packages/modules/Connectivity/Tethering/common/TetheringLib/src", "frameworks/native/aidl/binder", // For PersistableBundle.aidl ], }, diff --git a/framework/src/android/net/CaptivePortal.java b/framework/src/android/net/CaptivePortal.java index 4a7b601642..4c534f3c4b 100644 --- a/framework/src/android/net/CaptivePortal.java +++ b/framework/src/android/net/CaptivePortal.java @@ -18,10 +18,19 @@ package android.net; import android.annotation.NonNull; import android.annotation.RequiresPermission; import android.annotation.SystemApi; +import android.annotation.TargetApi; +import android.os.Binder; +import android.os.Build; import android.os.IBinder; +import android.os.OutcomeReceiver; import android.os.Parcel; import android.os.Parcelable; import android.os.RemoteException; +import android.os.ServiceSpecificException; +import android.system.OsConstants; + +import java.util.Objects; +import java.util.concurrent.Executor; /** * A class allowing apps handling the {@link ConnectivityManager#ACTION_CAPTIVE_PORTAL_SIGN_IN} @@ -69,6 +78,15 @@ public class CaptivePortal implements Parcelable { @SystemApi public static final int APP_REQUEST_REEVALUATION_REQUIRED = APP_REQUEST_BASE + 0; + /** + * Binder object used for tracking the lifetime of the process, so CS can perform cleanup if + * the CaptivePortal app dies. This binder is not parcelled as part of this object. It is + * created in the client process and sent to the server by setDelegateUid so that the server + * can use it to register a death recipient. + * + */ + private final Binder mLifetimeBinder = new Binder(); + private final IBinder mBinder; /** @hide */ @@ -167,4 +185,56 @@ public class CaptivePortal implements Parcelable { @SystemApi public void logEvent(int eventId, @NonNull String packageName) { } + + /** + * Sets the UID of the app that is allowed to perform network traffic for captive + * portal login. + * + * This app will be allowed to communicate directly on the captive + * portal by binding to the {@link android.net.Network} extra passed in the + * ACTION_CAPTIVE_PORTAL_SIGN_IN broadcast that contained this object. + * + * Communication will bypass network access restrictions such as VPNs and + * Private DNS settings, so the delegated UID must be trusted to ensure that only + * traffic intended for captive portal login binds to that network. + * + * By default, no UID is delegated. The delegation can be cleared by calling + * this method again with {@link android.os.Process.INVALID_UID}. Only one UID can + * be delegated at any given time. + * + * The operation is asynchronous. The uid is only guaranteed to have access when + * the provided OutcomeReceiver is called. + * + * @hide + */ + @RequiresPermission(NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK) + // OutcomeReceiver is not available on R, but the mainline version of this + // class is only available on S+. + @TargetApi(Build.VERSION_CODES.S) + public void setDelegateUid(int uid, @NonNull Executor executor, + @NonNull final OutcomeReceiver receiver) { + Objects.requireNonNull(executor); + Objects.requireNonNull(receiver); + try { + ICaptivePortal.Stub.asInterface(mBinder).setDelegateUid( + uid, + mLifetimeBinder, + new IIntResultListener.Stub() { + @Override + public void onResult(int resultCode) { + if (resultCode != 0) { + final String msg = "Fail to set the delegate UID " + uid + + ", error: " + OsConstants.errnoName(resultCode); + executor.execute(() -> { + receiver.onError(new ServiceSpecificException(resultCode, msg)); + }); + } else { + executor.execute(() -> receiver.onResult(null)); + } + } + }); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } } diff --git a/framework/src/android/net/ICaptivePortal.aidl b/framework/src/android/net/ICaptivePortal.aidl index e35f8d46af..5cbb4282bc 100644 --- a/framework/src/android/net/ICaptivePortal.aidl +++ b/framework/src/android/net/ICaptivePortal.aidl @@ -16,6 +16,9 @@ package android.net; +import android.net.IIntResultListener; +import android.os.IBinder; + /** * Interface to inform NetworkMonitor of decisions of app handling captive portal. * @hide @@ -23,4 +26,5 @@ package android.net; oneway interface ICaptivePortal { void appRequest(int request); void appResponse(int response); + void setDelegateUid(int uid, IBinder binder, IIntResultListener listener); } diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java index f3b97bc5c1..4c0d7b3bbc 100644 --- a/service/src/com/android/server/ConnectivityService.java +++ b/service/src/com/android/server/ConnectivityService.java @@ -121,6 +121,9 @@ import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_SELF_CER import static android.net.connectivity.ConnectivityCompatChanges.NETWORK_BLOCKED_WITHOUT_INTERNET_PERMISSION; import static android.os.Process.INVALID_UID; import static android.os.Process.VPN_UID; +import static android.system.OsConstants.ENOENT; +import static android.system.OsConstants.ENOTCONN; +import static android.system.OsConstants.EOPNOTSUPP; import static android.system.OsConstants.ETH_P_ALL; import static android.system.OsConstants.IPPROTO_TCP; import static android.system.OsConstants.IPPROTO_UDP; @@ -195,6 +198,7 @@ import android.net.ICaptivePortal; import android.net.IConnectivityDiagnosticsCallback; import android.net.IConnectivityManager; import android.net.IDnsResolver; +import android.net.IIntResultListener; import android.net.INetd; import android.net.INetworkActivityListener; import android.net.INetworkAgent; @@ -6340,8 +6344,20 @@ public class ConnectivityService extends IConnectivityManager.Stub } } - private class CaptivePortalImpl extends ICaptivePortal.Stub { + public class CaptivePortalImpl extends ICaptivePortal.Stub implements IBinder.DeathRecipient { private final Network mNetwork; + // Binder object to track the lifetime of the setDelegateUid caller for cleanup purposes. + // + // Note that in theory it can happen that there are multiple callers for a given + // object. For example, the app that receives the CaptivePortal object from the Intent + // fired by startCaptivePortalAppInternal could send the object to another process, or + // clone it. Only the first of these objects that calls setDelegateUid will properly + // register a death recipient. Calls from the other objects will work, but only the + // first object's death will cause the death recipient to fire. + // TODO: track all callers by callerBinder instead of CaptivePortalImpl, store callerBinder + // in a Set. When the death recipient fires, we can remove the callingBinder from the set, + // and when the set is empty, we can clear the delegated UID. + private IBinder mDelegateUidCaller; private CaptivePortalImpl(Network network) { mNetwork = network; @@ -6381,6 +6397,55 @@ public class ConnectivityService extends IConnectivityManager.Stub } } + private int handleSetDelegateUid(int uid, @NonNull final IBinder callerBinder) { + if (mDelegateUidCaller == null) { + mDelegateUidCaller = callerBinder; + try { + // While technically unnecessary, it is safe to register a DeathRecipient for + // a cleanup operation (where uid = INVALID_UID). + mDelegateUidCaller.linkToDeath(this, 0); + } catch (RemoteException e) { + // remote has died, return early. + return ENOTCONN; + } + } + + final NetworkAgentInfo nai = getNetworkAgentInfoForNetwork(mNetwork); + if (nai == null) return ENOENT; // network does not exist anymore. + if (nai.isDestroyed()) return ENOENT; // network has already been destroyed. + + // TODO: consider allowing the uid to bypass VPN on all networks before V. + if (!mDeps.isAtLeastV()) return EOPNOTSUPP; + + // Check whether there has already been a delegate UID configured, if so, perform + // cleanup and disallow bypassing VPN for that UID if no other caller is delegating + // this UID. + // TODO: consider using exceptions instead of errnos. + final int errno = nai.removeCaptivePortalDelegateUid(this); + if (errno != 0) return errno; + + // If uid == INVALID_UID, we are done. + if (uid == INVALID_UID) return 0; + return nai.setCaptivePortalDelegateUid(this, uid); + } + + @Override + public void setDelegateUid(int uid, @NonNull final IBinder callerBinder, + @NonNull final IIntResultListener listener) { + Objects.requireNonNull(callerBinder); + Objects.requireNonNull(listener); + enforceAnyPermissionOf(mContext, NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK); + + mHandler.post(() -> { + final int errno = handleSetDelegateUid(uid, callerBinder); + try { + listener.onResult(errno); + } catch (RemoteException e) { + // remote has died, nothing to do. + } + }); + } + @Nullable private NetworkMonitorManager getNetworkMonitorManager(final Network network) { // getNetworkAgentInfoForNetwork is thread-safe @@ -6390,6 +6455,13 @@ public class ConnectivityService extends IConnectivityManager.Stub // nai.networkMonitor() is thread-safe return nai.networkMonitor(); } + + @Override + public void binderDied() { + // Cleanup invalid UID and restore the VPN bypass rule. Because mDelegateUidCaller is + // never reset, it cannot be null in this context. + mHandler.post(() -> handleSetDelegateUid(INVALID_UID, mDelegateUidCaller)); + } } public boolean avoidBadWifi() { diff --git a/service/src/com/android/server/connectivity/NetworkAgentInfo.java b/service/src/com/android/server/connectivity/NetworkAgentInfo.java index 94b655f5e3..2b00386420 100644 --- a/service/src/com/android/server/connectivity/NetworkAgentInfo.java +++ b/service/src/com/android/server/connectivity/NetworkAgentInfo.java @@ -25,6 +25,9 @@ import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET; import static android.net.NetworkCapabilities.TRANSPORT_TEST; import static android.net.NetworkCapabilities.TRANSPORT_WIFI; import static android.net.NetworkCapabilities.transportNamesOf; +import static android.system.OsConstants.EIO; +import static android.system.OsConstants.EEXIST; +import static android.system.OsConstants.ENOENT; import android.annotation.NonNull; import android.annotation.Nullable; @@ -57,9 +60,11 @@ import android.net.TcpKeepalivePacketData; import android.os.Handler; import android.os.IBinder; import android.os.RemoteException; +import android.os.ServiceSpecificException; import android.os.SystemClock; import android.telephony.data.EpsBearerQosSessionAttributes; import android.telephony.data.NrQosSessionAttributes; +import android.util.ArrayMap; import android.util.ArraySet; import android.util.Log; import android.util.Pair; @@ -70,6 +75,7 @@ import com.android.internal.util.IndentingPrintWriter; import com.android.internal.util.WakeupMessage; import com.android.net.module.util.HandlerUtils; import com.android.server.ConnectivityService; +import com.android.server.ConnectivityService.CaptivePortalImpl; import java.io.PrintWriter; import java.net.Inet4Address; @@ -574,6 +580,10 @@ public class NetworkAgentInfo implements NetworkRanker.Scoreable { // For fast lookups. Indexes into mInactivityTimers by request ID. private final SparseArray mInactivityTimerForRequest = new SparseArray<>(); + // Map of delegated UIDs used to bypass VPN and its captive portal app caller. + private final ArrayMap mCaptivePortalDelegateUids = + new ArrayMap<>(); + // Inactivity expiry timer. Armed whenever mInactivityTimers is non-empty, regardless of // whether the network is inactive or not. Always set to the expiry of the mInactivityTimers // that expires last. When the timer fires, all inactivity state is cleared, and if the network @@ -626,6 +636,7 @@ public class NetworkAgentInfo implements NetworkRanker.Scoreable { private final Context mContext; private final Handler mHandler; private final QosCallbackTracker mQosCallbackTracker; + private final INetd mNetd; private final long mCreationTime; @@ -655,6 +666,7 @@ public class NetworkAgentInfo implements NetworkRanker.Scoreable { mConnServiceDeps = deps; setScore(score); // uses members connService, networkCapabilities and networkAgentConfig clatd = new Nat464Xlat(this, netd, dnsResolver, deps); + mNetd = netd; mContext = context; mHandler = handler; this.factorySerialNumber = factorySerialNumber; @@ -1549,6 +1561,52 @@ public class NetworkAgentInfo implements NetworkRanker.Scoreable { } } + private int allowBypassVpnOnNetwork(boolean allow, int uid, int netId) { + try { + mNetd.networkAllowBypassVpnOnNetwork(allow, uid, netId); + return 0; + } catch (RemoteException e) { + // Netd has crashed, and this process is about to crash as well. + return EIO; + } catch (ServiceSpecificException e) { + return e.errorCode; + } + } + + /** + * Set the delegate UID of the app that is allowed to perform network traffic for captive + * portal login, and configure the netd bypass rule with this delegated UID. + * + * @param caller the captive portal app to that delegated UID + * @param uid the delegated UID of the captive portal app. + * @return Return 0 if set the UID and VPN bypass rule successfully or bypass rule corresponding + * to this UID already exists otherwise return errno. + */ + public int setCaptivePortalDelegateUid(@NonNull final CaptivePortalImpl caller, int uid) { + final int errorCode = allowBypassVpnOnNetwork(true /* allow */, uid, network.netId); + if (errorCode == 0 || errorCode == EEXIST) { + mCaptivePortalDelegateUids.put(caller, uid); + } + return errorCode == EEXIST ? 0 : errorCode; + } + + /** + * Remove the delegate UID of the app that is allowed to perform network traffic for captive + * portal login, and remove the netd bypass rule if no other caller is delegating this UID. + * + * @param caller the captive portal app to that delegated UID. + * @return Return 0 if remove the UID and VPN bypass rule successfully or bypass rule + * corresponding to this UID doesn't exist otherwise return errno. + */ + public int removeCaptivePortalDelegateUid(@NonNull final CaptivePortalImpl caller) { + final Integer maybeDelegateUid = mCaptivePortalDelegateUids.remove(caller); + if (maybeDelegateUid == null) return 0; + if (mCaptivePortalDelegateUids.values().contains(maybeDelegateUid)) return 0; + final int errorCode = + allowBypassVpnOnNetwork(false /* allow */, maybeDelegateUid, network.netId); + return errorCode == ENOENT ? 0 : errorCode; + } + private static boolean areAllowedUidsAcceptableFromNetworkAgent( @NonNull final NetworkCapabilities nc, final boolean hasAutomotiveFeature, @NonNull final ConnectivityService.Dependencies deps, diff --git a/tests/common/java/android/net/CaptivePortalTest.java b/tests/common/java/android/net/CaptivePortalTest.java index 15d3398d43..6655827c61 100644 --- a/tests/common/java/android/net/CaptivePortalTest.java +++ b/tests/common/java/android/net/CaptivePortalTest.java @@ -19,6 +19,7 @@ package android.net; import static org.junit.Assert.assertEquals; import android.os.Build; +import android.os.IBinder; import android.os.RemoteException; import androidx.test.filters.SmallTest; @@ -55,6 +56,10 @@ public class CaptivePortalTest { mCode = request; } + @Override + public void setDelegateUid(int uid, IBinder binder, IIntResultListener listener) { + } + // This is only @Override on R- public void logEvent(int eventId, String packageName) throws RemoteException { mCode = eventId;