mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
286 lines
9.6 KiB
Python
286 lines
9.6 KiB
Python
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import errno
|
|
import os
|
|
import stat
|
|
from collections.abc import Iterable
|
|
|
|
|
|
class UnsafeWorkspacePathError(OSError):
|
|
"""A tenant-controlled path could not be opened without following links."""
|
|
|
|
|
|
_DIRECTORY_FLAGS = (
|
|
os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
|
|
)
|
|
_FILE_READ_FLAGS = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
|
|
_FILE_WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
|
|
_MAX_REMOVAL_ENTRIES = 4096
|
|
_MAX_REMOVAL_DEPTH = 16
|
|
|
|
|
|
def _component(value: str) -> str:
|
|
normalized = str(value or '').strip()
|
|
if (
|
|
not normalized
|
|
or normalized in {'.', '..'}
|
|
or '/' in normalized
|
|
or '\\' in normalized
|
|
or '\x00' in normalized
|
|
or len(os.fsencode(normalized)) > 240
|
|
):
|
|
raise UnsafeWorkspacePathError('Unsafe Workspace path component')
|
|
return normalized
|
|
|
|
|
|
def _unsafe(path: str, exc: BaseException | None = None) -> UnsafeWorkspacePathError:
|
|
error = UnsafeWorkspacePathError(f'Workspace path is not a link-free directory: {path}')
|
|
if exc is not None:
|
|
error.__cause__ = exc
|
|
return error
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _root_fd(root: str):
|
|
try:
|
|
fd = os.open(root, _DIRECTORY_FLAGS)
|
|
except OSError as exc:
|
|
raise _unsafe(root, exc)
|
|
try:
|
|
if not stat.S_ISDIR(os.fstat(fd).st_mode):
|
|
raise _unsafe(root)
|
|
yield fd
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def _open_dir_at(parent_fd: int, name: str, *, create: bool) -> int:
|
|
name = _component(name)
|
|
if create:
|
|
try:
|
|
os.mkdir(name, mode=0o700, dir_fd=parent_fd)
|
|
except FileExistsError:
|
|
pass
|
|
try:
|
|
fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd)
|
|
except OSError as exc:
|
|
raise _unsafe(name, exc)
|
|
if not stat.S_ISDIR(os.fstat(fd).st_mode):
|
|
os.close(fd)
|
|
raise _unsafe(name)
|
|
return fd
|
|
|
|
|
|
def _remove_entry(
|
|
parent_fd: int,
|
|
name: str,
|
|
*,
|
|
budget: list[int] | None = None,
|
|
depth: int = 0,
|
|
) -> None:
|
|
"""Remove an entry recursively without following a symlink at any depth."""
|
|
|
|
name = _component(name)
|
|
budget = budget if budget is not None else [_MAX_REMOVAL_ENTRIES]
|
|
if depth > _MAX_REMOVAL_DEPTH or budget[0] <= 0:
|
|
raise UnsafeWorkspacePathError('Workspace cleanup exceeded its inode budget')
|
|
budget[0] -= 1
|
|
for _ in range(4):
|
|
try:
|
|
child_fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd)
|
|
except FileNotFoundError:
|
|
return
|
|
except OSError as exc:
|
|
if exc.errno not in {errno.ELOOP, errno.ENOTDIR, errno.EACCES}:
|
|
raise
|
|
try:
|
|
os.unlink(name, dir_fd=parent_fd)
|
|
return
|
|
except FileNotFoundError:
|
|
return
|
|
except IsADirectoryError:
|
|
continue
|
|
else:
|
|
try:
|
|
_clear_dir(child_fd, budget=budget, depth=depth + 1)
|
|
finally:
|
|
os.close(child_fd)
|
|
try:
|
|
os.rmdir(name, dir_fd=parent_fd)
|
|
return
|
|
except FileNotFoundError:
|
|
return
|
|
except NotADirectoryError:
|
|
continue
|
|
raise UnsafeWorkspacePathError('Workspace entry changed while it was being removed')
|
|
|
|
|
|
def _clear_dir(directory_fd: int, *, budget: list[int], depth: int) -> None:
|
|
# ``scandir(fd)`` enumerates the already-open directory. Names are then
|
|
# resolved relative to the same fd, so a tenant cannot redirect the walk by
|
|
# swapping an ancestor symlink between validation and use.
|
|
# Do not materialize the whole directory: an attacker-controlled outbox
|
|
# may contain an inode bomb even when its byte size is tiny. Removal is
|
|
# deliberately budgeted and fails closed once the per-operation cap is
|
|
# reached; hard filesystem/inode quota remains a Cloud readiness gate.
|
|
with os.scandir(directory_fd) as iterator:
|
|
for entry in iterator:
|
|
_remove_entry(directory_fd, entry.name, budget=budget, depth=depth)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _query_fd(root: str, subdir: str, query_key: str, *, create: bool, reset: bool = False):
|
|
subdir = _component(subdir)
|
|
query_key = _component(query_key)
|
|
with _root_fd(root) as root_fd:
|
|
subdir_fd = _open_dir_at(root_fd, subdir, create=create)
|
|
try:
|
|
if reset:
|
|
_remove_entry(subdir_fd, query_key)
|
|
query_fd = _open_dir_at(subdir_fd, query_key, create=create)
|
|
try:
|
|
yield query_fd
|
|
finally:
|
|
os.close(query_fd)
|
|
finally:
|
|
os.close(subdir_fd)
|
|
|
|
|
|
def write_files(
|
|
root: str,
|
|
subdir: str,
|
|
query_key: str,
|
|
files: Iterable[tuple[str, bytes]],
|
|
) -> None:
|
|
"""Atomically recreate one query directory and write regular files only."""
|
|
|
|
with _query_fd(root, subdir, query_key, create=True, reset=True) as query_fd:
|
|
for raw_name, data in files:
|
|
name = _component(raw_name)
|
|
try:
|
|
file_fd = os.open(name, _FILE_WRITE_FLAGS, 0o600, dir_fd=query_fd)
|
|
except OSError as exc:
|
|
raise UnsafeWorkspacePathError(f'Could not create a link-free Workspace file: {name}') from exc
|
|
with os.fdopen(file_fd, 'wb') as file_obj:
|
|
file_obj.write(data)
|
|
|
|
|
|
def _read_directory(
|
|
directory_fd: int,
|
|
*,
|
|
prefix: str,
|
|
max_file_bytes: int,
|
|
max_files: int,
|
|
max_total_bytes: int,
|
|
output: list[tuple[str, bytes]],
|
|
total: list[int],
|
|
remaining_entries: list[int],
|
|
remaining_directories: list[int],
|
|
depth: int,
|
|
) -> None:
|
|
if depth > 8:
|
|
return
|
|
with os.scandir(directory_fd) as iterator:
|
|
for entry in iterator:
|
|
if len(output) >= max_files or total[0] >= max_total_bytes:
|
|
return
|
|
if remaining_entries[0] <= 0:
|
|
raise UnsafeWorkspacePathError('Sandbox outbox exceeds the directory-entry limit')
|
|
remaining_entries[0] -= 1
|
|
name = _component(entry.name)
|
|
relative = f'{prefix}/{name}' if prefix else name
|
|
if entry.is_symlink():
|
|
continue
|
|
if entry.is_dir(follow_symlinks=False):
|
|
if remaining_directories[0] <= 0:
|
|
raise UnsafeWorkspacePathError('Sandbox outbox exceeds the directory limit')
|
|
remaining_directories[0] -= 1
|
|
try:
|
|
child_fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=directory_fd)
|
|
except OSError:
|
|
continue
|
|
try:
|
|
_read_directory(
|
|
child_fd,
|
|
prefix=relative,
|
|
max_file_bytes=max_file_bytes,
|
|
max_files=max_files,
|
|
max_total_bytes=max_total_bytes,
|
|
output=output,
|
|
total=total,
|
|
remaining_entries=remaining_entries,
|
|
remaining_directories=remaining_directories,
|
|
depth=depth + 1,
|
|
)
|
|
finally:
|
|
os.close(child_fd)
|
|
continue
|
|
try:
|
|
file_fd = os.open(name, _FILE_READ_FLAGS, dir_fd=directory_fd)
|
|
except OSError:
|
|
continue
|
|
try:
|
|
metadata = os.fstat(file_fd)
|
|
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > max_file_bytes:
|
|
continue
|
|
remaining = max_total_bytes - total[0]
|
|
if metadata.st_size > remaining:
|
|
continue
|
|
with os.fdopen(file_fd, 'rb', closefd=False) as file_obj:
|
|
data = file_obj.read(max_file_bytes + 1)
|
|
if len(data) > max_file_bytes or len(data) > remaining:
|
|
continue
|
|
output.append((relative, data))
|
|
total[0] += len(data)
|
|
finally:
|
|
os.close(file_fd)
|
|
|
|
|
|
def read_regular_files(
|
|
root: str,
|
|
subdir: str,
|
|
query_key: str,
|
|
*,
|
|
max_file_bytes: int,
|
|
max_files: int,
|
|
max_total_bytes: int,
|
|
max_entries: int = 512,
|
|
max_directories: int = 64,
|
|
) -> list[tuple[str, bytes]]:
|
|
"""Read bounded regular files without following tenant-created links."""
|
|
|
|
output: list[tuple[str, bytes]] = []
|
|
try:
|
|
with _query_fd(root, subdir, query_key, create=False) as query_fd:
|
|
_read_directory(
|
|
query_fd,
|
|
prefix='',
|
|
max_file_bytes=max_file_bytes,
|
|
max_files=max_files,
|
|
max_total_bytes=max_total_bytes,
|
|
output=output,
|
|
total=[0],
|
|
remaining_entries=[max_entries],
|
|
remaining_directories=[max_directories],
|
|
depth=0,
|
|
)
|
|
except (FileNotFoundError, UnsafeWorkspacePathError):
|
|
# A missing directory is an empty outbox. An unsafe existing path is
|
|
# deliberately surfaced to the caller rather than followed.
|
|
if os.path.lexists(os.path.join(root, subdir, query_key)):
|
|
raise
|
|
return output
|
|
|
|
|
|
def reset_directory(root: str, subdir: str, query_key: str) -> None:
|
|
with _query_fd(root, subdir, query_key, create=True, reset=True):
|
|
return
|
|
|
|
|
|
def purge_subdirectory(root: str, subdir: str) -> None:
|
|
"""Remove one known subtree without following a hostile replacement link."""
|
|
|
|
with _root_fd(root) as root_fd:
|
|
_remove_entry(root_fd, _component(subdir))
|