1 /*
2  * Copyright 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.webkit;
18 
19 import android.content.Context;
20 import android.content.res.Resources;
21 import android.net.Uri;
22 import android.util.Log;
23 import android.webkit.WebResourceResponse;
24 
25 import androidx.annotation.VisibleForTesting;
26 import androidx.annotation.WorkerThread;
27 import androidx.core.util.Pair;
28 import androidx.webkit.internal.AssetHelper;
29 
30 import org.jspecify.annotations.NonNull;
31 import org.jspecify.annotations.Nullable;
32 
33 import java.io.File;
34 import java.io.IOException;
35 import java.io.InputStream;
36 import java.util.ArrayList;
37 import java.util.List;
38 
39 /**
40  * Helper class to load local files including application's static assets and resources using
41  * http(s):// URLs inside a {@link android.webkit.WebView} class.
42  * Loading local files using web-like URLs instead of {@code "file://"} is desirable as it is
43  * compatible with the Same-Origin policy.
44  *
45  * <p>
46  * For more context about application's assets and resources and how to normally access them please
47  * refer to <a href="https://developer.android.com/guide/topics/resources/providing-resources">
48  * Android Developer Docs: App resources overview</a>.
49  *
50  * <p class='note'>
51  * This class is expected to be used within
52  * {@link android.webkit.WebViewClient#shouldInterceptRequest}, which is invoked on a different
53  * thread than application's main thread. Although instances are themselves thread-safe (and may be
54  * safely constructed on the application's main thread), exercise caution when accessing private
55  * data or the view system.
56  * <p>
57  * Using http(s):// URLs to access local resources may conflict with a real website. This means
58  * that local files should only be hosted on domains your organization owns (at paths reserved
59  * for this purpose) or the default domain reserved for this: {@code appassets.androidplatform.net}.
60  * <p>
61  * A typical usage would be like:
62  * <pre class="prettyprint">
63  * final WebViewAssetLoader assetLoader = new WebViewAssetLoader.Builder()
64  *          .addPathHandler("/assets/", new AssetsPathHandler(this))
65  *          .build();
66  * <p>
67  * webView.setWebViewClient(new WebViewClientCompat() {
68  *     {@literal @}Override
69  *     public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
70  *         return assetLoader.shouldInterceptRequest(request.getUrl());
71  *     }
72  * <p>
73  *     {@literal @}Override
74  *     {@literal @}SuppressWarnings("deprecation") // for API < 21
75  *     public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
76  *         return assetLoader.shouldInterceptRequest(Uri.parse(url));
77  *     }
78  * });
79  * <p>
80  * WebSettings webViewSettings = webView.getSettings();
81  * // Setting this off for security. Off by default for SDK versions >= 16.
82  * webViewSettings.setAllowFileAccessFromFileURLs(false);
83  * // Off by default, deprecated for SDK versions >= 30.
84  * webViewSettings.setAllowUniversalAccessFromFileURLs(false);
85  * // Keeping these off is less critical but still a good idea, especially if your app is not
86  * // using file:// or content:// URLs.
87  * webViewSettings.setAllowFileAccess(false);
88  * webViewSettings.setAllowContentAccess(false);
89  * <p>
90  * // Assets are hosted under http(s)://appassets.androidplatform.net/assets/... .
91  * // If the application's assets are in the "main/assets" folder this will read the file
92  * // from "main/assets/www/index.html" and load it as if it were hosted on:
93  * // https://appassets.androidplatform.net/assets/www/index.html
94  * webview.loadUrl("https://appassets.androidplatform.net/assets/www/index.html");
95  * </pre>
96  */
97 public final class WebViewAssetLoader {
98     private static final String TAG = "WebViewAssetLoader";
99 
100     /**
101      * An unused domain reserved for Android applications to intercept requests for app assets.
102      * <p>
103      * It is used by default unless the user specified a different domain.
104      */
105     public static final String DEFAULT_DOMAIN = "appassets.androidplatform.net";
106 
107     private final List<PathMatcher> mMatchers;
108 
109     /**
110      * A handler that produces responses for a registered path.
111      *
112      * <p>
113      * Implement this interface to handle other use-cases according to your app's needs.
114      * <p>
115      * Methods of this handler will be invoked on a background thread and care must be taken to
116      * correctly synchronize access to any shared state.
117      * <p>
118      * On Android KitKat and above these methods may be called on more than one thread. This thread
119      * may be different than the thread on which the shouldInterceptRequest method was invoked.
120      * This means that on Android KitKat and above it is possible to block in this method without
121      * blocking other resources from loading. The number of threads used to parallelize loading
122      * is an internal implementation detail of the WebView and may change between updates which
123      * means that the amount of time spent blocking in this method should be kept to an absolute
124      * minimum.
125      */
126     public interface PathHandler {
127         /**
128          * Handles the requested URL by returning the appropriate response.
129          * <p>
130          * Returning a {@code null} value means that the handler decided not to handle this path.
131          * In this case, {@link WebViewAssetLoader} will try the next handler registered on this
132          * path or pass to WebView that will fall back to network to try to resolve the URL.
133          * <p>
134          * However, if the handler wants to save unnecessary processing either by another handler or
135          * by falling back to network, in cases like a file cannot be found, it may return a
136          * {@code new WebResourceResponse(null, null, null)} which is received as an
137          * HTTP response with status code {@code 404} and no body.
138          *
139          * @param path the suffix path to be handled.
140          * @return {@link WebResourceResponse} for the requested path or {@code null} if it can't
141          *                                     handle this path.
142          */
143         @WorkerThread
handle(@onNull String path)144         @Nullable WebResourceResponse handle(@NonNull String path);
145     }
146 
147     /**
148      * Handler class to open a file from assets directory in the application APK.
149      */
150     public static final class AssetsPathHandler implements PathHandler {
151         private final AssetHelper mAssetHelper;
152 
153         /**
154          * @param context {@link Context} used to resolve assets.
155          */
AssetsPathHandler(@onNull Context context)156         public AssetsPathHandler(@NonNull Context context) {
157             mAssetHelper = new AssetHelper(context);
158         }
159 
160         @VisibleForTesting
AssetsPathHandler(@onNull AssetHelper assetHelper)161         /*package*/ AssetsPathHandler(@NonNull AssetHelper assetHelper) {
162             mAssetHelper = assetHelper;
163         }
164 
165         /**
166          * Opens the requested file from the application's assets directory.
167          * <p>
168          * The matched prefix path used shouldn't be a prefix of a real web path. Thus, if the
169          * requested file cannot be found a {@link WebResourceResponse} object with a {@code null}
170          * {@link InputStream} will be returned instead of {@code null}. This saves the time of
171          * falling back to network and trying to resolve a path that doesn't exist. A
172          * {@link WebResourceResponse} with {@code null} {@link InputStream} will be received as an
173          * HTTP response with status code {@code 404} and no body.
174          * <p class="note">
175          * The MIME type for the file will be determined from the file's extension using
176          * {@link java.net.URLConnection#guessContentTypeFromName}. Developers should ensure that
177          * asset files are named using standard file extensions. If the file does not have a
178          * recognised extension, {@code "text/plain"} will be used by default.
179          *
180          * @param path the suffix path to be handled.
181          * @return {@link WebResourceResponse} for the requested file.
182          */
183         @Override
184         @WorkerThread
handle(@onNull String path)185         public @Nullable WebResourceResponse handle(@NonNull String path) {
186             try {
187                 InputStream is = mAssetHelper.openAsset(path);
188                 String mimeType = AssetHelper.guessMimeType(path);
189                 return new WebResourceResponse(mimeType, null, is);
190             } catch (IOException e) {
191                 Log.e(TAG, "Error opening asset path: " + path, e);
192                 return new WebResourceResponse(null, null, null);
193             }
194         }
195     }
196 
197     /**
198      * Handler class to open a file from resources directory in the application APK.
199      */
200     public static final class ResourcesPathHandler implements PathHandler {
201         private final AssetHelper mAssetHelper;
202 
203         /**
204          * @param context {@link Context} used to resolve resources.
205          */
ResourcesPathHandler(@onNull Context context)206         public ResourcesPathHandler(@NonNull Context context) {
207             mAssetHelper = new AssetHelper(context);
208         }
209 
210         @VisibleForTesting
ResourcesPathHandler(@onNull AssetHelper assetHelper)211         /*package*/ ResourcesPathHandler(@NonNull AssetHelper assetHelper) {
212             mAssetHelper = assetHelper;
213         }
214 
215         /**
216          * Opens the requested file from application's resources directory.
217          * <p>
218          * The matched prefix path used shouldn't be a prefix of a real web path. Thus, if the
219          * requested file cannot be found a {@link WebResourceResponse} object with a {@code null}
220          * {@link InputStream} will be returned instead of {@code null}. This saves the time of
221          * falling back to network and trying to resolve a path that doesn't exist. A
222          * {@link WebResourceResponse} with {@code null} {@link InputStream} will be received as an
223          * HTTP response with status code {@code 404} and no body.
224          * <p class="note">
225          * The MIME type for the file will be determined from the file's extension using
226          * {@link java.net.URLConnection#guessContentTypeFromName}. Developers should ensure that
227          * resource files are named using standard file extensions. If the file does not have a
228          * recognised extension, {@code "text/plain"} will be used by default.
229          *
230          * @param path the suffix path to be handled.
231          * @return {@link WebResourceResponse} for the requested file.
232          */
233         @Override
234         @WorkerThread
handle(@onNull String path)235         public @Nullable WebResourceResponse handle(@NonNull String path) {
236             try {
237                 InputStream is = mAssetHelper.openResource(path);
238                 String mimeType = AssetHelper.guessMimeType(path);
239                 return new WebResourceResponse(mimeType, null, is);
240             } catch (Resources.NotFoundException e) {
241                 Log.e(TAG, "Resource not found from the path: " + path, e);
242             } catch (IOException e) {
243                 Log.e(TAG, "Error opening resource from the path: " + path, e);
244             }
245             return new WebResourceResponse(null, null, null);
246         }
247     }
248 
249     /**
250      * Handler class to open files from application internal storage.
251      * For more information about android storage please refer to
252      * <a href="https://developer.android.com/guide/topics/data/data-storage">Android Developers
253      * Docs: Data and file storage overview</a>.
254      * <p class="note">
255      * To avoid leaking user or app data to the web, make sure to choose {@code directory}
256      * carefully, and assume any file under this directory could be accessed by any web page subject
257      * to same-origin rules.
258      * <p>
259      * A typical usage would be like:
260      * <pre class="prettyprint">
261      * File publicDir = new File(context.getFilesDir(), "public");
262      * // Host "files/public/" in app's data directory under:
263      * // http://appassets.androidplatform.net/public/...
264      * WebViewAssetLoader assetLoader = new WebViewAssetLoader.Builder()
265      *          .addPathHandler("/public/", new InternalStoragePathHandler(context, publicDir))
266      *          .build();
267      * </pre>
268      */
269     public static final class InternalStoragePathHandler implements PathHandler {
270         /**
271          * Forbidden subdirectories of {@link Context#getDataDir} that cannot be exposed by this
272          * handler. They are forbidden as they often contain sensitive information.
273          * <p class="note">
274          * Note: Any future addition to this list will be considered breaking changes to the API.
275          */
276         private static final String[] FORBIDDEN_DATA_DIRS =
277                 new String[] {"app_webview/", "databases/", "lib/", "shared_prefs/", "code_cache/"};
278 
279         private final @NonNull File mDirectory;
280 
281         /**
282          * Creates PathHandler for app's internal storage.
283          * The directory to be exposed must be inside either the application's internal data
284          * directory {@link Context#getDataDir} or cache directory {@link Context#getCacheDir}.
285          * External storage is not supported for security reasons, as other apps with
286          * {@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE} may be able to modify the
287          * files.
288          * <p>
289          * Exposing the entire data or cache directory is not permitted, to avoid accidentally
290          * exposing sensitive application files to the web. Certain existing subdirectories of
291          * {@link Context#getDataDir} are also not permitted as they are often sensitive.
292          * These files are ({@code "app_webview/"}, {@code "databases/"}, {@code "lib/"},
293          * {@code "shared_prefs/"} and {@code "code_cache/"}).
294          * <p>
295          * The application should typically use a dedicated subdirectory for the files it intends to
296          * expose and keep them separate from other files.
297          *
298          * @param context {@link Context} that is used to access app's internal storage.
299          * @param directory the absolute path of the exposed app internal storage directory from
300          *                  which files can be loaded.
301          * @throws IllegalArgumentException if the directory is not allowed.
302          */
InternalStoragePathHandler(@onNull Context context, @NonNull File directory)303         public InternalStoragePathHandler(@NonNull Context context, @NonNull File directory) {
304             try {
305                 mDirectory = new File(AssetHelper.getCanonicalDirPath(directory));
306                 if (!isAllowedInternalStorageDir(context)) {
307                     throw new IllegalArgumentException("The given directory \"" + directory
308                             + "\" doesn't exist under an allowed app internal storage directory");
309                 }
310             } catch (IOException e) {
311                 throw new IllegalArgumentException(
312                         "Failed to resolve the canonical path for the given directory: "
313                         + directory.getPath(), e);
314             }
315         }
316 
isAllowedInternalStorageDir(@onNull Context context)317         private boolean isAllowedInternalStorageDir(@NonNull Context context) throws IOException {
318             String dir = AssetHelper.getCanonicalDirPath(mDirectory);
319             String cacheDir = AssetHelper.getCanonicalDirPath(context.getCacheDir());
320             String dataDir = AssetHelper.getCanonicalDirPath(AssetHelper.getDataDir(context));
321             // dir has to be a subdirectory of data or cache dir.
322             if (!dir.startsWith(cacheDir) && !dir.startsWith(dataDir)) {
323                 return false;
324             }
325             // dir cannot be the entire cache or data dir.
326             if (dir.equals(cacheDir) || dir.equals(dataDir)) {
327                 return false;
328             }
329             // dir cannot be a subdirectory of any forbidden data dir.
330             for (String forbiddenPath : FORBIDDEN_DATA_DIRS) {
331                 if (dir.startsWith(dataDir + forbiddenPath)) {
332                     return false;
333                 }
334             }
335             return true;
336         }
337 
338         /**
339          * Opens the requested file from the exposed data directory.
340          * <p>
341          * The matched prefix path used shouldn't be a prefix of a real web path. Thus, if the
342          * requested file cannot be found or is outside the mounted directory a
343          * {@link WebResourceResponse} object with a {@code null} {@link InputStream} will be
344          * returned instead of {@code null}. This saves the time of falling back to network and
345          * trying to resolve a path that doesn't exist. A {@link WebResourceResponse} with
346          * {@code null} {@link InputStream} will be received as an HTTP response with status code
347          * {@code 404} and no body.
348          * <p class="note">
349          * The MIME type for the file will be determined from the file's extension using
350          * {@link java.net.URLConnection#guessContentTypeFromName}. Developers should ensure that
351          * files are named using standard file extensions. If the file does not have a
352          * recognised extension, {@code "text/plain"} will be used by default.
353          *
354          * @param path the suffix path to be handled.
355          * @return {@link WebResourceResponse} for the requested file.
356          */
357         @Override
358         @WorkerThread
handle(@onNull String path)359         public @NonNull WebResourceResponse handle(@NonNull String path) {
360             try {
361                 File file = AssetHelper.getCanonicalFileIfChild(mDirectory, path);
362                 if (file != null) {
363                     InputStream is = AssetHelper.openFile(file);
364                     String mimeType = AssetHelper.guessMimeType(path);
365                     return new WebResourceResponse(mimeType, null, is);
366                 } else {
367                     Log.e(TAG, String.format(
368                             "The requested file: %s is outside the mounted directory: %s", path,
369                                     mDirectory));
370                 }
371             } catch (IOException e) {
372                 Log.e(TAG, "Error opening the requested path: " + path, e);
373             }
374             return new WebResourceResponse(null, null, null);
375         }
376     }
377 
378 
379     /**
380      * Matches URIs on the form: {@code "http(s)://authority/path/**"}, HTTPS is always enabled.
381      *
382      * <p>
383      * Methods of this class will be invoked on a background thread and care must be taken to
384      * correctly synchronize access to any shared state.
385      * <p>
386      * On Android KitKat and above these methods may be called on more than one thread. This thread
387      * may be different than the thread on which the shouldInterceptRequest method was invoked.
388      * This means that on Android KitKat and above it is possible to block in this method without
389      * blocking other resources from loading. The number of threads used to parallelize loading
390      * is an internal implementation detail of the WebView and may change between updates which
391      * means that the amount of time spent blocking in this method should be kept to an absolute
392      * minimum.
393      */
394     @VisibleForTesting
395     /*package*/ static class PathMatcher {
396         static final String HTTP_SCHEME = "http";
397         static final String HTTPS_SCHEME = "https";
398 
399         final boolean mHttpEnabled;
400         final @NonNull String mAuthority;
401         final @NonNull String mPath;
402         final @NonNull PathHandler mHandler;
403 
404         /**
405          * @param authority the authority to match (For instance {@code "example.com"})
406          * @param path the prefix path to match, it should start and end with a {@code "/"}.
407          * @param httpEnabled enable hosting under the HTTP scheme, HTTPS is always enabled.
408          * @param handler the {@link PathHandler} the handler class for this URI.
409          */
PathMatcher(final @NonNull String authority, final @NonNull String path, boolean httpEnabled, final @NonNull PathHandler handler)410         PathMatcher(final @NonNull String authority, final @NonNull String path,
411                             boolean httpEnabled, final @NonNull PathHandler handler) {
412             if (path.isEmpty() || path.charAt(0) != '/') {
413                 throw new IllegalArgumentException("Path should start with a slash '/'.");
414             }
415             if (!path.endsWith("/")) {
416                 throw new IllegalArgumentException("Path should end with a slash '/'");
417             }
418             mAuthority = authority;
419             mPath = path;
420             mHttpEnabled = httpEnabled;
421             mHandler = handler;
422         }
423 
424         /**
425          * Match against registered scheme, authority and path prefix.
426          * <p>
427          * Match happens when:
428          * <ul>
429          *      <li>Scheme is "https" <b>or</b> the scheme is "http" and http is enabled.</li>
430          *      <li>Authority exact matches the given URI's authority.</li>
431          *      <li>Path is a prefix of the given URI's path.</li>
432          * </ul>
433          *
434          * @param uri the URI whose path we will match against.
435          *
436          * @return {@code PathHandler} if a match happens, {@code null} otherwise.
437          */
438         @WorkerThread
match(@onNull Uri uri)439         public @Nullable PathHandler match(@NonNull Uri uri) {
440             // Only match HTTP_SCHEME if caller enabled HTTP matches.
441             if (uri.getScheme().equals(HTTP_SCHEME) && !mHttpEnabled) {
442                 return null;
443             }
444             // Don't match non-HTTP(S) schemes.
445             if (!uri.getScheme().equals(HTTP_SCHEME) && !uri.getScheme().equals(HTTPS_SCHEME)) {
446                 return null;
447             }
448             if (!uri.getAuthority().equals(mAuthority)) {
449                 return null;
450             }
451             if (!uri.getPath().startsWith(mPath)) {
452                 return null;
453             }
454             return mHandler;
455         }
456 
457         /**
458          * Utility method to get the suffix path of a matched path.
459          *
460          * @param path the full received path.
461          * @return the suffix path.
462          */
463         @WorkerThread
getSuffixPath(@onNull String path)464         public @NonNull String getSuffixPath(@NonNull String path) {
465             return path.replaceFirst(mPath, "");
466         }
467     }
468 
469     /**
470      * A builder class for constructing {@link WebViewAssetLoader} objects.
471      */
472     public static final class Builder {
473         private boolean mHttpAllowed;
474         private String mDomain = DEFAULT_DOMAIN;
475         // This is stored as a List<Pair> to preserve the order in which PathHandlers are added and
476         // permit multiple PathHandlers for the same path.
477         private final @NonNull List<Pair<String, PathHandler>> mHandlerList = new ArrayList<>();
478 
479         /**
480          * Set the domain under which app assets can be accessed.
481          * The default domain is {@code "appassets.androidplatform.net"}
482          *
483          * @param domain the domain on which app assets should be hosted.
484          * @return {@link Builder} object.
485          */
setDomain(@onNull String domain)486         public @NonNull Builder setDomain(@NonNull String domain) {
487             mDomain = domain;
488             return this;
489         }
490 
491         /**
492          * Allow using the HTTP scheme in addition to HTTPS.
493          * The default is to not allow HTTP.
494          *
495          * @return {@link Builder} object.
496          */
setHttpAllowed(boolean httpAllowed)497         public @NonNull Builder setHttpAllowed(boolean httpAllowed) {
498             mHttpAllowed = httpAllowed;
499             return this;
500         }
501 
502         /**
503          * Register a {@link PathHandler} for a specific path.
504          * <p>
505          * The path should start and end with a {@code "/"} and it shouldn't collide with a real web
506          * path.
507          *
508          * <p>{@code WebViewAssetLoader} will try {@code PathHandlers} in the order they're
509          * registered, and will use whichever is the first to return a non-{@code null} {@link
510          * WebResourceResponse}.
511          *
512          * @param path the prefix path where this handler should be register.
513          * @param handler {@link PathHandler} that handles requests for this path.
514          * @return {@link Builder} object.
515          * @throws IllegalArgumentException if the path is invalid.
516          */
addPathHandler(@onNull String path, @NonNull PathHandler handler)517         public @NonNull Builder addPathHandler(@NonNull String path, @NonNull PathHandler handler) {
518             mHandlerList.add(Pair.create(path, handler));
519             return this;
520         }
521 
522         /**
523          * Build and return a {@link WebViewAssetLoader} object.
524          *
525          * @return immutable {@link WebViewAssetLoader} object.
526          */
build()527         public @NonNull WebViewAssetLoader build() {
528             List<PathMatcher> pathMatcherList = new ArrayList<>();
529             for (Pair<String, PathHandler> pair : mHandlerList) {
530                 String path = pair.first;
531                 PathHandler handler = pair.second;
532                 pathMatcherList.add(new PathMatcher(mDomain, path, mHttpAllowed, handler));
533             }
534             return new WebViewAssetLoader(pathMatcherList);
535         }
536     }
537 
WebViewAssetLoader(@onNull List<PathMatcher> pathMatchers)538     /*package*/ WebViewAssetLoader(@NonNull List<PathMatcher> pathMatchers) {
539         mMatchers = pathMatchers;
540     }
541 
542     /**
543      * Attempt to resolve the {@code url} to an application resource or asset, and return
544      * a {@link WebResourceResponse} for the content.
545      * <p>
546      * This method should be invoked from within
547      * {@link android.webkit.WebViewClient#shouldInterceptRequest(android.webkit.WebView, String)}.
548      *
549      * @param url the URL to process.
550      * @return {@link WebResourceResponse} if the request URL matches a registered URL,
551      *         {@code null} otherwise.
552      */
553     @WorkerThread
shouldInterceptRequest(@onNull Uri url)554     public @Nullable WebResourceResponse shouldInterceptRequest(@NonNull Uri url) {
555         for (PathMatcher matcher : mMatchers) {
556             PathHandler handler = matcher.match(url);
557             // The requested URL doesn't match the URL where this handler has been registered.
558             if (handler == null) continue;
559             String suffixPath = matcher.getSuffixPath(url.getPath());
560             WebResourceResponse response = handler.handle(suffixPath);
561             // Handler doesn't want to intercept this request, try next handler.
562             if (response == null) continue;
563 
564             return response;
565         }
566         return null;
567     }
568 }
569