Coverage for src/taipanstack/security/sanitizers.py: 100%

222 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-10-13 20:52 +0000

1""" 

2Input sanitizers for cleaning untrusted data. 

3 

4Provides functions to sanitize strings, filenames, and paths 

5to remove potentially dangerous characters. 

6""" 

7 

8import os 

9import re 

10from pathlib import Path 

11 

12# Constants to avoid magic values (PLR2004) 

13MAX_PATH_LENGTH = 4096 # pragma: no mutate 

14MAX_STRING_LENGTH = 1000000 # pragma: no mutate 

15 

16# Pre-compiled regex and sets for Performance Benchmarks 

17_INVALID_FILENAME_CHARS_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]') # pragma: no mutate 

18_HTML_TAGS_RE = re.compile(r"<[^>]+>") # pragma: no mutate 

19# Remove control characters (C0 and C1 sets) 

20_CONTROL_CHARS_RE = re.compile( 

21 r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", 

22) # pragma: no mutate 

23_WINDOWS_RESERVED_NAMES = frozenset( # pragma: no mutate 

24 { 

25 "CON", 

26 "PRN", 

27 "AUX", 

28 "NUL", 

29 "COM1", 

30 "COM2", 

31 "COM3", 

32 "COM4", 

33 "COM5", 

34 "COM6", 

35 "COM7", 

36 "COM8", 

37 "COM9", 

38 "LPT1", 

39 "LPT2", 

40 "LPT3", 

41 "LPT4", 

42 "LPT5", 

43 "LPT6", 

44 "LPT7", 

45 "LPT8", 

46 "LPT9", 

47 }, 

48) 

49 

50 

51def _handle_html(result: str, allow_html: bool) -> str: 

52 """Remove HTML tags and escape entities if not allowed.""" 

53 if allow_html: 

54 return result 

55 result = _HTML_TAGS_RE.sub("", result) 

56 result = result.replace("&", "&amp;") 

57 result = result.replace("<", "&lt;") 

58 return result.replace(">", "&gt;") 

59 

60 

61def _handle_unicode(result: str, allow_unicode: bool) -> str: 

62 """Filter out non-ASCII characters if unicode is not allowed.""" 

63 if allow_unicode: 

64 return result 

65 return result.encode("ascii", errors="ignore").decode("ascii") 

66 

67 

68def _check_string_length(value: str) -> None: 

69 if len(value) > MAX_STRING_LENGTH: 

70 raise ValueError("String length exceeds maximum allowed limit") 

71 

72 

73def _check_max_length_param(max_length: int | None) -> None: 

74 if max_length is not None and max_length < 0: 

75 raise ValueError("max_length cannot be negative") 

76 

77 

78def _is_valid_max_length_type(max_length: int | None) -> bool: 

79 """Check if max_length is of valid integer type.""" 

80 if max_length is None: 

81 return True 

82 return isinstance(max_length, int) and not isinstance(max_length, bool) 

83 

84 

85def _validate_string_params(value: str, max_length: int | None) -> None: 

86 """Validate parameters for sanitize_string.""" 

87 if not isinstance(value, str): 

88 raise TypeError(f"value must be str, got {type(value).__name__}") 

89 

90 if not _is_valid_max_length_type(max_length): 

91 raise TypeError(f"max_length must be int, got {type(max_length).__name__}") 

92 

93 _check_string_length(value) 

94 _check_max_length_param(max_length) 

95 

96 

97def _enforce_max_length(value: str, max_length: int | None) -> str: 

98 if max_length is not None and len(value) > max_length: 

99 return value[:max_length] 

100 return value 

101 

102 

103def sanitize_string( 

104 value: str, 

105 *, 

106 max_length: int | None = None, 

107 allow_html: bool = False, 

108 allow_unicode: bool = True, 

109 strip_whitespace: bool = True, 

110) -> str: 

111 """Sanitize a string by removing dangerous characters. 

112 

113 Args: 

114 value: The string to sanitize. 

115 max_length: Maximum length to truncate to. 

116 allow_html: Whether to keep HTML tags (default: False). 

117 allow_unicode: Whether to keep non-ASCII characters. 

118 strip_whitespace: Whether to strip leading/trailing whitespace. 

119 

120 Returns: 

121 The sanitized string. 

122 

123 Example: 

124 ```python 

125 sanitize_string("<script>alert('xss')</script>Hello") 

126 # Returns: "scriptalert('xss')/scriptHello" 

127 ``` 

128 

129 """ 

130 _validate_string_params(value, max_length) 

131 

132 if not value: 

133 return "" 

134 

135 result = value.strip() if strip_whitespace else value 

