Skip to content

Instantly share code, notes, and snippets.

@alexgleason
Created September 9, 2025 17:18
Show Gist options
  • Select an option

  • Save alexgleason/9f53957210a5af9588ebb7a817c7c53e to your computer and use it in GitHub Desktop.

Select an option

Save alexgleason/9f53957210a5af9588ebb7a817c7c53e to your computer and use it in GitHub Desktop.
safezip
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