-
Notifications
You must be signed in to change notification settings - Fork 4.7k
fix(sandbox): refuse FIFO, socket, and device nodes in UnixLocal read/write instead of blocking the event loop #4891
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1c01951
40d2fcb
700a080
7c58cec
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,13 +15,14 @@ | |
| import shlex | ||
| import shutil | ||
| import signal | ||
| import stat | ||
| import tarfile | ||
| import tempfile | ||
| import termios | ||
| import time | ||
| import uuid | ||
| from collections import deque | ||
| from collections.abc import Collection, Mapping, Sequence | ||
| from collections.abc import Callable, Collection, Mapping, Sequence | ||
| from contextlib import suppress | ||
| from dataclasses import dataclass, field | ||
| from functools import partial | ||
|
|
@@ -152,6 +153,115 @@ class _UnixPtyProcessEntry: | |
| wait_task: asyncio.Task[None] | None = None | ||
|
|
||
|
|
||
| _SPECIAL_FILE_KINDS: tuple[tuple[Callable[[int], bool], str], ...] = ( | ||
| (stat.S_ISFIFO, "fifo"), | ||
| (stat.S_ISSOCK, "socket"), | ||
| (stat.S_ISCHR, "character device"), | ||
| (stat.S_ISBLK, "block device"), | ||
| ) | ||
| _SPECIAL_FILE_EXIT_CODE = 3 | ||
|
|
||
| # User-scoped writer, run as the requested user via the confined exec path. It does the | ||
| # special-file classification itself (host-side checks cannot see inside a directory only | ||
| # that user may search): an existing entry is opened read-write, which unlike a write-only | ||
| # open never blocks on a FIFO, and the type of the *descriptor* is tested through /dev/fd. | ||
| _USER_WRITE_SCRIPT = ( | ||
| 'target="$1"\n' | ||
| 'mkdir -p "$(dirname "$target")" || exit 1\n' | ||
| 'if [ -e "$target" ] || [ -L "$target" ]; then\n' | ||
| ' exec 3<>"$target" || exit 1\n' | ||
| " if [ -p /dev/fd/3 ] || [ -S /dev/fd/3 ] || [ -c /dev/fd/3 ] || [ -b /dev/fd/3 ]; then\n" | ||
|
Comment on lines
+172
to
+173
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a privileged user-scoped write targets a character or block device with an open-time side effect or blocking driver, this AGENTS.md reference: AGENTS.md:L103-L103 Useful? React with 👍 / 👎. |
||
| f" exit {_SPECIAL_FILE_EXIT_CODE}\n" | ||
| " fi\n" | ||
| " exec 3>&-\n" | ||
| "fi\n" | ||
| 'cat > "$target"\n' | ||
|
Comment on lines
+176
to
+178
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When another sandbox command replaces an existing regular target with a FIFO after fd 3 is classified, this closes the validated descriptor and AGENTS.md reference: AGENTS.md:L104-L104 Useful? React with 👍 / 👎. |
||
| ) | ||
|
|
||
|
|
||
| def _special_file_kind(mode: int) -> str | None: | ||
| return next((name for predicate, name in _SPECIAL_FILE_KINDS if predicate(mode)), None) | ||
|
|
||
|
|
||
| def _raise_for_special_file(kind: str | None, *, path: Path, for_write: bool) -> None: | ||
| if kind is None: | ||
| return | ||
| context = {"reason": f"not a regular file: {kind}"} | ||
| if for_write: | ||
| raise WorkspaceArchiveWriteError(path=path, context=context) | ||
| raise WorkspaceArchiveReadError(path=path, context=context) | ||
|
|
||
|
|
||
| def _classify_mode(mode: int, *, workspace_path: Path, path: Path, for_write: bool) -> None: | ||
| if stat.S_ISDIR(mode): | ||
| raise IsADirectoryError(errno.EISDIR, os.strerror(errno.EISDIR), str(workspace_path)) | ||
| _raise_for_special_file(_special_file_kind(mode), path=path, for_write=for_write) | ||
|
|
||
|
|
||
| def _open_regular_file(workspace_path: Path, *, path: Path, for_write: bool) -> int: | ||
| """Open a workspace file for in-process I/O, refusing FIFOs, sockets, and device nodes. | ||
|
|
||
| `open()` on a FIFO with no peer blocks the calling thread, and this session performs | ||
| file I/O synchronously on the event loop, so such an open would stall the whole | ||
| process; a device node may block or act on open regardless of `O_NONBLOCK`. | ||
|
|
||
| Where the platform offers `O_PATH` (Linux), the entry is pinned with a descriptor that | ||
| does not open it, classified with `fstat()`, and then that same inode is opened for I/O | ||
| through `/proc/self/fd`, so a replacement of the path between the two steps cannot | ||
| reach a blocking open. A missing target is created with `O_EXCL`, which guarantees the | ||
| created entry is a regular file. Elsewhere the entry is classified with `stat()` | ||
| before a non-blocking open and again with `fstat()` on the opened descriptor. | ||
| Missing paths keep their existing error handling; a directory is reported like the | ||
| blocking `open()` did. | ||
| """ | ||
| cloexec = getattr(os, "O_CLOEXEC", 0) | ||
| if for_write: | ||
| io_flags = os.O_WRONLY | os.O_TRUNC | ||
| else: | ||
| io_flags = os.O_RDONLY | ||
| o_path = getattr(os, "O_PATH", None) | ||
| if o_path is not None: | ||
| try: | ||
| pin = os.open(workspace_path, o_path | cloexec) | ||
| except FileNotFoundError: | ||
| if not for_write: | ||
| raise | ||
| # Create the file ourselves; O_EXCL means whatever we get back is the | ||
| # regular file this call created, never an entry swapped in meanwhile. | ||
| return os.open(workspace_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | cloexec, 0o666) | ||
| try: | ||
| _classify_mode( | ||
| os.fstat(pin).st_mode, workspace_path=workspace_path, path=path, for_write=for_write | ||
| ) | ||
| return os.open(f"/proc/self/fd/{pin}", io_flags | cloexec) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On a Linux chroot or minimal container where AGENTS.md reference: AGENTS.md:L93-L93 Useful? React with 👍 / 👎. |
||
| finally: | ||
| os.close(pin) | ||
|
|
||
| try: | ||
| _classify_mode( | ||
| workspace_path.stat().st_mode, | ||
| workspace_path=workspace_path, | ||
| path=path, | ||
| for_write=for_write, | ||
| ) | ||
| except OSError: | ||
| pass # missing or unreadable: let the open below report it | ||
| flags = io_flags | os.O_NONBLOCK | cloexec | ||
| if for_write: | ||
| flags |= os.O_CREAT | ||
| fd = os.open(workspace_path, flags, 0o666) | ||
|
seratch marked this conversation as resolved.
|
||
| try: | ||
| _classify_mode( | ||
| os.fstat(fd).st_mode, workspace_path=workspace_path, path=path, for_write=for_write | ||
| ) | ||
| # Regular files ignore O_NONBLOCK; clear it anyway so the handle behaves like open(). | ||
| fcntl.fcntl(fd, fcntl.F_SETFL, fcntl.fcntl(fd, fcntl.F_GETFL) & ~os.O_NONBLOCK) | ||
| except BaseException: | ||
| os.close(fd) | ||
| raise | ||
| return fd | ||
|
|
||
|
|
||
| class UnixLocalSandboxSession(BaseSandboxSession): | ||
| """ | ||
| Unix-only session implementation that runs commands on the host and uses the host filesystem | ||
|
|
@@ -985,11 +1095,12 @@ async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase | |
|
|
||
| workspace_path = self.normalize_path(path) | ||
| try: | ||
| return workspace_path.open("rb") | ||
| fd = _open_regular_file(workspace_path, path=path, for_write=False) | ||
| except FileNotFoundError as e: | ||
| raise WorkspaceReadNotFoundError(path=path, cause=e) from e | ||
| except OSError as e: | ||
| raise WorkspaceArchiveReadError(path=path, cause=e) from e | ||
| return os.fdopen(fd, "rb") | ||
|
|
||
| async def write( | ||
| self, | ||
|
|
@@ -1007,7 +1118,8 @@ async def write( | |
|
|
||
| try: | ||
| workspace_path.parent.mkdir(parents=True, exist_ok=True) | ||
| with workspace_path.open("wb") as f: | ||
| fd = _open_regular_file(workspace_path, path=workspace_path, for_write=True) | ||
| with os.fdopen(fd, "wb") as f: | ||
| shutil.copyfileobj(payload.stream, f) | ||
| except OSError as e: | ||
| raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e | ||
|
|
@@ -1024,7 +1136,7 @@ async def _write_stream_with_exec( | |
| command_parts = self._prepare_exec_command( | ||
| "sh", | ||
| "-c", | ||
| 'mkdir -p "$(dirname "$1")" && cat > "$1"', | ||
| _USER_WRITE_SCRIPT, | ||
| "sh", | ||
| str(path), | ||
| shell=False, | ||
|
|
@@ -1062,6 +1174,10 @@ async def _write_stream_with_exec( | |
| except OSError as e: | ||
| raise WorkspaceArchiveWriteError(path=path, cause=e) from e | ||
|
|
||
| if proc.returncode == _SPECIAL_FILE_EXIT_CODE: | ||
| raise WorkspaceArchiveWriteError( | ||
| path=path, context={"reason": "not a regular file", "user": str(user)} | ||
| ) | ||
| if proc.returncode: | ||
| raise WorkspaceArchiveWriteError( | ||
| path=path, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the requested user owns an existing regular file with write-only permissions such as mode
0200,exec 3<>"$target"fails because<>requests both read and write access, even though the previouscat > "$target"path and the non-user path require only write access. Consequently a supported user-scoped write now raisesWorkspaceArchiveWriteErrorinstead of updating the file; classify the target without adding a read-permission requirement to this path.AGENTS.md reference: AGENTS.md:L103-L103
Useful? React with 👍 / 👎.