package io.github.a13e300.ksuwebui; import android.content.Context; import android.util.Log; import android.webkit.WebResourceResponse; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import androidx.webkit.WebViewAssetLoader; import com.topjohnwu.superuser.nio.FileSystemManager; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.util.zip.GZIPInputStream; /** * Handler class to open files from file system by root access * For more information about android storage please refer to * Android Developers * Docs: Data and file storage overview. *
* 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. *
* A typical usage would be like: *
* File publicDir = new File(context.getFilesDir(), "public");
* // Host "files/public/" in app's data directory under:
* // http://appassets.androidplatform.net/public/...
* WebViewAssetLoader assetLoader = new WebViewAssetLoader.Builder()
* .addPathHandler("/public/", new InternalStoragePathHandler(context, publicDir))
* .build();
*
*/
public final class RemoteFsPathHandler implements WebViewAssetLoader.PathHandler {
private static final String TAG = "FsServicePathHandler";
/**
* Default value to be used as MIME type if guessing MIME type failed.
*/
public static final String DEFAULT_MIME_TYPE = "text/plain";
/**
* Forbidden subdirectories of {@link Context#getDataDir} that cannot be exposed by this
* handler. They are forbidden as they often contain sensitive information.
* * Note: Any future addition to this list will be considered breaking changes to the API. */ private static final String[] FORBIDDEN_DATA_DIRS = new String[] {"/data/data", "/data/system"}; @NonNull private final File mDirectory; private final FileSystemManager mFs; /** * 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. *
* Exposing the entire data or cache directory is not permitted, to avoid accidentally * exposing sensitive application files to the web. Certain existing subdirectories of * {@link Context#getDataDir} are also not permitted as they are often sensitive. * These files are ({@code "app_webview/"}, {@code "databases/"}, {@code "lib/"}, * {@code "shared_prefs/"} and {@code "code_cache/"}). *
* 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 RemoteFsPathHandler(@NonNull Context context, @NonNull File directory, FileSystemManager fs) { try { mDirectory = new File(getCanonicalDirPath(directory)); if (!isAllowedInternalStorageDir(context)) { throw new IllegalArgumentException("The given directory \"" + directory + "\" doesn't exist under an allowed app internal storage directory"); } mFs = fs; } catch (IOException e) { throw new IllegalArgumentException( "Failed to resolve the canonical path for the given directory: " + directory.getPath(), e); } } private boolean isAllowedInternalStorageDir(@NonNull Context context) throws IOException { String dir = getCanonicalDirPath(mDirectory); for (String forbiddenPath : FORBIDDEN_DATA_DIRS) { if (dir.startsWith(forbiddenPath)) { return false; } } return true; } /** * Opens the requested file from the exposed data directory. *
* 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. *
* The MIME type for the file will be determined from the file's extension using * {@link java.net.URLConnection#guessContentTypeFromName}. Developers should ensure that * files are named using standard file extensions. If the file does not have a * recognised extension, {@code "text/plain"} will be used by default. * * @param path the suffix path to be handled. * @return {@link WebResourceResponse} for the requested file. */ @Override @WorkerThread @NonNull public WebResourceResponse handle(@NonNull String path) { try { File file = getCanonicalFileIfChild(mDirectory, path); if (file != null) { InputStream is = openFile(file, mFs); String mimeType = guessMimeType(path); return new WebResourceResponse(mimeType, null, is); } else { Log.e(TAG, String.format( "The requested file: %s is outside the mounted directory: %s", path, mDirectory)); } } catch (IOException e) { Log.e(TAG, "Error opening the requested path: " + path, e); } return new WebResourceResponse(null, null, null); } public static String getCanonicalDirPath(@NonNull File file) throws IOException { String canonicalPath = file.getCanonicalPath(); if (!canonicalPath.endsWith("/")) canonicalPath += "/"; return canonicalPath; } public static File getCanonicalFileIfChild(@NonNull File parent, @NonNull String child) throws IOException { String parentCanonicalPath = getCanonicalDirPath(parent); String childCanonicalPath = new File(parent, child).getCanonicalPath(); if (childCanonicalPath.startsWith(parentCanonicalPath)) { return new File(childCanonicalPath); } return null; } @NonNull private static InputStream handleSvgzStream(@NonNull String path, @NonNull InputStream stream) throws IOException { return path.endsWith(".svgz") ? new GZIPInputStream(stream) : stream; } public static InputStream openFile(@NonNull File file, @NonNull FileSystemManager fs) throws IOException { return handleSvgzStream(file.getPath(), fs.getFile(file.getAbsolutePath()).newInputStream()); } /** * Use {@link MimeUtil#getMimeFromFileName} to guess MIME type or return the * {@link #DEFAULT_MIME_TYPE} if it can't guess. * * @param filePath path of the file to guess its MIME type. * @return MIME type guessed from file extension or {@link #DEFAULT_MIME_TYPE}. */ @NonNull public static String guessMimeType(@NonNull String filePath) { String mimeType = MimeUtil.getMimeFromFileName(filePath); return mimeType == null ? DEFAULT_MIME_TYPE : mimeType; } }