Skip to content

Commit bed924b

Browse files
authored
fix: reject external symlink targets during hydrate (#3094)
1 parent e1cb2be commit bed924b

13 files changed

Lines changed: 191 additions & 12 deletions

File tree

src/agents/extensions/sandbox/blaxel/sandbox.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,10 @@ async def hydrate_workspace(self, data: io.IOBase) -> None:
600600
raise WorkspaceWriteTypeError(path=Path(tar_path), actual_type=type(payload).__name__)
601601

602602
try:
603-
validate_tar_bytes(bytes(payload))
603+
validate_tar_bytes(
604+
bytes(payload),
605+
allow_external_symlink_targets=False,
606+
)
604607
except UnsafeTarMemberError as e:
605608
raise WorkspaceArchiveWriteError(
606609
path=root,

src/agents/extensions/sandbox/cloudflare/sandbox.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1161,7 +1161,10 @@ async def _hydrate_workspace_via_http(self, data: io.IOBase) -> None:
11611161
raise WorkspaceArchiveWriteError(path=root, context={"reason": "non_bytes_payload"})
11621162

11631163
try:
1164-
validate_tar_bytes(bytes(raw))
1164+
validate_tar_bytes(
1165+
bytes(raw),
1166+
allow_external_symlink_targets=False,
1167+
)
11651168
except UnsafeTarMemberError as e:
11661169
raise WorkspaceArchiveWriteError(
11671170
path=root,

src/agents/extensions/sandbox/daytona/sandbox.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1001,7 +1001,10 @@ async def hydrate_workspace(self, data: io.IOBase) -> None:
10011001
raise WorkspaceWriteTypeError(path=Path(tar_path), actual_type=type(payload).__name__)
10021002

10031003
try:
1004-
validate_tar_bytes(bytes(payload))
1004+
validate_tar_bytes(
1005+
bytes(payload),
1006+
allow_external_symlink_targets=False,
1007+
)
10051008
except UnsafeTarMemberError as e:
10061009
raise WorkspaceArchiveWriteError(
10071010
path=root,

src/agents/extensions/sandbox/e2b/sandbox.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1496,7 +1496,10 @@ async def hydrate_workspace(self, data: io.IOBase) -> None:
14961496
) from e
14971497

14981498
try:
1499-
validate_tar_bytes(bytes(raw))
1499+
validate_tar_bytes(
1500+
bytes(raw),
1501+
allow_external_symlink_targets=False,
1502+
)
15001503
except UnsafeTarMemberError as e:
15011504
raise WorkspaceArchiveWriteError(
15021505
path=error_root,

src/agents/extensions/sandbox/modal/sandbox.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1717,6 +1717,7 @@ async def _hydrate_workspace_via_tar(self, data: io.IOBase) -> None:
17171717
validate_tar_bytes(
17181718
bytes(raw),
17191719
skip_rel_paths=self.state.manifest.ephemeral_persistence_paths(),
1720+
allow_external_symlink_targets=False,
17201721
)
17211722
except UnsafeTarMemberError as e:
17221723
raise WorkspaceArchiveWriteError(

src/agents/extensions/sandbox/runloop/sandbox.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1282,7 +1282,10 @@ async def _hydrate_workspace_via_tar(self, payload: bytes) -> None:
12821282
archive_path = root / f".sandbox-runloop-hydrate-{self.state.session_id.hex}.tar"
12831283

12841284
try:
1285-
validate_tar_bytes(payload)
1285+
validate_tar_bytes(
1286+
payload,
1287+
allow_external_symlink_targets=False,
1288+
)
12861289
except UnsafeTarMemberError as e:
12871290
raise WorkspaceArchiveWriteError(
12881291
path=root,

src/agents/extensions/sandbox/vercel/sandbox.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,18 @@ async def _validate_path_access(self, path: Path | str, *, for_write: bool = Fal
285285
def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
286286
return (RESOLVE_WORKSPACE_PATH_HELPER,)
287287

288-
def _validate_tar_bytes(self, raw: bytes) -> None:
288+
def _validate_tar_bytes(
289+
self,
290+
raw: bytes,
291+
*,
292+
allow_external_symlink_targets: bool = True,
293+
) -> None:
289294
try:
290295
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:*") as tar:
291-
validate_tarfile(tar)
296+
validate_tarfile(
297+
tar,
298+
allow_external_symlink_targets=allow_external_symlink_targets,
299+
)
292300
except UnsafeTarMemberError as exc:
293301
raise ValueError(str(exc)) from exc
294302
except (tarfile.TarError, OSError) as exc:
@@ -608,7 +616,7 @@ async def _hydrate_workspace_internal(self, raw: bytes) -> None:
608616
)
609617
tar_command = ("tar", "xf", archive_path.as_posix(), "-C", root.as_posix())
610618
try:
611-
self._validate_tar_bytes(raw)
619+
self._validate_tar_bytes(raw, allow_external_symlink_targets=False)
612620
await self.mkdir(root, parents=True)
613621
await self._write_files_with_retry([{"path": archive_path.as_posix(), "content": raw}])
614622
result = await self.exec(*tar_command, shell=False)

src/agents/sandbox/sandboxes/docker.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1243,7 +1243,10 @@ async def hydrate_workspace(self, data: io.IOBase) -> None:
12431243
try:
12441244
archive.seek(0)
12451245
with tarfile.open(fileobj=archive, mode="r:*") as tar:
1246-
validate_tarfile(tar)
1246+
validate_tarfile(
1247+
tar,
1248+
allow_external_symlink_targets=False,
1249+
)
12471250
except UnsafeTarMemberError as e:
12481251
raise WorkspaceArchiveWriteError(
12491252
path=error_root,

src/agents/sandbox/sandboxes/unix_local.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1037,7 +1037,11 @@ async def hydrate_workspace(self, data: io.IOBase) -> None:
10371037
try:
10381038
root.mkdir(parents=True, exist_ok=True)
10391039
with tarfile.open(fileobj=data, mode="r:*") as tar:
1040-
safe_extract_tarfile(tar, root=root)
1040+
safe_extract_tarfile(
1041+
tar,
1042+
root=root,
1043+
allow_external_symlink_targets=False,
1044+
)
10411045
except UnsafeTarMemberError as e:
10421046
raise WorkspaceArchiveWriteError(
10431047
path=root, context={"reason": e.reason, "member": e.member}, cause=e

src/agents/sandbox/util/tar_utils.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,45 @@ def _raise_if_windows_member_path(member_name: str) -> None:
3535
raise UnsafeTarMemberError(member=member_name, reason="windows path separator")
3636

3737

38+
def _normalize_posix_path_without_root(path: PurePosixPath) -> tuple[str, ...] | None:
39+
normalized: list[str] = []
40+
for part in path.parts:
41+
if part in ("", ".", "/"):
42+
continue
43+
if part == "..":
44+
if not normalized:
45+
return None
46+
normalized.pop()
47+
continue
48+
normalized.append(part)
49+
return tuple(normalized)
50+
51+
52+
def _validate_symlink_target(
53+
member: tarfile.TarInfo,
54+
*,
55+
rel_path: Path,
56+
allow_external_symlink_targets: bool,
57+
) -> None:
58+
if not member.issym() or allow_external_symlink_targets:
59+
return
60+
61+
target = PurePosixPath(member.linkname)
62+
if target.is_absolute():
63+
raise UnsafeTarMemberError(
64+
member=member.name,
65+
reason=f"absolute symlink target not allowed: {member.linkname}",
66+
)
67+
68+
member_parent = PurePosixPath(rel_path.as_posix()).parent
69+
normalized = _normalize_posix_path_without_root(member_parent / target)
70+
if normalized is None:
71+
raise UnsafeTarMemberError(
72+
member=member.name,
73+
reason=f"symlink target escapes archive root: {member.linkname}",
74+
)
75+
76+
3877
def safe_tar_member_rel_path(
3978
member: tarfile.TarInfo,
4079
*,
@@ -199,6 +238,7 @@ def validate_tarfile(
199238
skip_rel_paths: Iterable[str | Path] = (),
200239
root_name: str | None = None,
201240
allow_symlinks: bool = True,
241+
allow_external_symlink_targets: bool = True,
202242
) -> None:
203243
"""Validate a workspace tar before handing it to a local or remote extractor.
204244
@@ -235,6 +275,11 @@ def validate_tarfile(
235275
members_by_rel_path[rel_path] = member
236276

237277
if member.issym():
278+
_validate_symlink_target(
279+
member,
280+
rel_path=rel_path,
281+
allow_external_symlink_targets=allow_external_symlink_targets,
282+
)
238283
if rel_path in rejected_symlink_rel_paths:
239284
raise UnsafeTarMemberError(
240285
member=member.name,
@@ -266,6 +311,7 @@ def validate_tar_bytes(
266311
reject_symlink_rel_paths: Iterable[str | Path] = (),
267312
skip_rel_paths: Iterable[str | Path] = (),
268313
root_name: str | None = None,
314+
allow_external_symlink_targets: bool = True,
269315
) -> None:
270316
"""Validate raw workspace tar bytes with the shared safe tar policy."""
271317

@@ -276,14 +322,20 @@ def validate_tar_bytes(
276322
reject_symlink_rel_paths=reject_symlink_rel_paths,
277323
skip_rel_paths=skip_rel_paths,
278324
root_name=root_name,
325+
allow_external_symlink_targets=allow_external_symlink_targets,
279326
)
280327
except UnsafeTarMemberError:
281328
raise
282329
except (tarfile.TarError, OSError) as e:
283330
raise UnsafeTarMemberError(member="<tar>", reason="invalid tar stream") from e
284331

285332

286-
def safe_extract_tarfile(tar: tarfile.TarFile, *, root: Path) -> None:
333+
def safe_extract_tarfile(
334+
tar: tarfile.TarFile,
335+
*,
336+
root: Path,
337+
allow_external_symlink_targets: bool = True,
338+
) -> None:
287339
"""
288340
Safely extract a tar archive into `root`.
289341
@@ -302,7 +354,10 @@ def safe_extract_tarfile(tar: tarfile.TarFile, *, root: Path) -> None:
302354
root_resolved = root.resolve()
303355

304356
members = tar.getmembers()
305-
validate_tarfile(tar)
357+
validate_tarfile(
358+
tar,
359+
allow_external_symlink_targets=allow_external_symlink_targets,
360+
)
306361

307362
def _prepare_replaceable_leaf(*, dest: Path, rel_path: Path, name: str) -> None:
308363
_ensure_no_symlink_parents(root=root_resolved, dest=dest, check_leaf=False)

0 commit comments

Comments
 (0)