172 lines
7.4 KiB
Python
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
|