Coverage for src/fileaudit/zip_check.py: 35%

155 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-11 12:11 +0200

1""" 

2License GPL3 

3(C) 2026 Created by Maikel Mardjan - https://nocomplexity.com/ 

4FileAudit - ZIP File Security Checker 

5""" 

6import inspect 

7import stat 

8import zipfile 

9from functools import wraps 

10from pathlib import Path 

11 

12 

13# --------------------------------------------------------------------------- 

14# Defaults 

15# --------------------------------------------------------------------------- 

16 

17DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MiB 

18DEFAULT_MAX_UNCOMPRESSED_RATIO = 100 # 100:1 

19DEFAULT_MAX_ZIP_MEMBERS = 10_000 

20DEFAULT_MAX_TOTAL_EXTRACTED_SIZE = 1 * 1024 * 1024**3 # 1 GiB 

21DEFAULT_MAX_INDIVIDUAL_FILE_SIZE = 100 * 1024 * 1024 # 100 MiB 

22DEFAULT_MAX_FILENAME_LENGTH = 255 

23DEFAULT_MAX_DIRECTORY_DEPTH = 20 

24 

25 

26# --------------------------------------------------------------------------- 

27# Exception 

28# --------------------------------------------------------------------------- 

29 

30class ZipValidationError(Exception): 

31 """Raised when a ZIP archive fails security validation.""" 

32 

33 

34# --------------------------------------------------------------------------- 

35# ZIP validation implementation 

36# --------------------------------------------------------------------------- 

37 

38def _validate_zip_file( 

39 path, 

40 max_file_size, 

41 max_uncompressed_ratio, 

42 max_zip_members, 

43 max_total_extracted_size, 

44 max_individual_file_size, 

45 max_filename_length, 

46 max_directory_depth, 

47): 

48 """ 

49 Validate a ZIP archive without extracting it. 

50 

51 Raises: 

52 ZipValidationError: If the archive fails any security check. 

53 """ 

54 

55 path = Path(path) 

56 

57 if not path.exists(): 

58 raise ZipValidationError(f"ZIP file does not exist: {path}") 

59 

60 if not path.is_file(): 

61 raise ZipValidationError(f"ZIP path is not a regular file: {path}") 

62 

63 # ----------------------------------------------------------------------- 

64 # Compressed archive size 

65 # ----------------------------------------------------------------------- 

66 

67 try: 

68 file_size = path.stat().st_size 

69 except OSError as e: 

70 raise ZipValidationError( 

71 f"Unable to stat ZIP file '{path}': {e}" 

72 ) from e 

73 

74 if file_size > max_file_size: 

75 raise ZipValidationError( 

76 f"ZIP file is too large: {file_size} bytes " 

77 f"(maximum {max_file_size})" 

78 ) 

79 

80 # ----------------------------------------------------------------------- 

81 # Open archive 

82 # ----------------------------------------------------------------------- 

83 

84 try: 

85 with zipfile.ZipFile(path, "r") as zf: #NOSEC --This is a security check(er)!! 

86 

87 # A ZIP central directory can contain many entries even when the 

88 # archive itself is relatively small. 

89 infos = zf.infolist() 

90 

91 if len(infos) > max_zip_members: 

92 raise ZipValidationError( 

93 f"ZIP contains too many members: {len(infos)} " 

94 f"(maximum {max_zip_members})" 

95 ) 

96 

97 total_uncompressed_size = 0 

98 seen_names = set() 

99 

100 for info in infos: 

101 filename = info.filename 

102 

103 # ----------------------------------------------------------- 

104 # Basic filename validation 

105 # ----------------------------------------------------------- 

106 

107 if not filename: 

108 raise ZipValidationError( 

109 "ZIP contains an entry with an empty filename" 

110 ) 

111 

112 if "\x00" in filename: 

113 raise ZipValidationError( 

114 f"ZIP entry contains a NUL byte: {filename!r}" 

115 ) 

116 

117 # ZIP uses '/' internally, but '\' can become a path 

118 # separator when the archive is later processed on Windows. 

119 if "\\" in filename: 

120 raise ZipValidationError( 

121 f"ZIP entry contains a backslash in its path: " 

122 f"{filename!r}" 

123 ) 

124 

125 # Measure encoded filename length rather than Python's 

126 # character count. This is closer to filesystem limits. 

127 filename_length = len(filename.encode("utf-8")) 

128 

129 if filename_length > max_filename_length: 

