@@ -173,21 +173,49 @@ export default async function installPlugin(
173173 await fsOperation ( PLUGIN_DIR ) . createDirectory ( id ) ;
174174 }
175175
176+ // Track unsafe absolute entries to skip
177+ const ignoredUnsafeEntries = new Set ( ) ;
178+
176179 const promises = Object . keys ( zip . files ) . map ( async ( file ) => {
177180 try {
178181 let correctFile = file ;
179182 if ( / \\ / . test ( correctFile ) ) {
180183 correctFile = correctFile . replace ( / \\ / g, "/" ) ;
181184 }
182185
186+ // Determine if the zip entry is a directory from JSZip metadata
187+ const isDirEntry = ! ! zip . files [ file ] . dir || / \/ $ / . test ( correctFile ) ;
188+
189+ // If the original path is absolute or otherwise unsafe, skip it and warn later
190+ console . log (
191+ `Skipping unsafe path: ${ file } : ${ isUnsafeAbsolutePath ( file ) } ` ,
192+ ) ;
193+ if ( isUnsafeAbsolutePath ( file ) ) {
194+ ignoredUnsafeEntries . add ( file ) ;
195+ return ;
196+ }
197+
198+ // Sanitize path so it cannot escape pluginDir or start with '/'
199+ correctFile = sanitizeZipPath ( correctFile , isDirEntry ) ;
200+ if ( ! correctFile ) return ; // nothing to do
183201 const fileUrl = Url . join ( pluginDir , correctFile ) ;
184202
185- if ( ! state . exists ( correctFile ) ) {
186- await createFileRecursive ( pluginDir , correctFile ) ;
203+ // Always ensure directories exist for dir entries
204+ if ( isDirEntry ) {
205+ await createFileRecursive ( pluginDir , correctFile , true ) ;
206+ return ;
207+ }
208+
209+ // For files, ensure parent directory exists even if state claims it exists
210+ const lastSlash = correctFile . lastIndexOf ( "/" ) ;
211+ if ( lastSlash >= 0 ) {
212+ const parentRel = correctFile . slice ( 0 , lastSlash + 1 ) ;
213+ await createFileRecursive ( pluginDir , parentRel , true ) ;
187214 }
188215
189- // Skip directories
190- if ( correctFile . endsWith ( "/" ) ) return ;
216+ if ( ! state . exists ( correctFile ) ) {
217+ await createFileRecursive ( pluginDir , correctFile , false ) ;
218+ }
191219
192220 let data = await zip . files [ file ] . async ( "ArrayBuffer" ) ;
193221
@@ -206,6 +234,20 @@ export default async function installPlugin(
206234 // Wait for all files to be processed
207235 await Promise . allSettled ( promises ) ;
208236
237+ // Emit a non-blocking warning if any unsafe entries were skipped
238+ if ( ! isDependency && ignoredUnsafeEntries . size ) {
239+ const sample = Array . from ( ignoredUnsafeEntries ) . slice ( 0 , 3 ) . join ( ", " ) ;
240+ loaderDialog . setMessage (
241+ `Skipped ${ ignoredUnsafeEntries . size } unsafe archive entr${
242+ ignoredUnsafeEntries . size === 1 ? "y" : "ies"
243+ } (e.g., ${ sample } )`,
244+ ) ;
245+ console . warn (
246+ "Plugin installer: skipped unsafe absolute paths in archive:" ,
247+ Array . from ( ignoredUnsafeEntries ) ,
248+ ) ;
249+ }
250+
209251 if ( isDependency ) {
210252 depsLoaders . push ( async ( ) => {
211253 await loadPlugin ( id , true ) ;
@@ -245,28 +287,105 @@ export default async function installPlugin(
245287 * @param {string } parent
246288 * @param {Array<string> | string } dir
247289 */
248- async function createFileRecursive ( parent , dir ) {
249- let isDir = false ;
290+ async function createFileRecursive ( parent , dir , shouldBeDirAtEnd ) {
291+ let wantDirEnd = ! ! shouldBeDirAtEnd ;
292+ /** @type {string[] } */
293+ let parts ;
250294 if ( typeof dir === "string" ) {
251- if ( dir . endsWith ( "/" ) ) {
252- isDir = true ;
253- dir = dir . slice ( 0 , - 1 ) ;
254- }
255- dir = dir . split ( "/" ) ;
295+ if ( dir . endsWith ( "/" ) ) wantDirEnd = true ;
296+ dir = dir . replace ( / \\ / g , "/" ) ;
297+ parts = dir . split ( "/" ) ;
298+ } else {
299+ parts = dir ;
256300 }
257- dir = dir . filter ( ( d ) => d ) ;
258- const cd = dir . shift ( ) ;
301+ parts = parts . filter ( ( d ) => d ) ;
302+ const cd = parts . shift ( ) ;
303+ if ( ! cd ) return ;
259304 const newParent = Url . join ( parent , cd ) ;
305+
306+ const isLast = parts . length === 0 ;
307+ const needDir = ! isLast || wantDirEnd ;
260308 if ( ! ( await fsOperation ( newParent ) . exists ( ) ) ) {
261- if ( dir . length || isDir ) {
262- await fsOperation ( parent ) . createDirectory ( cd ) ;
309+ if ( needDir ) {
310+ try {
311+ await fsOperation ( parent ) . createDirectory ( cd ) ;
312+ } catch ( e ) {
313+ // If another concurrent task created it, consider it fine
314+ if ( ! ( await fsOperation ( newParent ) . exists ( ) ) ) throw e ;
315+ }
263316 } else {
264- await fsOperation ( parent ) . createFile ( cd ) ;
317+ try {
318+ await fsOperation ( parent ) . createFile ( cd ) ;
319+ } catch ( e ) {
320+ if ( ! ( await fsOperation ( newParent ) . exists ( ) ) ) throw e ;
321+ }
322+ }
323+ }
324+ if ( parts . length ) {
325+ await createFileRecursive ( newParent , parts , wantDirEnd ) ;
326+ }
327+ }
328+
329+ /**
330+ * Sanitize zip entry path to ensure it's relative and safe under pluginDir
331+ * - Normalizes separators to '/'
332+ * - Strips leading slashes and Windows drive prefixes (e.g., C:/)
333+ * - Resolves '.' and '..' segments
334+ * - Preserves trailing slash for directory entries
335+ * @param {string } p
336+ * @param {boolean } isDir
337+ * @returns {string } sanitized relative path
338+ */
339+ function sanitizeZipPath ( p , isDir ) {
340+ if ( ! p ) return "" ;
341+ let path = String ( p ) ;
342+ // Normalize separators
343+ path = path . replace ( / \\ / g, "/" ) ;
344+ // Remove URL-like scheme if present accidentally
345+ path = path . replace ( / ^ [ a - z A - Z ] + : \/ \/ / , "" ) ;
346+ // Strip leading slashes
347+ path = path . replace ( / ^ \/ + / , "" ) ;
348+ // Strip Windows drive letter, e.g., C:/
349+ path = path . replace ( / ^ [ A - Z a - z ] : \/ / , "" ) ;
350+
351+ const parts = path . split ( "/" ) ;
352+ const stack = [ ] ;
353+ for ( const part of parts ) {
354+ if ( ! part || part === "." ) continue ;
355+ if ( part === ".." ) {
356+ if ( stack . length ) stack . pop ( ) ;
357+ continue ;
265358 }
359+ stack . push ( part ) ;
266360 }
267- if ( dir . length ) {
268- await createFileRecursive ( newParent , dir ) ;
361+ let safe = stack . join ( "/" ) ;
362+ if ( isDir && safe && ! safe . endsWith ( "/" ) ) safe += "/" ;
363+ return safe ;
364+ }
365+
366+ /**
367+ * Detects unsafe absolute paths in zip entries that should be ignored.
368+ * Treats leading '/' as absolute, Windows drive roots like 'C:/' as absolute,
369+ * and common Android/Linux device roots like '/data', '/root', '/system'.
370+ * @param {string } p
371+ */
372+ function isUnsafeAbsolutePath ( p ) {
373+ if ( ! p ) return false ;
374+ const s = String ( p ) ;
375+ if ( / ^ [ A - Z a - z ] : [ \\ \/ ] / . test ( s ) ) return true ; // Windows drive root
376+ if ( s . startsWith ( "//" ) ) return true ; // network path
377+ if ( s . startsWith ( "/" ) ) {
378+ return (
379+ s . startsWith ( "/data" ) ||
380+ s . startsWith ( "/system" ) ||
381+ s . startsWith ( "/vendor" ) ||
382+ s . startsWith ( "/storage" ) ||
383+ s . startsWith ( "/sdcard" ) ||
384+ s . startsWith ( "/root" ) ||
385+ true // any leading slash is unsafe
386+ ) ;
269387 }
388+ return false ;
270389}
271390
272391/**
0 commit comments