136 result = _CONTROL_CHARS_RE.sub("", result) 

137 result = _handle_html(result, allow_html) 

138 result = _handle_unicode(result, allow_unicode) 

139 

140 return _enforce_max_length(result, max_length) 

141 

142 

143def _get_filename_from_path(filename: str) -> str: 

144 """Extract the base filename from a full path.""" 

145 slash_idx = max(filename.rfind("/"), filename.rfind("\\")) 

146 if slash_idx >= 0: 

147 return filename[slash_idx + 1 :] 

148 return filename 

149 

150 

151def _has_valid_extension(name: str, idx: int) -> bool: 

152 """Determine if a dot represents a valid extension.""" 

153 return idx > 0 and not all(c == "." for c in name) and name != ".." 

154 

155 

156def _extract_stem_and_suffix( 

157 filename: str, 

158 preserve_extension: bool, 

159) -> tuple[str, str]: 

160 """Extract stem and suffix from a filename.""" 

161 name = _get_filename_from_path(filename) 

162 idx = name.rfind(".") 

163 

164 if _has_valid_extension(name, idx): 

165 stem = name[:idx] 

166 suffix = name[idx:] if preserve_extension else "" 

167 return stem, suffix 

168 

169 return name, "" 

170 

171 

172def _remove_invalid_chars(stem: str, replacement: str) -> str: 

173 """Remove or replace invalid characters in a filename stem.""" 

174 try: 

175 if "\\" in replacement: 

176 # Use lambda to avoid processing regex escape sequences in replacement 

177 safe_stem = _INVALID_FILENAME_CHARS_RE.sub(lambda _: replacement, stem) # type: ignore[misc] 

178 else: 

179 safe_stem = _INVALID_FILENAME_CHARS_RE.sub(replacement, stem) 

180 except re.error: 

181 safe_stem = _INVALID_FILENAME_CHARS_RE.sub("_", stem) 

182 

183 # Remove leading/trailing dots and spaces (Windows issues) 

184 safe_stem = safe_stem.strip(". ") 

185 

186 # Remove path separators that might have snuck through 

187 safe_stem = safe_stem.replace("/", replacement) 

188 safe_stem = safe_stem.replace("\\", replacement) 

189 

190 return safe_stem 

191 

192 

193def _collapse_replacements(safe_stem: str, replacement: str) -> str: 

194 """Collapse multiple consecutive replacement characters.""" 

195 if replacement: 

196 double_replacement = replacement + replacement 

197 while double_replacement in safe_stem: 

198 safe_stem = safe_stem.replace(double_replacement, replacement) 

199 safe_stem = safe_stem.strip(replacement) 

200 return safe_stem 

201 

202 

203def _truncate_filename(safe_stem: str, suffix: str, max_length: int) -> str: 

204 """Truncate the filename while keeping the extension if possible.""" 

205 result = f"{safe_stem}{suffix}" 

206 if len(result) > max_length: 

207 available = max_length - len(suffix) 

208 if available > 0: 

209 safe_stem = safe_stem[:available] 

210 result = f"{safe_stem}{suffix}" 

211 else: 

212 result = result[:max_length] 

213 return result 

214 

215 

216def _is_valid_length_and_not_dot(filename: str, max_length: int) -> bool: 

217 """Check if filename length is valid and not a dot directory.""" 

218 if len(filename) > max_length: 

219 return False 

220 return filename not in {"..", "."} 

221 

222 

223def _has_safe_characters(filename: str, stem: str) -> bool: 

224 """Check if filename has safe characters and is not a reserved name.""" 

225 if stem.upper() in _WINDOWS_RESERVED_NAMES: 

226 return False 

227 if not filename.isascii(): 

228 return False 

229 return filename.replace(".", "").replace("-", "").replace("_", "").isalnum() 

230 

231 

232def _is_filename_safe(filename: str, max_length: int, stem: str) -> bool: 

233 """Check if a filename is already safe without any modifications.""" 

234 if not _is_valid_length_and_not_dot(filename, max_length): 

235 return False 

236 return _has_safe_characters(filename, stem) 

237 

238 

239def _handle_reserved_names(safe_stem: str, replacement: str) -> str: 

240 """Handle Windows reserved names by prepending the replacement character.""" 

241 if safe_stem.upper() in _WINDOWS_RESERVED_NAMES: 

242 return f"{replacement}{safe_stem}" 

243 return safe_stem 

244 

245 

246def _finalize_filename( 

247 safe_stem: str, 

248 replacement: str, 

249 suffix: str, 

250 max_length: int, 

251) -> str: 

252 """Finalize the sanitized filename by handling reserved names and empty results.""" 

