|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import shutil |
| 4 | +import stat |
3 | 5 | import subprocess |
4 | 6 | import zipfile |
5 | 7 | from pathlib import Path |
|
14 | 16 |
|
15 | 17 | from .models import DatasetSource, SyncResult |
16 | 18 |
|
| 19 | +MAX_HTTP_DOWNLOAD_BYTES = 5 * 1024 * 1024 * 1024 |
| 20 | +MAX_ARCHIVE_MEMBERS = 100_000 |
| 21 | +MAX_ARCHIVE_UNCOMPRESSED_BYTES = 5 * 1024 * 1024 * 1024 |
| 22 | +SUBPROCESS_TIMEOUT_SECONDS = 900 |
| 23 | + |
17 | 24 | _SCRIPT_BACKED_DOWNLOADS: dict[str, tuple[str, ...]] = { |
18 | 25 | "msra_ner": ( |
19 | 26 | "https://raw.githubusercontent.com/OYE93/Chinese-NLP-Corpus/master/NER/MSRA/msra_train_bio.txt", |
@@ -100,31 +107,67 @@ def download_github_repo(repo_url: str, target_dir: Path, *, branch: str = "main |
100 | 107 | subprocess.run( |
101 | 108 | ["git", "clone", "--depth", "1", "--branch", branch, repo_url, str(target_dir)], |
102 | 109 | check=True, |
| 110 | + timeout=SUBPROCESS_TIMEOUT_SECONDS, |
103 | 111 | ) |
104 | 112 | return target_dir |
105 | 113 |
|
106 | 114 |
|
107 | | -def download_http_file(url: str, target_dir: Path, filename: str = "download.bin") -> Path: |
| 115 | +def download_http_file( |
| 116 | + url: str, |
| 117 | + target_dir: Path, |
| 118 | + filename: str = "download.bin", |
| 119 | + *, |
| 120 | + max_bytes: int = MAX_HTTP_DOWNLOAD_BYTES, |
| 121 | +) -> Path: |
108 | 122 | target_dir.mkdir(parents=True, exist_ok=True) |
109 | 123 | response = requests.get(url, timeout=120, stream=True) |
110 | 124 | response.raise_for_status() |
111 | 125 | if filename == "download.bin": |
112 | 126 | candidate = Path(unquote(urlparse(url).path)).name |
113 | 127 | if candidate: |
114 | 128 | filename = candidate |
115 | | - output_path = target_dir / filename |
| 129 | + output_path = _safe_child_path(target_dir, filename) |
| 130 | + declared_length = response.headers.get("Content-Length") |
| 131 | + if declared_length: |
| 132 | + try: |
| 133 | + content_length = int(declared_length) |
| 134 | + except ValueError as exc: |
| 135 | + raise ValueError(f"invalid Content-Length for {url}: {declared_length!r}") from exc |
| 136 | + if content_length > max_bytes: |
| 137 | + raise ValueError(f"download too large: {content_length} bytes exceeds limit {max_bytes}") |
| 138 | + tmp_path = output_path.with_name(f".{output_path.name}.part") |
| 139 | + bytes_written = 0 |
116 | 140 | try: |
117 | | - with output_path.open("wb") as handle: |
| 141 | + with tmp_path.open("wb") as handle: |
118 | 142 | for chunk in response.iter_content(chunk_size=1024 * 1024): |
119 | 143 | if chunk: |
| 144 | + bytes_written += len(chunk) |
| 145 | + if bytes_written > max_bytes: |
| 146 | + raise ValueError(f"download too large: exceeded limit {max_bytes} bytes") |
120 | 147 | handle.write(chunk) |
| 148 | + tmp_path.replace(output_path) |
121 | 149 | finally: |
| 150 | + if tmp_path.exists(): |
| 151 | + tmp_path.unlink() |
122 | 152 | close = getattr(response, "close", None) |
123 | 153 | if callable(close): |
124 | 154 | close() |
125 | 155 | return output_path |
126 | 156 |
|
127 | 157 |
|
| 158 | +def _safe_child_path(root: Path, filename: str) -> Path: |
| 159 | + if not filename: |
| 160 | + raise ValueError("download filename must not be empty") |
| 161 | + name = Path(filename).name |
| 162 | + if name != filename or name in {".", ".."}: |
| 163 | + raise ValueError(f"unsafe download filename: {filename!r}") |
| 164 | + resolved_root = root.resolve() |
| 165 | + output_path = (root / name).resolve() |
| 166 | + if not output_path.is_relative_to(resolved_root): |
| 167 | + raise ValueError(f"unsafe download filename: {filename!r}") |
| 168 | + return output_path |
| 169 | + |
| 170 | + |
128 | 171 | def _materialize_script_backed_dataset(source_id: str, target_dir: Path) -> None: |
129 | 172 | urls = _SCRIPT_BACKED_DOWNLOADS.get(source_id) |
130 | 173 | if not urls: |
@@ -168,21 +211,109 @@ def _allow_patterns_for_source(source_id: str) -> list[str] | None: |
168 | 211 | return None |
169 | 212 |
|
170 | 213 |
|
| 214 | +def _safe_archive_target(extract_dir: Path, member_name: str) -> Path: |
| 215 | + normalized_name = member_name.replace("\\", "/") |
| 216 | + member_path = Path(normalized_name) |
| 217 | + if member_path.is_absolute() or ".." in member_path.parts: |
| 218 | + raise ValueError(f"Blocked unsafe archive member path: {member_name!r}") |
| 219 | + resolved_root = extract_dir.resolve() |
| 220 | + target = (extract_dir / member_path).resolve() |
| 221 | + if not target.is_relative_to(resolved_root): |
| 222 | + raise ValueError(f"Blocked unsafe archive member path: {member_name!r}") |
| 223 | + return target |
| 224 | + |
| 225 | + |
| 226 | +def _validate_archive_limits(file_count: int, total_size: int) -> None: |
| 227 | + if file_count > MAX_ARCHIVE_MEMBERS: |
| 228 | + raise ValueError(f"archive has too many members: {file_count} > {MAX_ARCHIVE_MEMBERS}") |
| 229 | + if total_size > MAX_ARCHIVE_UNCOMPRESSED_BYTES: |
| 230 | + raise ValueError( |
| 231 | + f"archive expands to too many bytes: {total_size} > {MAX_ARCHIVE_UNCOMPRESSED_BYTES}" |
| 232 | + ) |
| 233 | + |
| 234 | + |
| 235 | +def _safe_zip_extractall(archive: zipfile.ZipFile, extract_dir: Path) -> None: |
| 236 | + """Extract a zip archive after validating paths, symlinks, count, and size.""" |
| 237 | + members = archive.infolist() |
| 238 | + total_size = 0 |
| 239 | + safe_targets: list[tuple[zipfile.ZipInfo, Path]] = [] |
| 240 | + for member in members: |
| 241 | + mode = member.external_attr >> 16 |
| 242 | + if stat.S_ISLNK(mode): |
| 243 | + raise ValueError(f"Blocked unsafe zip symlink member: {member.filename!r}") |
| 244 | + total_size += member.file_size |
| 245 | + safe_targets.append((member, _safe_archive_target(extract_dir, member.filename))) |
| 246 | + _validate_archive_limits(len(members), total_size) |
| 247 | + |
| 248 | + for member, target in safe_targets: |
| 249 | + if member.is_dir(): |
| 250 | + target.mkdir(parents=True, exist_ok=True) |
| 251 | + continue |
| 252 | + target.parent.mkdir(parents=True, exist_ok=True) |
| 253 | + with archive.open(member) as source, target.open("wb") as destination: |
| 254 | + shutil.copyfileobj(source, destination) |
| 255 | + |
| 256 | + |
| 257 | +def _replace_with_validated_extract_dir(tmp_dir: Path, extract_dir: Path) -> None: |
| 258 | + _validate_extracted_tree(tmp_dir) |
| 259 | + tmp_dir.replace(extract_dir) |
| 260 | + |
| 261 | + |
| 262 | +def _validate_extracted_tree(extract_dir: Path) -> None: |
| 263 | + resolved_root = extract_dir.resolve() |
| 264 | + file_count = 0 |
| 265 | + total_size = 0 |
| 266 | + for path in extract_dir.rglob("*"): |
| 267 | + if path.is_symlink(): |
| 268 | + raise ValueError(f"Blocked unsafe archive symlink: {path}") |
| 269 | + if not path.resolve().is_relative_to(resolved_root): |
| 270 | + raise ValueError(f"Blocked archive path escaping target directory: {path}") |
| 271 | + if path.is_file(): |
| 272 | + file_count += 1 |
| 273 | + total_size += path.stat().st_size |
| 274 | + _validate_archive_limits(file_count, total_size) |
| 275 | + |
| 276 | + |
| 277 | +def _fresh_extract_dir(path: Path) -> Path: |
| 278 | + tmp_dir = path.with_name(f".{path.stem}.extracting") |
| 279 | + if tmp_dir.exists(): |
| 280 | + shutil.rmtree(tmp_dir) |
| 281 | + return tmp_dir |
| 282 | + |
| 283 | + |
171 | 284 | def _extract_zip_files(target_dir: Path) -> None: |
172 | 285 | for path in target_dir.rglob("*.zip"): |
173 | 286 | extract_dir = path.with_suffix("") |
174 | 287 | if extract_dir.exists(): |
175 | 288 | continue |
176 | | - with zipfile.ZipFile(path) as archive: |
177 | | - archive.extractall(extract_dir) |
| 289 | + tmp_dir = _fresh_extract_dir(path) |
| 290 | + tmp_dir.mkdir(parents=True) |
| 291 | + try: |
| 292 | + with zipfile.ZipFile(path) as archive: |
| 293 | + _safe_zip_extractall(archive, tmp_dir) |
| 294 | + _replace_with_validated_extract_dir(tmp_dir, extract_dir) |
| 295 | + except Exception: |
| 296 | + shutil.rmtree(tmp_dir, ignore_errors=True) |
| 297 | + raise |
178 | 298 |
|
179 | 299 |
|
180 | 300 | def _extract_7z_files(target_dir: Path) -> None: |
181 | 301 | for path in target_dir.rglob("*.7z"): |
182 | 302 | extract_dir = path.with_suffix("") |
183 | 303 | if extract_dir.exists(): |
184 | 304 | continue |
185 | | - subprocess.run(["unar", "-quiet", "-output-directory", str(extract_dir), str(path)], check=True) |
| 305 | + tmp_dir = _fresh_extract_dir(path) |
| 306 | + tmp_dir.mkdir(parents=True) |
| 307 | + try: |
| 308 | + subprocess.run( |
| 309 | + ["unar", "-quiet", "-output-directory", str(tmp_dir), str(path)], |
| 310 | + check=True, |
| 311 | + timeout=SUBPROCESS_TIMEOUT_SECONDS, |
| 312 | + ) |
| 313 | + _replace_with_validated_extract_dir(tmp_dir, extract_dir) |
| 314 | + except Exception: |
| 315 | + shutil.rmtree(tmp_dir, ignore_errors=True) |
| 316 | + raise |
186 | 317 |
|
187 | 318 |
|
188 | 319 | _download_huggingface = download_huggingface_dataset |
|
0 commit comments