Sanitize VPN label to prevent HTML injection
This commit will try to sanitize the content of VpnDialog. This
commit creates a function which will try to sanitize the VPN
label, if the sanitized VPN label is different from the original
one, which means the VPN label might contain HTML tag or the VPN
label violates the words restriction(may contain some wording
which will mislead the user). For this kind of case, show the
package name instead of the VPN label to prevent misleading the
user.
The malicious VPN app might be able to add a large number of line
breaks with HTML in order to hide the system-displayed text from
the user in the connection request dialog. Thus, sanitizing the
content of the dialog is needed.
Bug: 204554636
Test: atest VpnDialogsTests
Change-Id: I8eb890fd2e5797d8d6ab5b12f9c628bc9616081d
diff --git a/packages/VpnDialogs/Android.bp b/packages/VpnDialogs/Android.bp
index 05135b2..e4f80e2 100644
--- a/packages/VpnDialogs/Android.bp
+++ b/packages/VpnDialogs/Android.bp
@@ -23,10 +23,15 @@
default_applicable_licenses: ["frameworks_base_license"],
}
+android_library {
+ name: "VpnDialogsLib",
+ srcs: ["src/**/*.java"],
+}
+
android_app {
name: "VpnDialogs",
certificate: "platform",
privileged: true,
- srcs: ["src/**/*.java"],
+ static_libs: ["VpnDialogsLib"],
platform_apis: true,
}
diff --git a/packages/VpnDialogs/res/values/strings.xml b/packages/VpnDialogs/res/values/strings.xml
index f971a09..28e7272 100644
--- a/packages/VpnDialogs/res/values/strings.xml
+++ b/packages/VpnDialogs/res/values/strings.xml
@@ -100,4 +100,33 @@
without any consequences. [CHAR LIMIT=20] -->
<string name="dismiss">Dismiss</string>
+ <!-- Malicious VPN apps may provide very long labels or cunning HTML to trick the system dialogs
+ into displaying what they want. The system will attempt to sanitize the label, and if the
+ label is deemed dangerous, then this string is used instead. The first argument is the
+ first 30 characters of the label, and the second argument is the package name of the app.
+ Example : Normally a VPN app may be called "My VPN app" in which case the dialog will read
+ "My VPN app wants to set up a VPN connection...". If the label is very long, then, this
+ will be used to show "VerylongVPNlabel… (com.my.vpn.app) wants to set up a VPN
+ connection...". For this case, the code will refer to sanitized_vpn_label_with_ellipsis.
+ -->
+ <string name="sanitized_vpn_label_with_ellipsis">
+ <xliff:g id="sanitized_vpn_label_with_ellipsis" example="My VPN app">%1$s</xliff:g>… (
+ <xliff:g id="sanitized_vpn_label_with_ellipsis" example="com.my.vpn.app">%2$s</xliff:g>)
+ </string>
+
+ <!-- Malicious VPN apps may provide very long labels or cunning HTML to trick the system dialogs
+ into displaying what they want. The system will attempt to sanitize the label, and if the
+ label is deemed dangerous, then this string is used instead. The first argument is the
+ label, and the second argument is the package name of the app.
+ Example : Normally a VPN app may be called "My VPN app" in which case the dialog will read
+ "My VPN app wants to set up a VPN connection...". If the VPN label contains HTML tag but
+ the length is not very long, the dialog will show "VpnLabelWith<br>HtmlTag
+ (com.my.vpn.app) wants to set up a VPN connection...". For this case, the code will refer
+ to sanitized_vpn_label.
+ -->
+ <string name="sanitized_vpn_label">
+ <xliff:g id="sanitized_vpn_label" example="My VPN app">%1$s</xliff:g> (
+ <xliff:g id="sanitized_vpn_label" example="com.my.vpn.app">%2$s</xliff:g>)
+ </string>
+
</resources>
diff --git a/packages/VpnDialogs/src/com/android/vpndialogs/ConfirmDialog.java b/packages/VpnDialogs/src/com/android/vpndialogs/ConfirmDialog.java
index fb23678..a98d6d8 100644
--- a/packages/VpnDialogs/src/com/android/vpndialogs/ConfirmDialog.java
+++ b/packages/VpnDialogs/src/com/android/vpndialogs/ConfirmDialog.java
@@ -33,6 +33,7 @@
import android.widget.Button;
import android.widget.TextView;
+import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.app.AlertActivity;
import com.android.internal.net.VpnConfig;
@@ -40,12 +41,19 @@
implements DialogInterface.OnClickListener, ImageGetter {
private static final String TAG = "VpnConfirm";
+ // Usually the label represents the app name, 150 code points might be enough to display the app
+ // name, and 150 code points won't cover the warning message from VpnDialog.
+ @VisibleForTesting
+ static final int MAX_VPN_LABEL_LENGTH = 150;
+
@VpnManager.VpnType private final int mVpnType;
private String mPackage;
private VpnManager mVm;
+ private View mView;
+
public ConfirmDialog() {
this(VpnManager.TYPE_VPN_SERVICE);
}
@@ -54,6 +62,43 @@
mVpnType = vpnType;
}
+ /**
+ * This function will use the string resource to combine the VPN label and the package name.
+ *
+ * If the VPN label violates the length restriction, the first 30 code points of VPN label and
+ * the package name will be returned. Or return the VPN label and the package name directly if
+ * the VPN label doesn't violate the length restriction.
+ *
+ * The result will be something like,
+ * - ThisIsAVeryLongVpnAppNameWhich... (com.vpn.app)
+ * if the VPN label violates the length restriction.
+ * or
+ * - VpnLabelWith<br>HtmlTag (com.vpn.app)
+ * if the VPN label doesn't violate the length restriction.
+ *
+ */
+ private String getSimplifiedLabel(String vpnLabel, String packageName) {
+ if (vpnLabel.codePointCount(0, vpnLabel.length()) > 30) {
+ return getString(R.string.sanitized_vpn_label_with_ellipsis,
+ vpnLabel.substring(0, vpnLabel.offsetByCodePoints(0, 30)),
+ packageName);
+ }
+
+ return getString(R.string.sanitized_vpn_label, vpnLabel, packageName);
+ }
+
+ @VisibleForTesting
+ protected String getSanitizedVpnLabel(String vpnLabel, String packageName) {
+ final String sanitizedVpnLabel = Html.escapeHtml(vpnLabel);
+ final boolean exceedMaxVpnLabelLength = sanitizedVpnLabel.codePointCount(0,
+ sanitizedVpnLabel.length()) > MAX_VPN_LABEL_LENGTH;
+ if (exceedMaxVpnLabelLength || !vpnLabel.equals(sanitizedVpnLabel)) {
+ return getSimplifiedLabel(sanitizedVpnLabel, packageName);
+ }
+
+ return sanitizedVpnLabel;
+ }
+
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -75,15 +120,16 @@
finish();
return;
}
- View view = View.inflate(this, R.layout.confirm, null);
- ((TextView) view.findViewById(R.id.warning)).setText(
- Html.fromHtml(getString(R.string.warning, getVpnLabel()),
- this, null /* tagHandler */));
+ mView = View.inflate(this, R.layout.confirm, null);
+ ((TextView) mView.findViewById(R.id.warning)).setText(
+ Html.fromHtml(getString(R.string.warning, getSanitizedVpnLabel(
+ getVpnLabel().toString(), mPackage)),
+ this /* imageGetter */, null /* tagHandler */));
mAlertParams.mTitle = getText(R.string.prompt);
mAlertParams.mPositiveButtonText = getText(android.R.string.ok);
mAlertParams.mPositiveButtonListener = this;
mAlertParams.mNegativeButtonText = getText(android.R.string.cancel);
- mAlertParams.mView = view;
+ mAlertParams.mView = mView;
setupAlert();
getWindow().setCloseOnTouchOutside(false);
@@ -92,6 +138,11 @@
button.setFilterTouchesWhenObscured(true);
}
+ @VisibleForTesting
+ public CharSequence getWarningText() {
+ return ((TextView) mView.findViewById(R.id.warning)).getText();
+ }
+
private CharSequence getVpnLabel() {
try {
return VpnConfig.getVpnLabel(this, mPackage);
diff --git a/packages/VpnDialogs/tests/Android.bp b/packages/VpnDialogs/tests/Android.bp
new file mode 100644
index 0000000..68639bd
--- /dev/null
+++ b/packages/VpnDialogs/tests/Android.bp
@@ -0,0 +1,36 @@
+// Copyright 2022, The Android Open Source Project
+//
+// 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 {
+ default_applicable_licenses: ["frameworks_base_license"],
+}
+
+android_test {
+ name: "VpnDialogsTests",
+ // Use platform certificate because the test will invoke a hidden API.
+ // (e.g. VpnManager#prepareVpn()).
+ certificate: "platform",
+ libs: [
+ "android.test.runner",
+ "android.test.base",
+ ],
+ static_libs: [
+ "androidx.test.core",
+ "androidx.test.rules",
+ "androidx.test.ext.junit",
+ "mockito-target-minus-junit4",
+ "VpnDialogsLib",
+ ],
+ srcs: ["src/**/*.java"],
+}
diff --git a/packages/VpnDialogs/tests/AndroidManifest.xml b/packages/VpnDialogs/tests/AndroidManifest.xml
new file mode 100644
index 0000000..f26c1fe
--- /dev/null
+++ b/packages/VpnDialogs/tests/AndroidManifest.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * 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.
+ */
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ package="com.android.vpndialogs.tests">
+
+ <application android:debuggable="true">
+ <uses-library android:name="android.test.runner" />
+ <activity android:name="com.android.vpndialogs.VpnDialogTest$InstrumentedConfirmDialog"/>
+ </application>
+ <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+ android:targetPackage="com.android.vpndialogs.tests"
+ android:label="Vpn dialog tests">
+ </instrumentation>
+</manifest>
diff --git a/packages/VpnDialogs/tests/src/com/android/vpndialogs/VpnDialogTest.java b/packages/VpnDialogs/tests/src/com/android/vpndialogs/VpnDialogTest.java
new file mode 100644
index 0000000..7cfa466
--- /dev/null
+++ b/packages/VpnDialogs/tests/src/com/android/vpndialogs/VpnDialogTest.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * 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 com.android.vpndialogs;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doReturn;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.net.VpnManager;
+
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@RunWith(AndroidJUnit4.class)
+public class VpnDialogTest {
+ private ActivityScenario<ConfirmDialog> mActivityScenario;
+
+ @SuppressWarnings("StaticMockMember")
+ @Mock
+ private static PackageManager sPm;
+
+ @SuppressWarnings("StaticMockMember")
+ @Mock
+ private static VpnManager sVm;
+
+ @Mock
+ private ApplicationInfo mAi;
+
+ private static final String VPN_APP_NAME = "VpnApp";
+ private static final String VPN_APP_PACKAGE_NAME = "com.android.vpndialogs.VpnDialogTest";
+ private static final String VPN_LABEL_CONTAINS_HTML_TAG =
+ "<b><a href=\"https://www.malicious.vpn.app.com\">Google Play</a>";
+ private static final String VPN_LABEL_CONTAINS_HTML_TAG_AND_VIOLATE_LENGTH_RESTRICTION =
+ "<b><a href=\"https://www.malicious.vpn.app.com\">Google Play</a></b>"
+ + " Wants to connect the network. <br></br><br></br><br></br><br></br><br></br>"
+ + " <br></br><br></br><br></br><br></br><br></br><br></br><br></br><br></br> Deny it?";
+ private static final String VPN_LABEL_VIOLATES_LENGTH_RESTRICTION = "This is a VPN label"
+ + " which violates the length restriction. The length restriction here are 150 code"
+ + " points. So the VPN label should be sanitized, and shows the package name to the"
+ + " user.";
+
+ public static class InstrumentedConfirmDialog extends ConfirmDialog {
+ @Override
+ public PackageManager getPackageManager() {
+ return sPm;
+ }
+
+ @Override
+ public @Nullable Object getSystemService(@ServiceName @NonNull String name) {
+ switch (name) {
+ case Context.VPN_MANAGEMENT_SERVICE:
+ return sVm;
+ default:
+ return super.getSystemService(name);
+ }
+ }
+
+ @Override
+ public String getCallingPackage() {
+ return VPN_APP_PACKAGE_NAME;
+ }
+ }
+
+ private void launchActivity() {
+ final Context context = getInstrumentation().getContext();
+ mActivityScenario = ActivityScenario.launch(
+ new Intent(context, InstrumentedConfirmDialog.class));
+ }
+
+ @Test
+ public void testGetSanitizedVpnLabel_withNormalCase() throws Exception {
+ // Test the normal case that the VPN label showed in the VpnDialog is the app name.
+ doReturn(VPN_APP_NAME).when(mAi).loadLabel(sPm);
+ launchActivity();
+ mActivityScenario.onActivity(activity -> {
+ assertTrue(activity.getWarningText().toString().contains(VPN_APP_NAME));
+ });
+ }
+
+ private void verifySanitizedVpnLabel(String originalLabel) {
+ doReturn(originalLabel).when(mAi).loadLabel(sPm);
+ launchActivity();
+ mActivityScenario.onActivity(activity -> {
+ // The VPN label was sanitized because violating length restriction or having a html
+ // tag, so the warning message will contain the package name.
+ assertTrue(activity.getWarningText().toString().contains(activity.getCallingPackage()));
+ // Also, the length of sanitized VPN label shouldn't longer than MAX_VPN_LABEL_LENGTH
+ // and it shouldn't contain html tag.
+ final String sanitizedVpnLabel =
+ activity.getSanitizedVpnLabel(originalLabel, VPN_APP_PACKAGE_NAME);
+ assertTrue(sanitizedVpnLabel.codePointCount(0, sanitizedVpnLabel.length())
+ < ConfirmDialog.MAX_VPN_LABEL_LENGTH);
+ assertFalse(sanitizedVpnLabel.contains("<b>"));
+ });
+ }
+
+ @Test
+ public void testGetSanitizedVpnLabel_withHtmlTag() throws Exception {
+ // Test the case that the VPN label was sanitized because there is a html tag.
+ verifySanitizedVpnLabel(VPN_LABEL_CONTAINS_HTML_TAG);
+ }
+
+ @Test
+ public void testGetSanitizedVpnLabel_withHtmlTagAndViolateLengthRestriction() throws Exception {
+ // Test the case that the VPN label was sanitized because there is a html tag.
+ verifySanitizedVpnLabel(VPN_LABEL_CONTAINS_HTML_TAG_AND_VIOLATE_LENGTH_RESTRICTION);
+ }
+
+ @Test
+ public void testGetSanitizedVpnLabel_withLengthRestriction() throws Exception {
+ // Test the case that the VPN label was sanitized because hitting the length restriction.
+ verifySanitizedVpnLabel(VPN_LABEL_VIOLATES_LENGTH_RESTRICTION);
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ MockitoAnnotations.initMocks(this);
+ doReturn(false).when(sVm).prepareVpn(anyString(), anyString(), anyInt());
+ doReturn(null).when(sPm).queryIntentServices(any(), anyInt());
+ doReturn(mAi).when(sPm).getApplicationInfo(anyString(), anyInt());
+ }
+}