"""Safe tar extraction with path-traversal, symlink and hardlink guards. Public API: safe_extract(archive, dest, *, max_members=10000, max_file_bytes=None) SafeExtractError Raises SafeExtractError on any unsafe member. Never writes outside `dest`. """ import os import tarfile __all__ = ["safe_extract", "SafeExtractError"] class SafeExtractError(Exception): pass def _norm_rel(name): if not isinstance(name, str) or name == "": raise SafeExtractError("empty member name") # tar names may use backslashes on windows-ish archives; normalize name = name.replace("\\", "/") if name.startswith("/"): raise SafeExtractError("absolute member path: %r" % name) parts = name.split("/") # drop a single leading "./" if parts and parts[0] == ".": parts = parts[1:] if not parts or all(p == "" for p in parts): raise SafeExtractError("empty member path: %r" % name) if any(p in ("", ".", "..") for p in parts): raise SafeExtractError("traversal/empty component in member path: %r" % name) return os.path.join(*parts) def _ensure_within(dest, target): dest = os.path.abspath(dest) target = os.path.abspath(target) if target == dest: return target if not (target.startswith(dest + os.sep)): raise SafeExtractError("member escapes destination: %r" % target) return target def _safe_link_target(dest, member_dir, linkname): if not isinstance(linkname, str) or linkname == "": raise SafeExtractError("empty link target") linkname = linkname.replace("\\", "/") if linkname.startswith("/"): raise SafeExtractError("absolute link target: %r" % linkname) # resolve relative to member's directory joined = os.path.normpath(os.path.join(member_dir, linkname)) # member_dir is already absolute inside dest if joined == os.path.abspath(dest): return joined if not joined.startswith(os.path.abspath(dest) + os.sep): raise SafeExtractError("link target escapes destination: %r" % linkname) return joined def safe_extract(archive, dest, *, max_members=10000, max_file_bytes=None): dest = os.path.abspath(dest) os.makedirs(dest, exist_ok=True) # refuse if dest itself is a symlink to elsewhere? We require dest real. if os.path.islink(dest): raise SafeExtractError("destination is a symlink") with tarfile.open(archive, "r:*") as tf: members = tf.getmembers() if len(members) > max_members: raise SafeExtractError("too many members: %d > %d" % (len(members), max_members)) # First pass: validate all members before writing anything. planned = [] for m in members: rel = _norm_rel(m.name) target = _ensure_within(dest, os.path.join(dest, rel)) if m.issym() or m.islnk(): member_dir = os.path.dirname(target) _safe_link_target(dest, member_dir, m.linkname) if max_file_bytes is not None and m.isfile(): if m.size > max_file_bytes: raise SafeExtractError("file too large: %s (%d bytes)" % (m.name, m.size)) planned.append((m, target)) # Second pass: extract, never following symlinks for parents. for m, target in planned: # ensure parent exists and is not a symlink escaping dest parent = os.path.dirname(target) # walk/create parents manually, refusing symlink parents cur = dest for part in os.path.relpath(parent, dest).split(os.sep): if part in ("", "."): continue cur = os.path.join(cur, part) if os.path.lexists(cur): if os.path.islink(cur): raise SafeExtractError("refusing to traverse symlinked parent: %r" % cur) else: os.mkdir(cur) _ensure_within(dest, cur) if m.isdir(): if os.path.lexists(target): if os.path.islink(target): raise SafeExtractError("refusing to replace symlink with dir: %r" % target) else: os.mkdir(target) elif m.issym(): # remove existing then create link if os.path.lexists(target): if os.path.isdir(target) and not os.path.islink(target): raise SafeExtractError("refusing to overwrite directory with symlink: %r" % target) os.remove(target) if not os.path.isdir(target) or os.path.islink(target) else None if os.path.lexists(target): # directory case handled above; best effort remove try: os.rmdir(target) except OSError: raise SafeExtractError("cannot replace existing path with symlink: %r" % target) linktarget = m.linkname.replace("\\", "/") # store relative/absolute as validated; use validated resolved? keep original relative os.symlink(linktarget, target) elif m.islnk(): # hardlink: resolve source within dest src_rel = _norm_rel(m.linkname) if not os.path.isabs(m.linkname) else None # hardlink linkname is a member name reference if os.path.isabs(m.linkname): raise SafeExtractError("absolute hardlink target: %r" % m.linkname) src = _ensure_within(dest, os.path.join(dest, _norm_rel(m.linkname))) if os.path.islink(src): raise SafeExtractError("hardlink source is symlink: %r" % src) # copy file content instead of hardlink to avoid cross-escape if os.path.isdir(src): raise SafeExtractError("hardlink to directory not allowed") # ensure target parent done; copy if os.path.lexists(target): if os.path.isdir(target) and not os.path.islink(target): raise SafeExtractError("refusing to overwrite dir with file: %r" % target) os.remove(target) import shutil as _sh _sh.copyfile(src, target) elif m.isfile(): if os.path.lexists(target) and os.path.isdir(target) and not os.path.islink(target): raise SafeExtractError("refusing to overwrite directory with file: %r" % target) if os.path.lexists(target): os.remove(target) srcf = tf.extractfile(m) if srcf is None: raise SafeExtractError("cannot read member: %r" % m.name) with open(target, "wb") as out: remaining = m.size chunk = 65536 while remaining > 0: data = srcf.read(min(chunk, remaining)) if not data: break out.write(data) remaining -= len(data) # mode bits (best effort) try: os.chmod(target, m.mode & 0o7777) except OSError: pass else: # devices, fifos, etc. skip for safety raise SafeExtractError("unsupported member type: %r" % m.name) return dest