130 raise ZipValidationError( 

131 f"ZIP entry filename is too long: " 

132 f"{filename_length} bytes " 

133 f"(maximum {max_filename_length}): {filename!r}" 

134 ) 

135 

136 # ----------------------------------------------------------- 

137 # Duplicate names 

138 # ----------------------------------------------------------- 

139 

140 # Duplicate names can cause different extraction libraries 

141 # to behave differently (first wins / last wins / overwrite). 

142 if filename in seen_names: 

143 raise ZipValidationError( 

144 f"ZIP contains duplicate filename: {filename!r}" 

145 ) 

146 

147 seen_names.add(filename) 

148 

149 # ----------------------------------------------------------- 

150 # Path traversal protection 

151 # ----------------------------------------------------------- 

152 

153 # Reject POSIX absolute paths. 

154 if filename.startswith("/"): 

155 raise ZipValidationError( 

156 f"ZIP contains an absolute path: {filename!r}" 

157 ) 

158 

159 # Reject Windows drive-letter paths such as C:/foo. 

160 if ( 

161 len(filename) >= 2 

162 and filename[1] == ":" 

163 and filename[0].isalpha() 

164 ): 

165 raise ZipValidationError( 

166 f"ZIP contains a Windows drive path: {filename!r}" 

167 ) 

168 

169 # Reject UNC-like paths. 

170 if filename.startswith("//"): 

171 raise ZipValidationError( 

172 f"ZIP contains a UNC-style path: {filename!r}" 

173 ) 

174 

175 parts = filename.split("/") 

176 

177 # Reject '.', '..', and empty path components where they 

178 # could result in ambiguous filesystem paths. 

179 for part in parts: 

180 if part == "..": 

181 raise ZipValidationError( 

182 f"ZIP contains a path traversal component: " 

183 f"{filename!r}" 

184 ) 

185 

186 # ----------------------------------------------------------- 

187 # Directory depth 

188 # ----------------------------------------------------------- 

189 

190 # Ignore the final component when it is the filename. 

191 is_directory = filename.endswith("/") 

192 

193 depth_parts = [ 

194 p for p in parts 

195 if p not in ("", ".") 

196 ] 

197 

198 depth = len(depth_parts) 

199 

200 if not is_directory and depth > 0: 

201 depth -= 1 

202 

203 if depth > max_directory_depth: 

204 raise ZipValidationError( 

205 f"ZIP entry exceeds maximum directory depth: " 

206 f"{filename!r} " 

207 f"(depth {depth}, maximum {max_directory_depth})" 

208 ) 

209 

210 # ----------------------------------------------------------- 

211 # ZIP member type / symlink protection 

212 # ----------------------------------------------------------- 

213 

214 # Unix external attributes contain the file mode in the 

215 # upper 16 bits. If present, reject symbolic links and 

216 # special files. 

217 unix_mode = (info.external_attr >> 16) & 0xFFFF 

218 

219 if unix_mode: 

220 file_type = stat.S_IFMT(unix_mode) 

221 

222 if file_type == stat.S_IFLNK: 

223 raise ZipValidationError( 

224 f"ZIP contains a symbolic link: {filename!r}" 

225 ) 

226 

227 if file_type not in (0, stat.S_IFREG, stat.S_IFDIR): 

228 raise ZipValidationError( 

229 f"ZIP contains an unsupported special file: " 

230 f"{filename!r}" 

231 ) 

232 

233 # ZIP's DOS directory attribute. 

234 dos_attributes = info.external_attr & 0xFFFF 

235 

236 if is_directory: 

237 is_directory = True 

238 elif unix_mode and stat.S_ISDIR(unix_mode): 

239 is_directory = True 

240 elif dos_attributes & 0x10: 

241 is_directory = True 

242 

243 # ----------------------------------------------------------- 

244 # Encryption 

245 # ----------------------------------------------------------- 

246 

247 # We cannot safely validate the contents of an encrypted 

248 # entry without a password. Reject encrypted entries. 

249 if info.flag_bits & 0x1: 

250 raise ZipValidationError( 

251 f"ZIP contains an encrypted entry: {filename!r}" 

252 ) 

253 

254 # ----------------------------------------------------------- 

255 # Compression method 

256 # ----------------------------------------------------------- 

257 

258 supported_methods = { 

259 zipfile.ZIP_STORED, 

260 zipfile.ZIP_DEFLATED, 

261 zipfile.ZIP_BZIP2, 

262 zipfile.ZIP_LZMA, 

263 } 

264 

265 if info.compress_type not in supported_methods: 

