Created
September 9, 2025 17:18
-
-
Save alexgleason/9f53957210a5af9588ebb7a817c7c53e to your computer and use it in GitHub Desktop.
safezip
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { BlobReader, BlobWriter, ZipReader } from "@zip.js/zip.js"; | |
| /** | |
| * Configuration options for safe zip extraction | |
| */ | |
| export interface SafeZipOptions { | |
| /** Maximum zip file size in bytes (default: 100MB) */ | |
| maxZipSize?: number; | |
| /** Maximum number of entries in zip (default: 10,000) */ | |
| maxEntries?: number; | |
| /** Maximum total uncompressed size in bytes (default: 500MB) */ | |
| maxUncompressedSize?: number; | |
| /** Allowed file extensions (default: all allowed) */ | |
| allowedExtensions?: string[]; | |
| } | |
| /** | |
| * Result of safe zip extraction | |
| */ | |
| export interface SafeZipResult { | |
| success: boolean; | |
| error?: string; | |
| extractedFiles?: string[]; | |
| skippedFiles?: string[]; | |
| } | |
| /** | |
| * Sanitizes a zip entry path to prevent path traversal attacks | |
| * @param path - The path from the zip entry | |
| * @returns The sanitized path or null if the path is unsafe | |
| */ | |
| function sanitizeZipPath(path: string): string | null { | |
| // Remove any leading slashes | |
| const sanitized = path.replace(/^\/+/, ""); | |
| // Split into path components | |
| const parts = sanitized.split("/"); | |
| const safeParts: string[] = []; | |
| for (const part of parts) { | |
| // Skip empty parts and current directory references | |
| if (part === "" || part === ".") { | |
| continue; | |
| } | |
| // Handle parent directory references | |
| if (part === "..") { | |
| // Remove the last safe part if it exists (go up one level) | |
| if (safeParts.length > 0) { | |
| safeParts.pop(); | |
| } | |
| // If no parts to go up from, just skip this ".." | |
| continue; | |
| } | |
| // Check for other potentially dangerous characters | |
| if (part.includes("\0") || part.includes("\\")) { | |
| return null; | |
| } | |
| safeParts.push(part); | |
| } | |
| // If no safe parts remain, reject | |
| if (safeParts.length === 0) { | |
| return null; | |
| } | |
| return safeParts.join("/"); | |
| } | |
| /** | |
| * Checks if a file extension is allowed | |
| * @param filename - The filename to check | |
| * @param allowedExtensions - Array of allowed extensions (with dots, e.g., ['.html', '.css']) | |
| * @returns True if the extension is allowed or no restrictions are set | |
| */ | |
| function isExtensionAllowed( | |
| filename: string, | |
| allowedExtensions?: string[], | |
| ): boolean { | |
| if (!allowedExtensions || allowedExtensions.length === 0) { | |
| return true; | |
| } | |
| const extension = filename.substring(filename.lastIndexOf(".")).toLowerCase(); | |
| return allowedExtensions.includes(extension); | |
| } | |
| /** | |
| * Safely extracts a zip file to a target directory with security protections | |
| * @param zipFile - The zip file to extract (File object) | |
| * @param targetPath - The target directory path to extract to | |
| * @param options - Configuration options for extraction | |
| * @returns Promise<SafeZipResult> - Result of the extraction operation | |
| */ | |
| export async function safeUnzip( | |
| zipFile: File, | |
| targetPath: string, | |
| options: SafeZipOptions = {}, | |
| ): Promise<SafeZipResult> { | |
| const { | |
| maxZipSize = 100 * 1024 * 1024, // 100MB | |
| maxEntries = 10000, | |
| maxUncompressedSize = 500 * 1024 * 1024, // 500MB | |
| allowedExtensions, | |
| } = options; | |
| const extractedFiles: string[] = []; | |
| const skippedFiles: string[] = []; | |
| try { | |
| // Validate zip file size | |
| if (zipFile.size > maxZipSize) { | |
| return { | |
| success: false, | |
| error: `Zip file too large (max ${ | |
| Math.round(maxZipSize / 1024 / 1024) | |
| }MB)`, | |
| }; | |
| } | |
| // Ensure target directory exists | |
| await Deno.mkdir(targetPath, { recursive: true }); | |
| // Get absolute target path for security checks | |
| const absoluteTargetPath = await Deno.realPath(targetPath); | |
| // Extract zip file | |
| const zipArrayBuffer = await zipFile.arrayBuffer(); | |
| const zipReader = new ZipReader(new BlobReader(new Blob([zipArrayBuffer]))); | |
| const entries = await zipReader.getEntries(); | |
| // Validate number of entries | |
| if (entries.length > maxEntries) { | |
| await zipReader.close(); | |
| return { | |
| success: false, | |
| error: `Too many files in zip (max ${maxEntries})`, | |
| }; | |
| } | |
| // Calculate total uncompressed size | |
| let totalUncompressedSize = 0; | |
| for (const entry of entries) { | |
| if (!entry.filename.endsWith("/") && entry.uncompressedSize) { | |
| totalUncompressedSize += entry.uncompressedSize; | |
| } | |
| } | |
| if (totalUncompressedSize > maxUncompressedSize) { | |
| await zipReader.close(); | |
| return { | |
| success: false, | |
| error: `Uncompressed content too large (max ${ | |
| Math.round(maxUncompressedSize / 1024 / 1024) | |
| }MB)`, | |
| }; | |
| } | |
| // Extract files | |
| for (const entry of entries) { | |
| if (!entry.filename.endsWith("/")) { | |
| // It's a file - sanitize the filename to prevent path traversal | |
| const sanitizedFilename = sanitizeZipPath(entry.filename); | |
| if (!sanitizedFilename) { | |
| console.warn(`Skipping unsafe path in zip: ${entry.filename}`); | |
| skippedFiles.push(entry.filename); | |
| continue; | |
| } | |
| // Check file extension if restrictions are set | |
| if (!isExtensionAllowed(sanitizedFilename, allowedExtensions)) { | |
| console.warn( | |
| `Skipping file with disallowed extension: ${entry.filename}`, | |
| ); | |
| skippedFiles.push(entry.filename); | |
| continue; | |
| } | |
| const filePath = `${targetPath}/${sanitizedFilename}`; | |
| // Verify the resolved path is within the target directory | |
| let absoluteFilePath: string; | |
| try { | |
| // Get the directory of the file to create it first | |
| const dirPath = filePath.substring(0, filePath.lastIndexOf("/")); | |
| if (dirPath !== targetPath) { | |
| await Deno.mkdir(dirPath, { recursive: true }); | |
| } | |
| // Now get the absolute path of where the file will be created | |
| const fileDir = await Deno.realPath(dirPath); | |
| const fileName = filePath.substring(filePath.lastIndexOf("/") + 1); | |
| absoluteFilePath = `${fileDir}/${fileName}`; | |
| } catch (error) { | |
| console.warn(`Failed to resolve path for ${filePath}: ${error}`); | |
| skippedFiles.push(entry.filename); | |
| continue; | |
| } | |
| // Security check: ensure the file will be created within the target directory | |
| if ( | |
| !absoluteFilePath.startsWith(absoluteTargetPath + "/") && | |
| absoluteFilePath !== absoluteTargetPath | |
| ) { | |
| console.warn( | |
| `Path traversal attempt detected: ${entry.filename} -> ${absoluteFilePath}`, | |
| ); | |
| skippedFiles.push(entry.filename); | |
| continue; | |
| } | |
| // Extract file | |
| if (entry.getData) { | |
| try { | |
| const fileData = await entry.getData(new BlobWriter()); | |
| const arrayBuffer = await fileData.arrayBuffer(); | |
| await Deno.writeFile(filePath, new Uint8Array(arrayBuffer)); | |
| extractedFiles.push(sanitizedFilename); | |
| } catch (error) { | |
| console.warn(`Failed to extract file ${entry.filename}: ${error}`); | |
| skippedFiles.push(entry.filename); | |
| } | |
| } | |
| } | |
| } | |
| await zipReader.close(); | |
| return { | |
| success: true, | |
| extractedFiles, | |
| skippedFiles, | |
| }; | |
| } catch (error) { | |
| console.error("Safe unzip error:", error); | |
| return { | |
| success: false, | |
| error: error instanceof Error | |
| ? error.message | |
| : "Unknown error during extraction", | |
| }; | |
| } | |
| } | |
| /** | |
| * Validates a zip file without extracting it | |
| * @param zipFile - The zip file to validate | |
| * @param options - Configuration options for validation | |
| * @returns Promise<SafeZipResult> - Result of the validation | |
| */ | |
| export async function validateZip( | |
| zipFile: File, | |
| options: SafeZipOptions = {}, | |
| ): Promise<SafeZipResult> { | |
| const { | |
| maxZipSize = 100 * 1024 * 1024, // 100MB | |
| maxEntries = 10000, | |
| maxUncompressedSize = 500 * 1024 * 1024, // 500MB | |
| allowedExtensions, | |
| } = options; | |
| try { | |
| // Validate zip file size | |
| if (zipFile.size > maxZipSize) { | |
| return { | |
| success: false, | |
| error: `Zip file too large (max ${ | |
| Math.round(maxZipSize / 1024 / 1024) | |
| }MB)`, | |
| }; | |
| } | |
| // Read zip file | |
| const zipArrayBuffer = await zipFile.arrayBuffer(); | |
| const zipReader = new ZipReader(new BlobReader(new Blob([zipArrayBuffer]))); | |
| const entries = await zipReader.getEntries(); | |
| // Validate number of entries | |
| if (entries.length > maxEntries) { | |
| await zipReader.close(); | |
| return { | |
| success: false, | |
| error: `Too many files in zip (max ${maxEntries})`, | |
| }; | |
| } | |
| // Calculate total uncompressed size and validate paths | |
| let totalUncompressedSize = 0; | |
| const validFiles: string[] = []; | |
| const invalidFiles: string[] = []; | |
| for (const entry of entries) { | |
| if (!entry.filename.endsWith("/")) { | |
| if (entry.uncompressedSize) { | |
| totalUncompressedSize += entry.uncompressedSize; | |
| } | |
| // Check if path is safe | |
| const sanitizedFilename = sanitizeZipPath(entry.filename); | |
| if (!sanitizedFilename) { | |
| invalidFiles.push(entry.filename); | |
| continue; | |
| } | |
| // Check file extension if restrictions are set | |
| if (!isExtensionAllowed(sanitizedFilename, allowedExtensions)) { | |
| invalidFiles.push(entry.filename); | |
| continue; | |
| } | |
| validFiles.push(sanitizedFilename); | |
| } | |
| } | |
| if (totalUncompressedSize > maxUncompressedSize) { | |
| await zipReader.close(); | |
| return { | |
| success: false, | |
| error: `Uncompressed content too large (max ${ | |
| Math.round(maxUncompressedSize / 1024 / 1024) | |
| }MB)`, | |
| }; | |
| } | |
| await zipReader.close(); | |
| return { | |
| success: true, | |
| extractedFiles: validFiles, | |
| skippedFiles: invalidFiles, | |
| }; | |
| } catch (error) { | |
| console.error("Zip validation error:", error); | |
| return { | |
| success: false, | |
| error: error instanceof Error | |
| ? error.message | |
| : "Unknown error during validation", | |
| }; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment