File size: 7,704 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 | import { isAbsolute, normalize } from 'path'
import { logForDebugging } from '../debug.js'
import { isENOENT } from '../errors.js'
import { getFsImplementation } from '../fsOperations.js'
import { containsPathTraversal } from '../path.js'
const LIMITS = {
MAX_FILE_SIZE: 512 * 1024 * 1024, // 512MB per file
MAX_TOTAL_SIZE: 1024 * 1024 * 1024, // 1024MB total uncompressed
MAX_FILE_COUNT: 100000, // Maximum number of files
MAX_COMPRESSION_RATIO: 50, // Anything above 50:1 is suspicious
MIN_COMPRESSION_RATIO: 0.5, // Below 0.5:1 might indicate already compressed malicious content
}
/**
* State tracker for zip file validation during extraction
*/
type ZipValidationState = {
fileCount: number
totalUncompressedSize: number
compressedSize: number
errors: string[]
}
/**
* File metadata from fflate filter
*/
type ZipFileMetadata = {
name: string
originalSize?: number
}
/**
* Result of validating a single file in a zip archive
*/
type FileValidationResult = {
isValid: boolean
error?: string
}
/**
* Validates a file path to prevent path traversal attacks
*/
export function isPathSafe(filePath: string): boolean {
if (containsPathTraversal(filePath)) {
return false
}
// Normalize the path to resolve any '.' segments
const normalized = normalize(filePath)
// Check for absolute paths (we only want relative paths in archives)
if (isAbsolute(normalized)) {
return false
}
return true
}
/**
* Validates a single file during zip extraction
*/
export function validateZipFile(
file: ZipFileMetadata,
state: ZipValidationState,
): FileValidationResult {
state.fileCount++
let error: string | undefined
// Check file count
if (state.fileCount > LIMITS.MAX_FILE_COUNT) {
error = `Archive contains too many files: ${state.fileCount} (max: ${LIMITS.MAX_FILE_COUNT})`
}
// Validate path safety
if (!isPathSafe(file.name)) {
error = `Unsafe file path detected: "${file.name}". Path traversal or absolute paths are not allowed.`
}
// Check individual file size
const fileSize = file.originalSize || 0
if (fileSize > LIMITS.MAX_FILE_SIZE) {
error = `File "${file.name}" is too large: ${Math.round(fileSize / 1024 / 1024)}MB (max: ${Math.round(LIMITS.MAX_FILE_SIZE / 1024 / 1024)}MB)`
}
// Track total uncompressed size
state.totalUncompressedSize += fileSize
// Check total size
if (state.totalUncompressedSize > LIMITS.MAX_TOTAL_SIZE) {
error = `Archive total size is too large: ${Math.round(state.totalUncompressedSize / 1024 / 1024)}MB (max: ${Math.round(LIMITS.MAX_TOTAL_SIZE / 1024 / 1024)}MB)`
}
// Check compression ratio for zip bomb detection
const currentRatio = state.totalUncompressedSize / state.compressedSize
if (currentRatio > LIMITS.MAX_COMPRESSION_RATIO) {
error = `Suspicious compression ratio detected: ${currentRatio.toFixed(1)}:1 (max: ${LIMITS.MAX_COMPRESSION_RATIO}:1). This may be a zip bomb.`
}
return error ? { isValid: false, error } : { isValid: true }
}
/**
* Unzips data from a Buffer and returns its contents as a record of file paths to Uint8Array data.
* Uses unzipSync to avoid fflate worker termination crashes in bun.
* Accepts raw zip bytes so that the caller can read the file asynchronously.
*
* fflate is lazy-imported to avoid its ~196KB of top-level lookup tables (revfd
* Int32Array(32769), rev Uint16Array(32768), etc.) being allocated at startup
* when this module is reached via the plugin loader chain.
*/
export async function unzipFile(
zipData: Buffer,
): Promise<Record<string, Uint8Array>> {
const { unzipSync } = await import('fflate')
const compressedSize = zipData.length
const state: ZipValidationState = {
fileCount: 0,
totalUncompressedSize: 0,
compressedSize: compressedSize,
errors: [],
}
const result = unzipSync(new Uint8Array(zipData), {
filter: file => {
const validationResult = validateZipFile(file, state)
if (!validationResult.isValid) {
throw new Error(validationResult.error!)
}
return true
},
})
logForDebugging(
`Zip extraction completed: ${state.fileCount} files, ${Math.round(state.totalUncompressedSize / 1024)}KB uncompressed`,
)
return result
}
/**
* Parse Unix file modes from a zip's central directory.
*
* fflate's `unzipSync` returns only `Record<string, Uint8Array>` — it does not
* surface the external file attributes stored in the central directory. This
* means executable bits are lost during extraction (everything becomes 0644).
* The git-clone path preserves +x natively, but the GCS/zip path needs this
* helper to keep parity.
*
* Returns `name → mode` for entries created on a Unix host (`versionMadeBy`
* high byte === 3). Entries from other hosts, or with no mode bits set, are
* omitted. Callers should treat a missing key as "use default mode".
*
* Format per PKZIP APPNOTE.TXT §4.3.12 (central directory) and §4.3.16 (EOCD).
* ZIP64 is not handled — returns `{}` on archives >4GB or >65535 entries,
* which is fine for marketplace zips (~3.5MB) and MCPB bundles.
*/
export function parseZipModes(data: Uint8Array): Record<string, number> {
// Buffer view for readUInt* methods — shares memory, no copy.
const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength)
const modes: Record<string, number> = {}
// 1. Find the End of Central Directory record (sig 0x06054b50). It lives in
// the trailing 22 + 65535 bytes (fixed EOCD size + max comment length).
// Scan backwards — the EOCD is typically the last 22 bytes.
const minEocd = Math.max(0, buf.length - 22 - 0xffff)
let eocd = -1
for (let i = buf.length - 22; i >= minEocd; i--) {
if (buf.readUInt32LE(i) === 0x06054b50) {
eocd = i
break
}
}
if (eocd < 0) return modes // malformed — let fflate's error surface elsewhere
const entryCount = buf.readUInt16LE(eocd + 10)
let off = buf.readUInt32LE(eocd + 16) // central directory start offset
// 2. Walk central directory entries (sig 0x02014b50). Each entry has a
// 46-byte fixed header followed by variable-length name/extra/comment.
for (let i = 0; i < entryCount; i++) {
if (off + 46 > buf.length || buf.readUInt32LE(off) !== 0x02014b50) break
const versionMadeBy = buf.readUInt16LE(off + 4)
const nameLen = buf.readUInt16LE(off + 28)
const extraLen = buf.readUInt16LE(off + 30)
const commentLen = buf.readUInt16LE(off + 32)
const externalAttr = buf.readUInt32LE(off + 38)
const name = buf.toString('utf8', off + 46, off + 46 + nameLen)
// versionMadeBy high byte = host OS. 3 = Unix. For Unix zips, the high
// 16 bits of externalAttr hold st_mode (file type + permission bits).
if (versionMadeBy >> 8 === 3) {
const mode = (externalAttr >>> 16) & 0xffff
if (mode) modes[name] = mode
}
off += 46 + nameLen + extraLen + commentLen
}
return modes
}
/**
* Reads a zip file from disk asynchronously and unzips it.
* Returns its contents as a record of file paths to Uint8Array data.
*/
export async function readAndUnzipFile(
filePath: string,
): Promise<Record<string, Uint8Array>> {
const fs = getFsImplementation()
try {
const zipData = await fs.readFileBytes(filePath)
// await is required here: without it, rejections from the now-async
// unzipFile() escape the try/catch and bypass the error wrapping below.
return await unzipFile(zipData)
} catch (error) {
if (isENOENT(error)) {
throw error
}
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(`Failed to read or unzip file: ${errorMessage}`)
}
}
|