253 safe_stem = _handle_reserved_names(safe_stem, replacement) 

254 

255 # Handle empty result 

256 if not safe_stem: 

257 safe_stem = "unnamed" 

258 

259 return _truncate_filename(safe_stem, suffix, max_length) 

260 

261 

262def _check_filename_type_str(filename: object, replacement: object) -> None: 

263 """Check str types of parameters for sanitize_filename.""" 

264 if not isinstance(filename, str): 

265 raise TypeError(f"filename must be str, got {type(filename).__name__}") 

266 if not isinstance(replacement, str): 

267 raise TypeError(f"replacement must be str, got {type(replacement).__name__}") 

268 

269 

270def _check_filename_types( 

271 filename: object, 

272 max_length: object, 

273 replacement: object, 

274 preserve_extension: object, 

275) -> None: 

276 """Check types of parameters for sanitize_filename.""" 

277 _check_filename_type_str(filename, replacement) 

278 

279 if not isinstance(max_length, int) or isinstance(max_length, bool): 

280 raise TypeError(f"max_length must be int, got {type(max_length).__name__}") 

281 

282 if not isinstance(preserve_extension, bool): 

283 raise TypeError( 

284 f"preserve_extension must be bool, got {type(preserve_extension).__name__}" 

285 ) 

286 

287 

288def _check_filename_length(filename: str, max_length: int) -> None: 

289 """Check max_length param and filename length.""" 

290 _check_max_length_param(max_length) 

291 if len(filename) > MAX_PATH_LENGTH: 

292 raise ValueError("Filename length exceeds maximum allowed limit") 

293 

294 

295def _validate_filename_params( 

296 filename: str, max_length: int, replacement: str, preserve_extension: bool 

297) -> None: 

298 """Validate parameters for sanitize_filename.""" 

299 _check_filename_types(filename, max_length, replacement, preserve_extension) 

300 _check_filename_length(filename, max_length) 

301 

302 

303def sanitize_filename( 

304 filename: str, 

305 *, 

306 max_length: int = 255, 

307 replacement: str = "_", 

308 preserve_extension: bool = True, 

309) -> str: 

310 """Sanitize a filename to be safe for filesystem use. 

311 

312 Removes or replaces characters that are: 

313 - Not allowed in filenames on various OSes 

314 - Potentially dangerous (path separators, etc.) 

315 

316 Args: 

317 filename: The filename to sanitize. 

318 max_length: Maximum length for the filename. 

319 replacement: Character to replace invalid chars with. 

320 preserve_extension: Keep original extension. 

321 

322 Returns: 

323 The sanitized filename. 

324 

325 Example: 

326 ```python 

327 sanitize_filename("my/../file<>:name.txt") 

328 # Returns: 'my_file_name.txt' 

329 ``` 

330 

331 """ 

332 _validate_filename_params(filename, max_length, replacement, preserve_extension) 

333 

334 if not filename: 

335 filename = "unnamed" 

336 

337 stem, suffix = _extract_stem_and_suffix(filename, preserve_extension) 

338 

339 if _is_filename_safe(filename, max_length, stem): 

340 return f"{stem}{suffix}" 

341 

342 safe_stem = _remove_invalid_chars(stem, replacement) 

343 safe_stem = _collapse_replacements(safe_stem, replacement) 

344 

345 return _finalize_filename(safe_stem, replacement, suffix, max_length) 

346 

347 

348def _get_stem(part: str) -> str: 

349 """Get the stem of a path part.""" 

350 idx = part.rfind(".") 

351 return part[:idx] if idx > 0 and not all(c == "." for c in part) else part 

352 

353 

354def _is_safe_path_part(part: str, stem: str) -> bool: 

355 """Check if a path part is safe.""" 

356 return ( 

357 len(part) <= 255 # noqa: PLR2004 

358 and part.isascii() 

359 and part.replace(".", "").replace("-", "").replace("_", "").isalnum() 

360 and stem.upper() not in _WINDOWS_RESERVED_NAMES 

361 ) 

362 

363 

364def _handle_dot_dot(parts: list[str], anchor: str) -> None: 

365 """Handle '..' by popping the last part if safe.""" 

366 if parts and parts[-1] != ".." and parts[-1] != anchor: 

367 parts.pop() 

368 

369 

370def _handle_normal_part(part: str, parts: list[str]) -> None: 

371 """Handle a normal part by checking if it's safe or sanitizing it.""" 

372 stem = _get_stem(part) 

373 if _is_safe_path_part(part, stem): 

374 parts.append(part) 

375 else: 

376 safe_part = sanitize_filename(part, preserve_extension=True) 

377 if safe_part and safe_part != "..": 

