Files
troupe-standup/wizard-work/beam/safe_extract.py
T

172 lines
7.4 KiB
Python

"""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