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