266 raise ZipValidationError( 

267 f"ZIP entry uses an unsupported compression method: " 

268 f"{filename!r}" 

269 ) 

270 

271 # ----------------------------------------------------------- 

272 # Size validation 

273 # ----------------------------------------------------------- 

274 

275 uncompressed_size = info.file_size 

276 compressed_size = info.compress_size 

277 

278 # Directory entries should not contain meaningful payload. 

279 if is_directory: 

280 if uncompressed_size > 0: 

281 raise ZipValidationError( 

282 f"Directory entry contains data: {filename!r}" 

283 ) 

284 continue 

285 

286 if uncompressed_size > max_individual_file_size: 

287 raise ZipValidationError( 

288 f"ZIP entry is too large: {filename!r} " 

289 f"({uncompressed_size} bytes, " 

290 f"maximum {max_individual_file_size})" 

291 ) 

292 

293 # Protect against ZIP bombs. A zero-byte compressed stream 

294 # cannot meaningfully have a finite compression ratio. 

295 if uncompressed_size > 0: 

296 if compressed_size == 0: 

297 raise ZipValidationError( 

298 f"ZIP entry has uncompressed data but zero " 

299 f"compressed size: {filename!r}" 

300 ) 

301 

302 ratio = uncompressed_size / compressed_size 

303 

304 if ratio > max_uncompressed_ratio: 

305 raise ZipValidationError( 

306 f"ZIP entry has an excessive compression ratio: " 

307 f"{filename!r} " 

308 f"({ratio:.2f}:1, maximum " 

309 f"{max_uncompressed_ratio}:1)" 

310 ) 

311 

312 # Check cumulative extracted size. 

313 total_uncompressed_size += uncompressed_size 

314 

315 if total_uncompressed_size > max_total_extracted_size: 

316 raise ZipValidationError( 

317 f"ZIP total uncompressed size is too large: " 

318 f"{total_uncompressed_size} bytes " 

319 f"(maximum {max_total_extracted_size})" 

320 ) 

321 

322 except ZipValidationError: 

323 raise 

324 

325 except zipfile.BadZipFile as e: 

326 raise ZipValidationError( 

327 f"Invalid or corrupt ZIP file: {e}" 

328 ) from e 

329 

330 except (OSError, EOFError, RuntimeError, ValueError) as e: 

331 raise ZipValidationError( 

332 f"Unable to validate ZIP file '{path}': {e}" 

333 ) from e 

334 

335 

336# --------------------------------------------------------------------------- 

337# Public API 

338# --------------------------------------------------------------------------- 

339 

340def validate_zip( 

341 func_or_path=None, 

342 max_file_size=None, 

343 max_uncompressed_ratio=None, 

344 max_zip_members=None, 

345 max_total_extracted_size=None, 

346 max_individual_file_size=None, 

347 max_filename_length=None, 

348 max_directory_depth=None, 

349): 

350 """ 

351 Validate ZIP files via decorator or direct invocation. 

352 

353 Supports: 

354 

355 1. Bare decorator:: 

356 

357 @validate_zip 

358 def process(path): 

359 ... 

360 

361 2. Decorator factory:: 

362 

363 @validate_zip() 

364 def process(path): 

365 ... 

366 

367 3. Named function argument:: 

368 

369 @validate_zip("zip_path") 

370 def process(zip_path): 

371 ... 

372 

373 4. Named argument with limits:: 

374 

375 @validate_zip( 

376 "zip_path", 

377 max_file_size=500 * 1024 * 1024, 

378 max_zip_members=5000, 

379 ) 

380 def process(zip_path): 

381 ... 

382 

383 5. Direct / CLI invocation:: 

384 

385 validate_zip("archive.zip") 

386 

387 Returns: 

388 In decorator mode: 

389 The decorated function. 

390 

391 In direct mode: 

392 True if validation succeeds, False otherwise. 

393 

394 Raises: 

395 ZipValidationError: 

396 If validation fails in decorator mode. 

397 """ 

398 

399 # Resolve optional limits to defaults. 

400 resolved_file_size = ( 

401 DEFAULT_MAX_FILE_SIZE 

402 if max_file_size is None 

403 else max_file_size 

404 ) 

405 

406 resolved_ratio = ( 

407 DEFAULT_MAX_UNCOMPRESSED_RATIO 

408 if max_uncompressed_ratio is None 

409 else max_uncompressed_ratio 

410 ) 

411 