378 parts.append(safe_part) 

379 

380 

381def _process_path_part(part: str, parts: list[str], anchor: str) -> None: 

382 """Process a single path component, updating the parts list inline.""" 

383 if part == "..": 

384 _handle_dot_dot(parts, anchor) 

385 elif part != ".": 

386 _handle_normal_part(part, parts) 

387 

388 

389def _clean_path_parts(path: Path) -> list[str]: 

390 """Clean and sanitize individual path components.""" 

391 parts: list[str] = [] 

392 anchor = path.anchor 

393 for part in path.parts: 

394 _process_path_part(part, parts, anchor) 

395 return parts 

396 

397 

398def _resolve_sanitized_path(sanitized: Path) -> Path: 

399 """Resolve a sanitized path.""" 

400 try: 

401 return sanitized.resolve() 

402 except (OSError, RuntimeError) as e: 

403 msg = f"Cannot resolve path: {e}" 

404 raise ValueError(msg) from e 

405 

406 

407def _apply_base_dir_constraint( 

408 sanitized: Path, 

409 base_dir: Path | str | None, 

410 resolve: bool, 

411) -> Path: 

412 """Apply base directory constraints to a sanitized path.""" 

413 if base_dir is None: 

414 return sanitized 

415 

416 base = Path(base_dir).resolve() 

417 if resolve: 

418 return _resolve_sanitized_path(sanitized) 

419 

420 # Make absolute relative to base 

421 if not sanitized.is_absolute(): 

422 return base / sanitized 

423 

424 return sanitized 

425 

426 

427def _process_string_path(path: str) -> Path: 

428 """Process a string path, removing null bytes and validating length.""" 

429 if len(path) > MAX_PATH_LENGTH: 

430 msg = "Path length exceeds maximum allowed" 

431 raise ValueError(msg) 

432 if "\x00" in path: 

433 path = path.replace("\x00", "") 

434 return Path(path) 

435 

436 

437def _check_pathlike_length(path: os.PathLike[str]) -> None: 

438 """Check the length of a PathLike object.""" 

439 if len(str(path)) > MAX_PATH_LENGTH: 

440 msg = "Path length exceeds maximum allowed" 

441 raise ValueError(msg) 

442 

443 

444def _normalize_path_input(path: str | os.PathLike[str]) -> Path: 

445 """Normalize input path string or Path object.""" 

446 if not isinstance(path, (str, os.PathLike)): 

447 raise TypeError( 

448 f"path must be a string or PathLike object, got {type(path).__name__}" 

449 ) 

450 

451 if isinstance(path, str): 

452 return _process_string_path(path) 

453 

454 _check_pathlike_length(path) 

455 return Path(path) 

456 

457 

458def _reconstruct_path(original_path: Path, parts: list[str]) -> Path: 

459 """Reconstruct a path from its sanitized parts.""" 

460 if original_path.is_absolute(): 

461 # Use path.anchor to correctly preserve absolute roots on Windows (e.g. C:\) 

462 anchor = Path(original_path.anchor) 

463 return anchor.joinpath(*parts) if parts else anchor 

464 

465 if parts: 

466 return Path().joinpath(*parts) 

467 

468 return Path() 

469 

470 

471def _validate_path_depth(path: Path, max_depth: int | None) -> None: 

472 """Validate that the path depth does not exceed max_depth.""" 

473 depth = len(path.parts) 

474 if max_depth is not None and depth > max_depth: 

475 msg = f"Path depth {depth} exceeds maximum of {max_depth}" 

476 raise ValueError(msg) 

477 

478 

479def sanitize_path( 

480 path: str | Path, 

481 *, 

482 base_dir: Path | None = None, 

483 max_depth: int | None = 10, 

484 resolve: bool = False, 

485) -> Path: 

486 """Sanitize a path to prevent traversal and normalize it. 

487 

488 Args: 

489 path: The path to sanitize. 

490 base_dir: Optional base directory to constrain to. 

491 max_depth: Maximum directory depth allowed. 

492 resolve: Whether to resolve the path (requires it to exist). 

493 

494 Returns: 

495 The sanitized Path object. 

496 

497 Raises: 

498 ValueError: If path is invalid or too deep. 

499 

500 """ 

501 if max_depth is not None and max_depth < 0: 

502 raise ValueError("max_depth cannot be negative") 

503 

504 normalized_path = _normalize_path_input(path) 

505 parts = _clean_path_parts(normalized_path) 

506 sanitized = _reconstruct_path(normalized_path, parts) 

507 

508 _validate_path_depth(sanitized, max_depth) 

509 return _apply_base_dir_constraint(sanitized, base_dir, resolve)