diff --git a/src/lib/installPlugin.js b/src/lib/installPlugin.js index 9dd31ab78..ff3da2343 100644 --- a/src/lib/installPlugin.js +++ b/src/lib/installPlugin.js @@ -8,7 +8,6 @@ import helpers from "utils/helpers"; import Url from "utils/Url"; import { isVersionGreater } from "utils/version"; import config from "./config"; -import InstallState from "./installState"; import { loadPluginWithTimeout } from "./loadPlugins"; /** @type {import("dialogs/loader").Loader} */ @@ -16,6 +15,24 @@ let loaderDialog; /** @type {Array<() => Promise>} */ let depsLoaders; +const PLUGIN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +function assertSafePluginId(id) { + if (!PLUGIN_ID_PATTERN.test(String(id || ""))) { + throw new Error("Invalid plugin id"); + } +} + +function extractPluginArchive(archiveUrl, pluginDir, manifest) { + return new Promise((resolve, reject) => { + cordova.exec(resolve, reject, "Tee", "extractPluginArchive", [ + archiveUrl, + pluginDir, + manifest, + ]); + }); +} + /** * Installs a plugin. * @param {string} id @@ -38,7 +55,9 @@ export default async function installPlugin( let pluginDir; let pluginUrl; - let state; + let archiveUrl; + let pluginWasInstalled = false; + let extractionComplete = false; try { if (!(await fsOperation(PLUGIN_DIR).exists())) { @@ -164,90 +183,26 @@ export default async function installPlugin( if (!pluginDir) { pluginJson.source = pluginUrl; id = pluginJson.id; - pluginDir = Url.join(PLUGIN_DIR, id); } - state = await InstallState.new(id); - - if (!(await fsOperation(pluginDir).exists())) { - await fsOperation(PLUGIN_DIR).createDirectory(id); - } - - // Track unsafe absolute entries to skip - const ignoredUnsafeEntries = new Set(); - - const files = Object.keys(zip.files); - const limit = 2; - - async function processFile(file) { - try { - const entry = zip.files[file]; - - let correctFile = file.replace(/\\/g, "/"); - const isDirEntry = entry.dir || correctFile.endsWith("/"); - - if (isUnsafeAbsolutePath(file)) { - ignoredUnsafeEntries.add(file); - return; - } - - correctFile = sanitizeZipPath(correctFile, isDirEntry); - if (!correctFile) return; - - const fileUrl = Url.join(pluginDir, correctFile); - - // Handle directory entries - if (isDirEntry) { - await createFileRecursive(pluginDir, correctFile, true); - return; - } - - // Ensure parent directory exists - const lastSlash = correctFile.lastIndexOf("/"); - if (lastSlash !== -1) { - const parentRel = correctFile.slice(0, lastSlash + 1); - await createFileRecursive(pluginDir, parentRel, true); - } - - if (!state.exists(correctFile)) { - await createFileRecursive(pluginDir, correctFile, false); - } - - let data = await entry.async("ArrayBuffer"); - - if (file === "plugin.json") { - data = JSON.stringify(pluginJson); - } - - if (!(await state.isUpdated(correctFile, data))) return; - - await fsOperation(fileUrl).writeFile(data); - } catch (error) { - console.error(`Error processing file ${file}:`, error); - } - } - - // Process in batches - for (let i = 0; i < files.length; i += limit) { - const batch = files.slice(i, i + limit); - await Promise.allSettled(batch.map(processFile)); - - // Allow UI thread to breathe - await new Promise((r) => setTimeout(r, 0)); - } - // Emit a non-blocking warning if any unsafe entries were skipped - if (!isDependency && ignoredUnsafeEntries.size) { - const sample = Array.from(ignoredUnsafeEntries).slice(0, 3).join(", "); - loaderDialog.setMessage( - `Skipped ${ignoredUnsafeEntries.size} unsafe archive entr${ - ignoredUnsafeEntries.size === 1 ? "y" : "ies" - } (e.g., ${sample})`, - ); - console.warn( - "Plugin installer: skipped unsafe absolute paths in archive:", - Array.from(ignoredUnsafeEntries), - ); - } + assertSafePluginId(id); + pluginDir = Url.join(PLUGIN_DIR, id); + pluginWasInstalled = await fsOperation(pluginDir).exists(); + archiveUrl = Url.join( + CACHE_STORAGE, + `.plugin-install-${helpers.uuid()}.zip`, + ); + await fsOperation(CACHE_STORAGE).createFile( + Url.basename(archiveUrl), + plugin, + ); + loaderDialog?.setMessage("Extracting plugin files..."); + await extractPluginArchive( + archiveUrl, + pluginDir, + JSON.stringify(pluginJson), + ); + extractionComplete = true; if (isDependency) { depsLoaders.push(async () => { @@ -259,17 +214,17 @@ export default async function installPlugin( } await loadPluginWithTimeout(id, true); } - - await state.save(); - deleteRedundantFiles(pluginDir, state); } } catch (err) { try { - // Clear the install state if installation fails - if (state) await state.clear(); - - // Delete the plugin directory if it was created - if (pluginDir && (await fsOperation(pluginDir).exists())) { + // A failed extraction leaves the previous plugin untouched. If a brand + // new plugin fails after activation, remove that incomplete install. + if ( + extractionComplete && + !pluginWasInstalled && + pluginDir && + (await fsOperation(pluginDir).exists()) + ) { await fsOperation(pluginDir).delete(); } } catch (cleanupError) { @@ -277,116 +232,15 @@ export default async function installPlugin( } throw err; } finally { - if (!isDependency) { - loaderDialog.destroy(); - } - } -} - -/** - * Create directory recursively - * @param {string} parent - * @param {Array | string} dir - */ -async function createFileRecursive(parent, dir, shouldBeDirAtEnd) { - let wantDirEnd = !!shouldBeDirAtEnd; - /** @type {string[]} */ - let parts; - if (typeof dir === "string") { - if (dir.endsWith("/")) wantDirEnd = true; - dir = dir.replace(/\\/g, "/"); - parts = dir.split("/"); - } else { - parts = dir; - } - parts = parts.filter((d) => d); - const cd = parts.shift(); - if (!cd) return; - const newParent = Url.join(parent, cd); - - const isLast = parts.length === 0; - const needDir = !isLast || wantDirEnd; - if (!(await fsOperation(newParent).exists())) { - if (needDir) { + if (archiveUrl) { try { - await fsOperation(parent).createDirectory(cd); - } catch (e) { - // If another concurrent task created it, consider it fine - if (!(await fsOperation(newParent).exists())) throw e; - } - } else { - try { - await fsOperation(parent).createFile(cd); - } catch (e) { - if (!(await fsOperation(newParent).exists())) throw e; - } + await fsOperation(archiveUrl).delete(); + } catch (_) {} } - } - if (parts.length) { - await createFileRecursive(newParent, parts, wantDirEnd); - } -} - -/** - * Sanitize zip entry path to ensure it's relative and safe under pluginDir - * - Normalizes separators to '/' - * - Strips leading slashes and Windows drive prefixes (e.g., C:/) - * - Resolves '.' and '..' segments - * - Preserves trailing slash for directory entries - * @param {string} p - * @param {boolean} isDir - * @returns {string} sanitized relative path - */ -function sanitizeZipPath(p, isDir) { - if (!p) return ""; - let path = String(p); - // Normalize separators - path = path.replace(/\\/g, "/"); - // Remove URL-like scheme if present accidentally - path = path.replace(/^[a-zA-Z]+:\/\//, ""); - // Strip leading slashes - path = path.replace(/^\/+/, ""); - // Strip Windows drive letter, e.g., C:/ - path = path.replace(/^[A-Za-z]:\//, ""); - - const parts = path.split("/"); - const stack = []; - for (const part of parts) { - if (!part || part === ".") continue; - if (part === "..") { - if (stack.length) stack.pop(); - continue; + if (!isDependency) { + loaderDialog.destroy(); } - stack.push(part); - } - let safe = stack.join("/"); - if (isDir && safe && !safe.endsWith("/")) safe += "/"; - return safe; -} - -/** - * Detects unsafe absolute paths in zip entries that should be ignored. - * Treats leading '/' as absolute, Windows drive roots like 'C:/' as absolute, - * and common Android/Linux device roots like '/data', '/root', '/system'. - * @param {string} p - */ -function isUnsafeAbsolutePath(p) { - if (!p) return false; - const s = String(p); - if (/^[A-Za-z]:[\\\/]/.test(s)) return true; // Windows drive root - if (s.startsWith("//")) return true; // network path - if (s.startsWith("/")) { - return ( - s.startsWith("/data") || - s.startsWith("/system") || - s.startsWith("/vendor") || - s.startsWith("/storage") || - s.startsWith("/sdcard") || - s.startsWith("/root") || - true // any leading slash is unsafe - ); } - return false; } /** @@ -495,35 +349,3 @@ async function resolveDep(manifest) { return purchase; } } - -/** - * - * @param {string} dir - * @param {Array} files - */ -async function listFileRecursive(dir, files) { - for (const child of await fsOperation(dir).lsDir()) { - const fileUrl = Url.join(dir, child.name); - if (child.isDirectory) { - await listFileRecursive(fileUrl, files); - } else { - files.push(fileUrl); - } - } -} - -/** - * - * @param {Record} files - */ -async function deleteRedundantFiles(pluginDir, state) { - /** @type {string[]} */ - let files = []; - await listFileRecursive(pluginDir, files); - - for (const file of files) { - if (!state.exists(file.replace(`${pluginDir}/`, ""))) { - fsOperation(file).delete(); - } - } -} diff --git a/src/plugins/pluginContext/src/android/Tee.java b/src/plugins/pluginContext/src/android/Tee.java index 39edb423c..89a72abd8 100644 --- a/src/plugins/pluginContext/src/android/Tee.java +++ b/src/plugins/pluginContext/src/android/Tee.java @@ -10,18 +10,32 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.HashMap; -import java.util.HashSet; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import android.content.Context; +import android.net.Uri; import org.apache.cordova.*; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Enumeration; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + //auth plugin import com.foxdebug.acode.rk.auth.EncryptedPreferenceManager; public class Tee extends CordovaPlugin { + private static final int MAX_ARCHIVE_ENTRIES = 16 * 1024; + private static final long MAX_ARCHIVE_BYTES = 512L * 1024 * 1024; + private static final long MAX_ENTRY_BYTES = 128L * 1024 * 1024; + private static final int BUFFER_SIZE = 64 * 1024; + private static final Set activeExtractions = ConcurrentHashMap.newKeySet(); + // pluginId : token private /*static*/ final Map tokenStore = new ConcurrentHashMap<>(); @@ -45,6 +59,11 @@ public void initialize(CordovaInterface cordova, CordovaWebView webView) { public boolean execute(String action, JSONArray args, CallbackContext callback) throws JSONException { + if ("extractPluginArchive".equals(action)) { + extractPluginArchive(args.getString(0), args.getString(1), args.getString(2), callback); + return true; + } + if ("get_secret".equals(action)) { String token = args.getString(0); @@ -126,6 +145,226 @@ public boolean execute(String action, JSONArray args, CallbackContext callback) return false; } + /** + * Extract a downloaded archive in native code. The previous plugin is kept + * in place until all archive entries have been streamed to a sibling + * staging directory and the directory swap succeeds. + */ + private void extractPluginArchive( + final String archiveUri, + final String destinationUri, + final String manifest, + final CallbackContext callback + ) { + cordova.getThreadPool().execute(new Runnable() { + @Override + public void run() { + File staging = null; + File backup = null; + File destination = null; + String destinationPath = null; + try { + File archive = webView.getResourceApi().mapUriToFile(Uri.parse(archiveUri)); + destination = webView.getResourceApi().mapUriToFile(Uri.parse(destinationUri)); + if (archive == null || !archive.isFile()) { + throw new IOException("Plugin archive is unavailable"); + } + if (destination == null || destination.getParentFile() == null) { + throw new IOException("Plugin destination is unavailable"); + } + + File parent = destination.getParentFile().getCanonicalFile(); + destination = destination.getCanonicalFile(); + if (!destination.getParentFile().equals(parent)) { + throw new IOException("Invalid plugin destination"); + } + if (!parent.exists() && !parent.mkdirs()) { + throw new IOException("Unable to create plugin directory"); + } + destinationPath = destination.getPath(); + if (!activeExtractions.add(destinationPath)) { + throw new IOException("Plugin installation is already in progress"); + } + + restoreInterruptedInstall(parent, destination); + + staging = new File( + parent, + "." + destination.getName() + ".install-" + UUID.randomUUID() + ); + if (!staging.mkdirs()) { + throw new IOException("Unable to create plugin staging directory"); + } + + extractArchive(archive, staging); + writeManifest(staging, manifest); + + if (destination.exists()) { + backup = new File( + parent, + "." + destination.getName() + ".backup-" + UUID.randomUUID() + ); + if (!destination.renameTo(backup)) { + throw new IOException("Unable to stage existing plugin"); + } + } + + if (!staging.renameTo(destination)) { + if (backup != null && backup.exists()) { + backup.renameTo(destination); + } + throw new IOException("Unable to activate plugin"); + } + staging = null; + + if (backup != null) { + deleteRecursively(backup); + } + callback.success(); + } catch (Exception error) { + callback.error(error.getMessage() == null ? "Plugin extraction failed" : error.getMessage()); + } finally { + if (staging != null) { + deleteRecursively(staging); + } + if (backup != null && backup.exists() && destination != null && !destination.exists()) { + backup.renameTo(destination); + } + if (destinationPath != null) { + activeExtractions.remove(destinationPath); + } + } + } + }); + } + + private static void extractArchive(File archive, File destination) throws IOException { + int entryCount = 0; + long extractedBytes = 0; + byte[] buffer = new byte[BUFFER_SIZE]; + + try (ZipFile zipFile = new ZipFile(archive)) { + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + entryCount += 1; + if (entryCount > MAX_ARCHIVE_ENTRIES) { + throw new IOException("Plugin archive contains too many files"); + } + + String name = normalizeArchivePath(entry.getName()); + if (name == null) continue; + File output = new File(destination, name); + if (entry.isDirectory()) { + if (!output.mkdirs() && !output.isDirectory()) { + throw new IOException("Unable to create plugin directory"); + } + continue; + } + + long declaredSize = entry.getSize(); + if (declaredSize > MAX_ENTRY_BYTES) { + throw new IOException("Plugin archive contains an oversized file"); + } + File outputParent = output.getParentFile(); + if (!outputParent.exists() && !outputParent.mkdirs()) { + throw new IOException("Unable to create plugin directory"); + } + + long entryBytes = 0; + try ( + InputStream input = zipFile.getInputStream(entry); + FileOutputStream outputStream = new FileOutputStream(output) + ) { + int count; + while ((count = input.read(buffer)) != -1) { + entryBytes += count; + extractedBytes += count; + if (entryBytes > MAX_ENTRY_BYTES || extractedBytes > MAX_ARCHIVE_BYTES) { + throw new IOException("Plugin archive is too large"); + } + outputStream.write(buffer, 0, count); + } + } + } + } + + } + + /** + * The staging directory is newly created for each install, so rejecting + * absolute and parent paths is sufficient to keep every output below it. + * This avoids a canonical-path filesystem lookup for every archive entry. + */ + private static String normalizeArchivePath(String path) throws IOException { + if (path == null) throw new IOException("Plugin archive contains an unsafe path"); + String rawPath = path.replace('\\', '/'); + if (rawPath.startsWith("/") || rawPath.matches("^[A-Za-z]:($|/.*)") || rawPath.indexOf('\0') >= 0) { + throw new IOException("Plugin archive contains an unsafe path"); + } + + StringBuilder normalizedPath = new StringBuilder(rawPath.length()); + for (String segment : rawPath.split("/")) { + if (segment.isEmpty() || ".".equals(segment)) continue; + if ("..".equals(segment)) { + throw new IOException("Plugin archive attempts to write outside its directory"); + } + if (normalizedPath.length() > 0) normalizedPath.append('/'); + normalizedPath.append(segment); + } + return normalizedPath.length() == 0 ? null : normalizedPath.toString(); + } + + private static void writeManifest(File destination, String manifest) throws IOException { + try (FileOutputStream output = new FileOutputStream(new File(destination, "plugin.json"))) { + output.write(manifest.getBytes(StandardCharsets.UTF_8)); + } + } + + /** + * A directory rename cannot be made atomic with replacing an existing + * directory. If Android stops the app between the two renames, restore the + * most recent backup before beginning another installation. + */ + private static void restoreInterruptedInstall(File parent, File destination) throws IOException { + String backupPrefix = "." + destination.getName() + ".backup-"; + File[] children = parent.listFiles(); + if (children == null) return; + + File newestBackup = null; + for (File child : children) { + if (!child.isDirectory() || !child.getName().startsWith(backupPrefix)) continue; + if (newestBackup == null || child.lastModified() > newestBackup.lastModified()) { + newestBackup = child; + } + } + + if (!destination.exists() && newestBackup != null) { + if (!newestBackup.renameTo(destination)) { + throw new IOException("Unable to restore previous plugin installation"); + } + } + + for (File child : children) { + if (child.isDirectory() && child.getName().startsWith(backupPrefix)) { + deleteRecursively(child); + } + } + } + + private static void deleteRecursively(File file) { + if (file == null || !file.exists()) return; + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + } + file.delete(); + } + private String getPluginIdFromToken(String token) { for (Map.Entry entry : tokenStore.entrySet()) {