diff --git a/wizard-work/beam/path_sandbox.py b/wizard-work/beam/path_sandbox.py index ab099cd..24961d0 100644 --- a/wizard-work/beam/path_sandbox.py +++ b/wizard-work/beam/path_sandbox.py @@ -59,13 +59,22 @@ def safe_join(root, *parts): candidate = os.path.join(rr, *parts) if parts else rr # normalize (collapses . and redundant slashes) but does not resolve symlinks candidate = os.path.normpath(candidate) - # containment check on the normalized (pre-symlink) form - if candidate != rr and not candidate.startswith(rr + os.sep): - raise PathEscapeError("joined path escapes root: %r" % candidate) + # containment check on the normalized (pre-symlink) form. + # Special-case root == "/" so we don't require a "//" prefix. + if rr == os.sep: + if not (candidate == rr or candidate.startswith(rr)): + raise PathEscapeError("joined path escapes root: %r" % candidate) + else: + if candidate != rr and not candidate.startswith(rr + os.sep): + raise PathEscapeError("joined path escapes root: %r" % candidate) # now resolve symlinks and re-check containment resolved = os.path.realpath(candidate) - if resolved != rr and not resolved.startswith(rr + os.sep): - raise PathEscapeError("symlink escape: %r -> %r" % (candidate, resolved)) + if rr == os.sep: + if not (resolved == rr or resolved.startswith(rr)): + raise PathEscapeError("symlink escape: %r -> %r" % (candidate, resolved)) + else: + if resolved != rr and not resolved.startswith(rr + os.sep): + raise PathEscapeError("symlink escape: %r -> %r" % (candidate, resolved)) return resolved @@ -86,4 +95,6 @@ def is_within(root, target): rt = os.path.realpath(target) except (OSError, ValueError): return False + if rr == os.sep: + return rt == rr or rt.startswith(rr) return rt == rr or rt.startswith(rr + os.sep)