412 resolved_members = ( 

413 DEFAULT_MAX_ZIP_MEMBERS 

414 if max_zip_members is None 

415 else max_zip_members 

416 ) 

417 

418 resolved_total_size = ( 

419 DEFAULT_MAX_TOTAL_EXTRACTED_SIZE 

420 if max_total_extracted_size is None 

421 else max_total_extracted_size 

422 ) 

423 

424 resolved_individual_size = ( 

425 DEFAULT_MAX_INDIVIDUAL_FILE_SIZE 

426 if max_individual_file_size is None 

427 else max_individual_file_size 

428 ) 

429 

430 resolved_filename_len = ( 

431 DEFAULT_MAX_FILENAME_LENGTH 

432 if max_filename_length is None 

433 else max_filename_length 

434 ) 

435 

436 resolved_depth = ( 

437 DEFAULT_MAX_DIRECTORY_DEPTH 

438 if max_directory_depth is None 

439 else max_directory_depth 

440 ) 

441 

442 # --------------------------------------------------------------- 

443 # Determine whether this is decorator or direct-call mode. 

444 # --------------------------------------------------------------- 

445 

446 is_decorator_mode = False 

447 

448 if func_or_path is None: 

449 is_decorator_mode = True 

450 

451 elif callable(func_or_path): 

452 # @validate_zip 

453 is_decorator_mode = True 

454 

455 elif isinstance(func_or_path, Path): 

456 # validate_zip(Path("archive.zip")) 

457 is_decorator_mode = False 

458 

459 elif isinstance(func_or_path, str): 459 ↛ 477line 459 didn't jump to line 477 because the condition on line 459 was always true

460 # A valid identifier is interpreted as a function argument name. 

461 # 

462 # Therefore: 

463 # @validate_zip("zip_path") 

464 # 

465 # means "validate the zip_path argument". 

466 # 

467 # While: 

468 # validate_zip("archive.zip") 

469 # 

470 # is direct-call mode. 

471 is_decorator_mode = func_or_path.isidentifier() 

472 

473 # --------------------------------------------------------------- 

474 # Direct call / CLI mode. 

475 # --------------------------------------------------------------- 

476 

477 if not is_decorator_mode and isinstance(func_or_path, (str, Path)): 

478 try: 

479 _validate_zip_file( 

480 func_or_path, 

481 resolved_file_size, 

482 resolved_ratio, 

483 resolved_members, 

484 resolved_total_size, 

485 resolved_individual_size, 

486 resolved_filename_len, 

487 resolved_depth, 

488 ) 

489 return True 

490 

491 except Exception as e: 

492 print(f"Exception: {e}") 

493 return False 

494 

495 # --------------------------------------------------------------- 

496 # Decorator mode. 

497 # --------------------------------------------------------------- 

498 

499 def decorator(f): 

500 sig = inspect.signature(f) 

501 params = list(sig.parameters.keys()) 

502 

503 if not params: 

504 raise ZipValidationError( 

505 f"Decorator applied to '{f.__name__}', " 

506 f"but it has no arguments." 

507 ) 

508 

509 # If the user supplied a valid argument name, use it. 

510 # Otherwise validate the first argument. 

511 if ( 

512 isinstance(func_or_path, str) 

513 and func_or_path in params 

514 ): 

515 target_arg = func_or_path 

516 else: 

517 target_arg = params[0] 

518 

519 @wraps(f) 

520 def wrapper(*args, **kwargs): 

521 try: 

522 bound_args = sig.bind(*args, **kwargs) 

523 bound_args.apply_defaults() 

524 

525 except TypeError as e: 

526 raise ZipValidationError( 

527 f"Invalid function call signature: {e}" 

528 ) from e 

529 

530 p = bound_args.arguments.get(target_arg) 

531 

532 if p is None: 

533 raise ZipValidationError( 

534 f"Missing required argument: {target_arg}" 

535 ) 

536 

537 if not isinstance(p, (str, Path)): 

538 raise ZipValidationError( 

539 f"Expected Path or str for {target_arg}, " 

540 f"got {type(p).__name__}" 

541 ) 

542 

543 _validate_zip_file( 

544 p, 

545 resolved_file_size, 

546 resolved_ratio, 

547 resolved_members, 

548 resolved_total_size, 

549 resolved_individual_size, 

550 resolved_filename_len, 

551 resolved_depth, 

552 ) 

553 

554 return f(*args, **kwargs) 

555 

556 return wrapper 

557 

558 if callable(func_or_path): 

559 return decorator(func_or_path) 

560 

561 return decorator