Merge "[WebKit] Add handler to load files from app internal storage" into androidx-master-dev
diff --git a/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderIntegrationTest.java b/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderIntegrationTest.java
index 70a2b11..06a7a6f 100644
--- a/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderIntegrationTest.java
+++ b/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderIntegrationTest.java
@@ -17,8 +17,10 @@
package androidx.webkit;
import static androidx.webkit.WebViewAssetLoader.AssetsPathHandler;
+import static androidx.webkit.WebViewAssetLoader.InternalStoragePathHandler;
import static androidx.webkit.WebViewAssetLoader.ResourcesPathHandler;
+import android.content.Context;;
import android.net.Uri;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
@@ -27,6 +29,7 @@
import androidx.test.filters.MediumTest;
import androidx.test.rule.ActivityTestRule;
import androidx.test.runner.AndroidJUnit4;
+import androidx.webkit.internal.AssetHelper;
import org.junit.After;
import org.junit.Assert;
@@ -35,6 +38,8 @@
import org.junit.Test;
import org.junit.runner.RunWith;
+import java.io.File;
+
@RunWith(AndroidJUnit4.class)
public class WebViewAssetLoaderIntegrationTest {
private static final String TAG = "WebViewAssetLoaderIntegrationTest";
@@ -43,6 +48,12 @@
public final ActivityTestRule<WebViewTestActivity> mActivityRule =
new ActivityTestRule<>(WebViewTestActivity.class);
+ private static final String TEST_INTERNAL_STORAGE_DIR = "app_public/";
+ private static final String TEST_INTERNAL_STORAGE_FILE =
+ TEST_INTERNAL_STORAGE_DIR + "html/test_with_title.html";
+ private static final String TEST_HTML_CONTENT =
+ "<head><title>WebViewAssetLoaderTest</title></head>";
+
private WebViewOnUiThread mOnUiThread;
private static class AssetLoadingWebViewClient extends WebViewOnUiThread.WaitForLoadedClient {
@@ -76,6 +87,10 @@
if (mOnUiThread != null) {
mOnUiThread.cleanUp();
}
+
+ Context context = mActivityRule.getActivity();
+ WebkitUtils.recursivelyDeleteFile(
+ new File(AssetHelper.getDataDir(context), TEST_INTERNAL_STORAGE_DIR));
}
@Test
@@ -125,4 +140,34 @@
Assert.assertEquals("WebViewAssetLoaderTest", mOnUiThread.getTitle());
}
+
+ @Test
+ @MediumTest
+ public void testAppInternalStorageHosting() throws Exception {
+ final WebViewTestActivity activity = mActivityRule.getActivity();
+
+ File dataDir = AssetHelper.getDataDir(activity);
+ WebkitUtils.writeToFile(new File(dataDir, TEST_INTERNAL_STORAGE_FILE), TEST_HTML_CONTENT);
+
+ InternalStoragePathHandler handler = new InternalStoragePathHandler(activity,
+ new File(dataDir, TEST_INTERNAL_STORAGE_DIR));
+ WebViewAssetLoader assetLoader = new WebViewAssetLoader.Builder()
+ .addPathHandler("/data/public/", handler)
+ .build();
+
+ mOnUiThread.setWebViewClient(new AssetLoadingWebViewClient(mOnUiThread, assetLoader));
+
+ String url = new Uri.Builder()
+ .scheme("https")
+ .authority(WebViewAssetLoader.DEFAULT_DOMAIN)
+ .appendPath("data")
+ .appendPath("public")
+ .appendPath("html")
+ .appendPath("test_with_title.html")
+ .build()
+ .toString();
+ mOnUiThread.loadUrlAndWaitForCompletion(url);
+
+ Assert.assertEquals("WebViewAssetLoaderTest", mOnUiThread.getTitle());
+ }
}
diff --git a/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderTest.java b/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderTest.java
index 029425a..db29999 100644
--- a/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderTest.java
+++ b/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderTest.java
@@ -16,11 +16,13 @@
package androidx.webkit;
+import android.content.Context;
import android.content.ContextWrapper;
import android.net.Uri;
import android.webkit.WebResourceResponse;
import static androidx.webkit.WebViewAssetLoader.AssetsPathHandler;
+import static androidx.webkit.WebViewAssetLoader.InternalStoragePathHandler;
import static androidx.webkit.WebViewAssetLoader.PathHandler;
import static androidx.webkit.WebViewAssetLoader.ResourcesPathHandler;
@@ -37,6 +39,7 @@
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
+import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
@@ -215,6 +218,84 @@
assertResponse(response, testHtmlContents);
}
+ @SmallTest
+ @Test(expected = IllegalArgumentException.class)
+ public void testCreateInternalStorageHandler_entireDataDir() throws Throwable {
+ Context context = ApplicationProvider.getApplicationContext();
+ File testDir = AssetHelper.getDataDir(context);
+ PathHandler handler =
+ new InternalStoragePathHandler(context, testDir);
+ }
+
+ @SmallTest
+ @Test(expected = IllegalArgumentException.class)
+ public void testCreateInternalStorageHandler_entireCacheDir() throws Throwable {
+ Context context = ApplicationProvider.getApplicationContext();
+ File testDir = context.getCacheDir();
+ PathHandler handler =
+ new InternalStoragePathHandler(context, testDir);
+ }
+
+ @SmallTest
+ @Test(expected = IllegalArgumentException.class)
+ public void testCreateInternalStorageHandler_databasesDir() throws Throwable {
+ Context context = ApplicationProvider.getApplicationContext();
+ File testDir = new File(AssetHelper.getDataDir(context), "databases/");
+ PathHandler handler =
+ new InternalStoragePathHandler(context, testDir);
+ }
+
+ @SmallTest
+ @Test(expected = IllegalArgumentException.class)
+ public void testCreateInternalStorageHandler_libDir() throws Throwable {
+ Context context = ApplicationProvider.getApplicationContext();
+ File testDir = new File(AssetHelper.getDataDir(context), "lib/");
+ PathHandler handler =
+ new InternalStoragePathHandler(context, testDir);
+ }
+
+ @SmallTest
+ @Test(expected = IllegalArgumentException.class)
+ public void testCreateInternalStorageHandler_webViewDir() throws Throwable {
+ Context context = ApplicationProvider.getApplicationContext();
+ File testDir = new File(AssetHelper.getDataDir(context), "app_webview");
+ PathHandler handler =
+ new InternalStoragePathHandler(context, testDir);
+ }
+
+ @SmallTest
+ @Test(expected = IllegalArgumentException.class)
+ public void testCreateInternalStorageHandler_sharedPrefsDir() throws Throwable {
+ Context context = ApplicationProvider.getApplicationContext();
+ File testDir = new File(AssetHelper.getDataDir(context), "/shared_prefs/");
+ PathHandler handler =
+ new InternalStoragePathHandler(context, testDir);
+ }
+
+ @Test
+ @SmallTest
+ public void testHostInternalStorageHandler_invalidAccess() throws Throwable {
+ Context context = ApplicationProvider.getApplicationContext();
+ File testDir = new File(AssetHelper.getDataDir(context), "/public/");
+ PathHandler handler =
+ new InternalStoragePathHandler(context, testDir);
+ WebViewAssetLoader assetLoader = new WebViewAssetLoader.Builder()
+ .addPathHandler("/public-data/", handler)
+ .build();
+
+ WebResourceResponse response = assetLoader.shouldInterceptRequest(
+ Uri.parse("https://appassets.androidplatform.net/public-data/../test.html"));
+ Assert.assertNull(
+ "should be null since it tries to access a file outside the mounted directory",
+ response.getData());
+
+ response = assetLoader.shouldInterceptRequest(
+ Uri.parse("https://appassets.androidplatform.net/public-data/html/test.html"));
+ Assert.assertNull(
+ "should be null as it accesses a non-existent file under the mounted directory",
+ response.getData());
+ }
+
@Test
@SmallTest
public void testMultiplePathHandlers() throws Throwable {
diff --git a/webkit/src/androidTest/java/androidx/webkit/WebkitUtils.java b/webkit/src/androidTest/java/androidx/webkit/WebkitUtils.java
index 28d0633..a7994fc 100644
--- a/webkit/src/androidTest/java/androidx/webkit/WebkitUtils.java
+++ b/webkit/src/androidTest/java/androidx/webkit/WebkitUtils.java
@@ -25,6 +25,9 @@
import org.junit.Assume;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
@@ -211,6 +214,42 @@
}
}
+ /**
+ * Write a string to a file, and create the whole parent directories if they don't exist.
+ */
+ public static void writeToFile(File file, String content)
+ throws IOException {
+ file.getParentFile().mkdirs();
+ FileOutputStream fos = new FileOutputStream(file);
+ try {
+ fos.write(content.getBytes("utf-8"));
+ } finally {
+ fos.close();
+ }
+ }
+
+ /**
+ * Delete the given File and (if it's a directory) everything within it.
+ * @param currentFile The file or directory to delete. Does not need to exist.
+ * @return Whether currentFile does not exist afterwards.
+ */
+ public static boolean recursivelyDeleteFile(File currentFile) {
+ if (!currentFile.exists()) {
+ return true;
+ }
+ if (currentFile.isDirectory()) {
+ File[] files = currentFile.listFiles();
+ if (files != null) {
+ for (File file : files) {
+ recursivelyDeleteFile(file);
+ }
+ }
+ }
+
+ boolean ret = currentFile.delete();
+ return ret;
+ }
+
// Do not instantiate this class.
private WebkitUtils() {}
}
diff --git a/webkit/src/androidTest/java/androidx/webkit/internal/AssetHelperTest.java b/webkit/src/androidTest/java/androidx/webkit/internal/AssetHelperTest.java
index 9ecf3f0..5696e8a9 100644
--- a/webkit/src/androidTest/java/androidx/webkit/internal/AssetHelperTest.java
+++ b/webkit/src/androidTest/java/androidx/webkit/internal/AssetHelperTest.java
@@ -21,15 +21,19 @@
import android.util.Log;
import androidx.test.InstrumentationRegistry;
+import androidx.test.filters.MediumTest;
import androidx.test.filters.SmallTest;
import androidx.test.runner.AndroidJUnit4;
+import androidx.webkit.WebkitUtils;
+import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.io.ByteArrayOutputStream;
+import java.io.File;
import java.io.IOException;
import java.io.InputStream;
@@ -39,11 +43,19 @@
private static final String TEST_STRING = "Just a test";
private AssetHelper mAssetHelper;
+ private File mInternalStorageTestDir;
@Before
public void setup() {
Context context = InstrumentationRegistry.getContext();
mAssetHelper = new AssetHelper(context);
+ mInternalStorageTestDir = new File(context.getFilesDir(), "test_dir");
+ mInternalStorageTestDir.mkdirs();
+ }
+
+ @After
+ public void tearDown() {
+ WebkitUtils.recursivelyDeleteFile(mInternalStorageTestDir);
}
@Test
@@ -114,6 +126,52 @@
mAssetHelper.openAsset(Uri.parse("/android_asset/test.txt")));
}
+ @Test
+ @MediumTest
+ public void testOpenFileFromInternalStorage() throws Throwable {
+ File testFile = new File(mInternalStorageTestDir, "some_file.txt");
+ WebkitUtils.writeToFile(testFile, TEST_STRING);
+
+ InputStream stream = AssetHelper.openFile(testFile);
+ Assert.assertNotNull("Should be able to open \"" + testFile + "\" from internal storage",
+ stream);
+ Assert.assertEquals(readAsString(stream), TEST_STRING);
+ }
+
+ @Test
+ @MediumTest
+ public void testOpenNonExistingFileInInternalStorage() throws Throwable {
+ File testFile = new File(mInternalStorageTestDir, "some/path/to/non_exist_file.txt");
+ InputStream stream = AssetHelper.openFile(testFile);
+ Assert.assertNull("Should not be able to open a non existing file from internal storage",
+ stream);
+ }
+
+ @Test
+ @SmallTest
+ public void testIsCanonicalChildOf() throws Throwable {
+ // Two files are used for testing :
+ // "/some/path/to/file_1.txt" and "/some/path/file_2.txt"
+
+ File parent = new File(mInternalStorageTestDir, "/some/path/");
+ File child = new File(parent, "/to/./file_1.txt");
+ boolean res = AssetHelper.isCanonicalChildOf(parent, child);
+ Assert.assertTrue(
+ "/to/./\"file_1.txt\" is in a subdirectory of \"/some/path/\"", res);
+
+ parent = new File(mInternalStorageTestDir, "/some/path/");
+ child = new File(parent, "/to/../file_2.txt");
+ res = AssetHelper.isCanonicalChildOf(parent, child);
+ Assert.assertTrue(
+ "/to/../\"file_2.txt\" is in a subdirectory of \"/some/path/\"", res);
+
+ parent = new File(mInternalStorageTestDir, "/some/path/to");
+ child = new File(parent, "/../file_2.txt");
+ res = AssetHelper.isCanonicalChildOf(parent, child);
+ Assert.assertFalse(
+ "/../\"file_2.txt\" is not in a subdirectory of \"/some/path/to/\"", res);
+ }
+
private static String readAsString(InputStream is) {
ByteArrayOutputStream os = new ByteArrayOutputStream();
byte[] buffer = new byte[512];
diff --git a/webkit/src/main/java/androidx/webkit/WebViewAssetLoader.java b/webkit/src/main/java/androidx/webkit/WebViewAssetLoader.java
index fa064fc..d74bde7 100644
--- a/webkit/src/main/java/androidx/webkit/WebViewAssetLoader.java
+++ b/webkit/src/main/java/androidx/webkit/WebViewAssetLoader.java
@@ -18,24 +18,29 @@
import android.content.Context;
import android.net.Uri;
+import android.util.Log;
import android.webkit.WebResourceResponse;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import androidx.webkit.internal.AssetHelper;
+import java.io.File;
+import java.io.IOException;
import java.io.InputStream;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.List;
/**
- * Helper class to load files including application's static assets and resources using http(s)://
- * URLs inside a {@link android.webkit.WebView} class.
- * Loading assets and resources using web-like URLs is desirable as it is compatible with the
- * Same-Origin policy.
+ * Helper class to load local files including application's static assets and resources using
+ * http(s):// URLs inside a {@link android.webkit.WebView} class.
+ * Loading local files using web-like URLs instead of {@code "file://"} is desirable as it is
+ * compatible with the Same-Origin policy.
*
* <p>
* For more context about application's assets and resources and how to normally access them please
@@ -209,6 +214,113 @@
}
/**
+ * Handler class to open files from application internal storage.
+ * For more information about android storage please refer to
+ * <a href="https://developer.android.com/guide/topics/data/data-storage">Android Developers
+ * Docs: Data and file storage overview</a>.
+ * <p>
+ * To avoid leaking user or app data to the web, make sure to choose {@code directory}
+ * carefully, and assume any file under this directory could be accessed by any web page subject
+ * to same-origin rules.
+ * @hide
+ */
+ // TODO(b/132880733) unhide the API when it's ready.
+ @RestrictTo(Scope.LIBRARY_GROUP_PREFIX)
+ public static final class InternalStoragePathHandler implements PathHandler {
+ /**
+ * Forbidden subdirectories of {@link Context#getDataDir} that cannot be exposed by this
+ * handler. They are forbidden as they often contain sensitive information.
+ */
+ public static final String[] FORBIDDEN_DATA_DIRS =
+ new String[] {"app_webview/", "databases/", "lib/", "shared_prefs/", "code_cache/"};
+
+ @NonNull private final File mDirectory;
+
+ /**
+ * Creates PathHandler for app's internal storage.
+ * The directory to be exposed must be inside either the application's internal data
+ * directory {@link context#getDataDir} or cache directory {@link context#getCacheDir}.
+ * External storage is not supported for security reasons, as other apps with
+ * {@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE} may be able to modify the
+ * files.
+ * <p>
+ * Exposing the entire data or cache directory is not permitted, to avoid accidentally
+ * exposing sensitive application files to the web. Certain existing directories are also
+ * not permitted, such as {@link FORBIDDEN_DATA_DIRS}, as they are often sensitive.
+ * <p>
+ * The application should typically use a dedicated subdirectory for the files it intends to
+ * expose and keep them separate from other files.
+ *
+ * @param context {@link Context} that is used to access app's internal storage.
+ * @param directory the absolute path of the exposed app internal storage directory from
+ * which files can be loaded.
+ * @throws IllegalArgumentException if the directory is not allowed.
+ */
+ public InternalStoragePathHandler(@NonNull Context context, @NonNull File directory) {
+ if (!isAllowedInternalStorageDir(context, directory)) {
+ throw new IllegalArgumentException("The given directory \"" + directory
+ + "\" doesn't exist under an allowed app internal storage directory");
+ }
+ mDirectory = directory;
+ }
+
+ private static boolean isAllowedInternalStorageDir(@NonNull Context context,
+ @NonNull File dir) {
+ try {
+ String dirPath = AssetHelper.getCanonicalPath(dir);
+ String cacheDirPath = AssetHelper.getCanonicalPath(context.getCacheDir());
+ String dataDirPath = AssetHelper.getCanonicalPath(AssetHelper.getDataDir(context));
+ // dir has to be a subdirectory of data or cache dir.
+ if (!dirPath.startsWith(cacheDirPath) && !dirPath.startsWith(dataDirPath)) {
+ return false;
+ }
+ // dir cannot be the entire cache or data dir.
+ if (dirPath.equals(cacheDirPath) || dirPath.equals(dataDirPath)) {
+ return false;
+ }
+ // dir cannot be a subdirectory of any forbidden data dir.
+ for (String forbiddenPath : FORBIDDEN_DATA_DIRS) {
+ if (dirPath.startsWith(dataDirPath + forbiddenPath)) return false;
+ }
+ return true;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Opens the requested file from the exposed data directory.
+ * <p>
+ * The matched prefix path used shouldn't be a prefix of a real web path. Thus, if the
+ * requested file cannot be found or is outside the mounted directory a
+ * {@link WebResourceResponse} object with a {@code null} {@link InputStream} will be
+ * returned instead of {@code null}. This saves the time of falling back to network and
+ * trying to resolve a path that doesn't exist. A {@link WebResourceResponse} with
+ * {@code null} {@link InputStream} will be received as an HTTP response with status code
+ * {@code 404} and no body.
+ *
+ * @param path the suffix path to be handled.
+ * @return {@link WebResourceResponse} for the requested file.
+ */
+ @Override
+ @WorkerThread
+ @NonNull
+ public WebResourceResponse handle(@NonNull String path) {
+ File file = new File(mDirectory, path);
+ InputStream is = null;
+ if (AssetHelper.isCanonicalChildOf(mDirectory, file)) {
+ is = AssetHelper.openFile(file);
+ } else {
+ Log.e(TAG, "The requested file: " + path + " is outside the mounted directory: "
+ + mDirectory);
+ }
+ String mimeType = URLConnection.guessContentTypeFromName(path);
+ return new WebResourceResponse(mimeType, null, is);
+ }
+ }
+
+
+ /**
* Matches URIs on the form: {@code "http(s)://authority/path/**"}, HTTPS is always enabled.
*
* <p>
diff --git a/webkit/src/main/java/androidx/webkit/internal/AssetHelper.java b/webkit/src/main/java/androidx/webkit/internal/AssetHelper.java
index d2f2dfe..8294ab2 100644
--- a/webkit/src/main/java/androidx/webkit/internal/AssetHelper.java
+++ b/webkit/src/main/java/androidx/webkit/internal/AssetHelper.java
@@ -20,12 +20,15 @@
import android.content.res.AssetManager;
import android.content.res.Resources;
import android.net.Uri;
+import android.os.Build;
import android.util.Log;
import android.util.TypedValue;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import java.io.File;
+import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
@@ -124,4 +127,73 @@
return null;
}
}
+
+ /**
+ * Open an {@code InputStream} for a file in application data directories.
+ *
+ * @param file The the file to be opened.
+ * @return An {@code InputStream} for the requested file or {@code null} if an error happens.
+ */
+ @Nullable
+ public static InputStream openFile(@NonNull File file) {
+ try {
+ FileInputStream fis = new FileInputStream(file);
+ return handleSvgzStream(Uri.parse(file.getPath()), fis);
+ } catch (IOException e) {
+ Log.e(TAG, "Error opening the requested file " + file, e);
+ return null;
+ }
+ }
+
+ /**
+ * Util method to test if the a given file is a child of the given parent directory.
+ * It uses canonical paths to make sure to resolve any symlinks, {@code "../"}, {@code "./"}
+ * ... etc in the given paths.
+ *
+ * @param parent the parent directory.
+ * @param child the child file.
+ * @return {@code true} if the canonical path of the given {@code child} starts with the
+ * canonical path of the given {@code parent}, {@code false} otherwise.
+ */
+ public static boolean isCanonicalChildOf(@NonNull File parent, @NonNull File child) {
+ try {
+ String parentCanonicalPath = parent.getCanonicalPath();
+ String childCanonicalPath = child.getCanonicalPath();
+
+ if (!parentCanonicalPath.endsWith("/")) parentCanonicalPath += "/";
+
+ return childCanonicalPath.startsWith(parentCanonicalPath);
+ } catch (IOException e) {
+ Log.e(TAG, "Error getting the canonical path of file", e);
+ return false;
+ }
+ }
+
+ /**
+ * Get the canonical path of the given directory with a slash {@code "/"} at the end.
+ */
+ @NonNull
+ public static String getCanonicalPath(@NonNull File file) throws IOException {
+ String path = file.getCanonicalPath();
+ if (!path.endsWith("/")) path += "/";
+ return path;
+ }
+
+ /**
+ * Get the data dir for an application.
+ *
+ * @param context the {@link Context} used to get the data dir.
+ * @return data dir {@link File} for that app.
+ */
+ @NonNull
+ public static File getDataDir(@NonNull Context context) {
+ // Context#getDataDir is only available in APIs >= 24.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ return context.getDataDir();
+ } else {
+ // For APIs < 24 cache dir is created under the data dir.
+ return context.getCacheDir().getParentFile();
+ }
+ }
+
}