Harden sandbox review paths
This commit is contained in:
parent
976b9466ed
commit
e3e1d38678
6 changed files with 649 additions and 72 deletions
|
|
@ -34,6 +34,7 @@ import io
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
# Code-interpreter convention prefixes. Remapping is gated on the prefix being
|
||||
# ABSENT (see _remap) so a genuine host mount / user dir is never shadowed.
|
||||
|
|
@ -56,20 +57,60 @@ _original_import = builtins.__import__
|
|||
_original_import_module = importlib.import_module
|
||||
|
||||
|
||||
def _path_is_in_sandbox(filename):
|
||||
def _initial_trusted_library_roots():
|
||||
"""Capture interpreter-managed package roots before sandbox code can edit sys.path."""
|
||||
roots = []
|
||||
for entry in sys.path:
|
||||
if not isinstance(entry, str) or not entry:
|
||||
continue
|
||||
try:
|
||||
path = os.path.realpath(entry)
|
||||
except OSError:
|
||||
continue
|
||||
if os.path.basename(path).lower() not in {"site-packages", "dist-packages"}:
|
||||
continue
|
||||
if path not in roots:
|
||||
roots.append(path)
|
||||
return tuple(roots)
|
||||
|
||||
|
||||
_TRUSTED_LIBRARY_ROOTS = _initial_trusted_library_roots()
|
||||
|
||||
|
||||
def _path_is_in_roots(filename, roots):
|
||||
if not isinstance(filename, str) or filename.startswith("<"):
|
||||
return False
|
||||
try:
|
||||
cwd = os.path.realpath(os.getcwd())
|
||||
path = os.path.realpath(filename)
|
||||
return os.path.commonpath((cwd, path)) == cwd
|
||||
return any(os.path.commonpath((root, path)) == root for root in roots)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _sandbox_code_requested_import():
|
||||
def _trusted_http_client_frame(frame):
|
||||
module = frame.f_globals.get("__name__", "")
|
||||
root = module.split(".", 1)[0]
|
||||
return root in {"httpx", "httpcore"} and _path_is_in_roots(
|
||||
frame.f_globals.get("__file__"), _TRUSTED_LIBRARY_ROOTS
|
||||
)
|
||||
|
||||
|
||||
def _trusted_httpx_in_call_stack(skip = 1):
|
||||
try:
|
||||
frame = sys._getframe(1)
|
||||
frame = sys._getframe(skip)
|
||||
except ValueError:
|
||||
return False
|
||||
while frame is not None:
|
||||
module = frame.f_globals.get("__name__", "")
|
||||
if module == "httpx" or module.startswith("httpx."):
|
||||
return _trusted_http_client_frame(frame)
|
||||
frame = frame.f_back
|
||||
return False
|
||||
|
||||
|
||||
def _sandbox_code_requested_import(skip = 1):
|
||||
try:
|
||||
frame = sys._getframe(skip)
|
||||
except ValueError:
|
||||
return True
|
||||
while frame is not None:
|
||||
|
|
@ -82,9 +123,7 @@ def _sandbox_code_requested_import():
|
|||
):
|
||||
frame = frame.f_back
|
||||
continue
|
||||
if module == "__main__" or _path_is_in_sandbox(frame.f_globals.get("__file__")):
|
||||
return True
|
||||
return False
|
||||
return not _trusted_http_client_frame(frame)
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -105,6 +144,91 @@ def _raise_blocked_network_module(root):
|
|||
)
|
||||
|
||||
|
||||
_HTTP_CORE_METADATA = frozenset(
|
||||
{
|
||||
"__cached__",
|
||||
"__doc__",
|
||||
"__file__",
|
||||
"__loader__",
|
||||
"__name__",
|
||||
"__package__",
|
||||
"__path__",
|
||||
"__spec__",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _GuardedHttpcoreModule(types.ModuleType):
|
||||
"""Keep httpx working while denying cached low-level APIs to sandbox code."""
|
||||
|
||||
def __getattribute__(self, name):
|
||||
if name not in _HTTP_CORE_METADATA and _sandbox_code_requested_import(2):
|
||||
_raise_blocked_network_module("httpcore")
|
||||
return types.ModuleType.__getattribute__(self, name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if _sandbox_code_requested_import(2):
|
||||
_raise_blocked_network_module("httpcore")
|
||||
return types.ModuleType.__setattr__(self, name, value)
|
||||
|
||||
def __delattr__(self, name):
|
||||
if _sandbox_code_requested_import(2):
|
||||
_raise_blocked_network_module("httpcore")
|
||||
return types.ModuleType.__delattr__(self, name)
|
||||
|
||||
|
||||
def _guard_httpcore_backend_method(cls, method_name):
|
||||
original = cls.__dict__.get(method_name)
|
||||
if not callable(original) or getattr(original, "_unsloth_httpcore_backend_guard", False):
|
||||
return
|
||||
|
||||
code = getattr(original, "__code__", None)
|
||||
if code is not None and code.co_flags & 0x80: # CO_COROUTINE
|
||||
|
||||
async def guarded(*args, **kwargs):
|
||||
if not _trusted_httpx_in_call_stack(2):
|
||||
_raise_blocked_network_module("httpcore")
|
||||
return await original(*args, **kwargs)
|
||||
|
||||
else:
|
||||
|
||||
def guarded(*args, **kwargs):
|
||||
if not _trusted_httpx_in_call_stack(2):
|
||||
_raise_blocked_network_module("httpcore")
|
||||
return original(*args, **kwargs)
|
||||
|
||||
guarded._unsloth_httpcore_backend_guard = True
|
||||
guarded.__name__ = getattr(original, "__name__", method_name)
|
||||
guarded.__qualname__ = getattr(original, "__qualname__", guarded.__name__)
|
||||
guarded.__doc__ = getattr(original, "__doc__", None)
|
||||
setattr(cls, method_name, guarded)
|
||||
|
||||
|
||||
def _guard_httpcore_network_backends(module):
|
||||
"""Guard httpcore's connection boundary even if module attribute lookup is bypassed."""
|
||||
seen = set()
|
||||
for value in vars(module).values():
|
||||
if not isinstance(value, type) or id(value) in seen:
|
||||
continue
|
||||
seen.add(id(value))
|
||||
_guard_httpcore_backend_method(value, "connect_tcp")
|
||||
_guard_httpcore_backend_method(value, "connect_unix_socket")
|
||||
|
||||
|
||||
def _guard_loaded_httpcore_modules():
|
||||
"""Harden httpcore modules loaded transitively by an approved high-level client."""
|
||||
for name, module in tuple(sys.modules.items()):
|
||||
if name != "httpcore" and not name.startswith("httpcore."):
|
||||
continue
|
||||
if not isinstance(module, types.ModuleType) or isinstance(module, _GuardedHttpcoreModule):
|
||||
continue
|
||||
spec = getattr(module, "__spec__", None)
|
||||
if getattr(spec, "_initializing", False):
|
||||
continue
|
||||
_guard_httpcore_network_backends(module)
|
||||
module.__class__ = _GuardedHttpcoreModule
|
||||
|
||||
|
||||
def _absolute_import_name(
|
||||
name,
|
||||
package = None,
|
||||
|
|
@ -134,17 +258,25 @@ def _guarded_import(
|
|||
level = 0,
|
||||
):
|
||||
package = globals.get("__package__") if isinstance(globals, dict) else None
|
||||
root = _blocked_network_module(_absolute_import_name(name, package, level))
|
||||
absolute_name = _absolute_import_name(name, package, level)
|
||||
root = _blocked_network_module(absolute_name)
|
||||
if root is not None:
|
||||
_raise_blocked_network_module(root)
|
||||
return _original_import(name, globals, locals, fromlist, level)
|
||||
module = _original_import(name, globals, locals, fromlist, level)
|
||||
if isinstance(absolute_name, str) and absolute_name.split(".", 1)[0] == "httpcore":
|
||||
_guard_loaded_httpcore_modules()
|
||||
return module
|
||||
|
||||
|
||||
def _guarded_import_module(name, package = None):
|
||||
root = _blocked_network_module(_absolute_import_name(name, package))
|
||||
absolute_name = _absolute_import_name(name, package)
|
||||
root = _blocked_network_module(absolute_name)
|
||||
if root is not None:
|
||||
_raise_blocked_network_module(root)
|
||||
return _original_import_module(name, package)
|
||||
module = _original_import_module(name, package)
|
||||
if isinstance(absolute_name, str) and absolute_name.split(".", 1)[0] == "httpcore":
|
||||
_guard_loaded_httpcore_modules()
|
||||
return module
|
||||
|
||||
|
||||
def _network_import_audit(event, args):
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
(DuckDuckGo), Python code execution, and terminal commands."""
|
||||
|
||||
import ast
|
||||
import base64
|
||||
import binascii
|
||||
import codecs
|
||||
import fnmatch
|
||||
import http.client
|
||||
|
|
@ -207,6 +209,154 @@ def _env_assignment_is_unsafe(name: str) -> bool:
|
|||
|
||||
|
||||
_FIND_EXEC_FLAGS = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
|
||||
_SANDBOX_PYTHON_ENV_VARS = frozenset(
|
||||
{"PATH", "PYTHONHOME", "PYTHONPATH", "UNSLOTH_STUDIO_SANDBOXED"}
|
||||
)
|
||||
_PYTHON_LAUNCH_WRAPPERS = _COMMAND_PREFIXES | frozenset({"conda", "hatch", "pipx", "poetry", "uv"})
|
||||
|
||||
|
||||
def _python_executable_token(token: str) -> bool:
|
||||
base = os.path.basename(token.replace("\\", "/")).lower()
|
||||
if base.endswith(".exe"):
|
||||
base = base[:-4]
|
||||
return bool(re.fullmatch(r"(?:python(?:w)?(?:\d+(?:\.\d+)*)?|py)", base))
|
||||
|
||||
|
||||
def _shell_command_segments(command: str) -> list[list[str]]:
|
||||
try:
|
||||
lexer = shlex.shlex(command, posix = sys.platform != "win32", punctuation_chars = ";&|()`")
|
||||
lexer.whitespace_split = True
|
||||
tokens = list(lexer)
|
||||
except ValueError:
|
||||
tokens = command.split()
|
||||
segments: list[list[str]] = [[]]
|
||||
for token in tokens:
|
||||
if token in _SHELL_SEPARATORS:
|
||||
if segments[-1]:
|
||||
segments.append([])
|
||||
continue
|
||||
segments[-1].append(token.strip("\"'"))
|
||||
return [segment for segment in segments if segment]
|
||||
|
||||
|
||||
def _segment_python_index(segment: list[str]) -> int | None:
|
||||
command_index = 0
|
||||
while command_index < len(segment) and _ASSIGNMENT_RE.match(segment[command_index]):
|
||||
command_index += 1
|
||||
if command_index >= len(segment):
|
||||
return None
|
||||
command = os.path.basename(segment[command_index].replace("\\", "/")).lower()
|
||||
if _python_executable_token(segment[command_index]):
|
||||
return command_index
|
||||
if command not in _PYTHON_LAUNCH_WRAPPERS:
|
||||
if command not in {"find", "fd"}:
|
||||
return None
|
||||
for index in range(command_index + 1, len(segment)):
|
||||
if _python_executable_token(segment[index]) and any(
|
||||
token in _FIND_EXEC_FLAGS for token in segment[command_index:index]
|
||||
):
|
||||
return index
|
||||
return None
|
||||
for index in range(command_index + 1, len(segment)):
|
||||
if _python_executable_token(segment[index]):
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def _python_flags_skip_sitecustomize(arguments: list[str]) -> bool:
|
||||
skip_next = False
|
||||
for argument in arguments:
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if argument in {"-c", "-m"} or not argument.startswith("-"):
|
||||
break
|
||||
if argument == "--":
|
||||
break
|
||||
if argument in {"--ignore-environment", "--isolated", "--no-site"}:
|
||||
return True
|
||||
if re.fullmatch(r"-[^-]*[SEI][^-]*", argument):
|
||||
return True
|
||||
if argument in {"-W", "-X", "--check-hash-based-pycs"}:
|
||||
skip_next = True
|
||||
return False
|
||||
|
||||
|
||||
def _segment_mutates_sandbox_python_env(segment: list[str], python_index: int) -> bool:
|
||||
before_python = segment[:python_index]
|
||||
for index, token in enumerate(before_python):
|
||||
assignment = _ASSIGNMENT_RE.match(token)
|
||||
if assignment and token.split("=", 1)[0].upper() in _SANDBOX_PYTHON_ENV_VARS:
|
||||
return True
|
||||
lowered = token.lower()
|
||||
if lowered in {"-i", "--ignore-environment"} and before_python:
|
||||
if os.path.basename(before_python[0]).lower() == "env":
|
||||
return True
|
||||
if lowered.startswith("--unset="):
|
||||
if token.split("=", 1)[1].upper() in _SANDBOX_PYTHON_ENV_VARS:
|
||||
return True
|
||||
if lowered in {"-u", "--unset"} and index + 1 < len(before_python):
|
||||
if before_python[index + 1].upper() in _SANDBOX_PYTHON_ENV_VARS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _segment_persistently_mutates_sandbox_python_env(segment: list[str]) -> bool:
|
||||
if not segment:
|
||||
return False
|
||||
first = os.path.basename(segment[0].replace("\\", "/")).lower()
|
||||
if all(_ASSIGNMENT_RE.match(token) for token in segment):
|
||||
return any(token.split("=", 1)[0].upper() in _SANDBOX_PYTHON_ENV_VARS for token in segment)
|
||||
if first in {"unset", "unsetenv"}:
|
||||
return any(token.upper() in _SANDBOX_PYTHON_ENV_VARS for token in segment[1:])
|
||||
if first in {"export", "set", "setenv"}:
|
||||
return any(
|
||||
token.split("=", 1)[0].upper() in _SANDBOX_PYTHON_ENV_VARS for token in segment[1:]
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _sandbox_python_startup_bypasses_guard(command: str, depth: int = 0) -> bool:
|
||||
"""Detect terminal-launched Python that suppresses the sandbox sitecustomize guard."""
|
||||
if depth > 4:
|
||||
return True
|
||||
environment_tainted = False
|
||||
shell_names = {"bash", "cmd", "cmd.exe", "dash", "fish", "ksh", "sh", "zsh"}
|
||||
for segment in _shell_command_segments(command):
|
||||
first = os.path.basename(segment[0].replace("\\", "/")).lower()
|
||||
wrapper_context = first in _PYTHON_LAUNCH_WRAPPERS
|
||||
for shell_index, shell_token in enumerate(segment):
|
||||
shell = os.path.basename(shell_token.replace("\\", "/")).lower()
|
||||
if shell not in shell_names or (shell_index and not wrapper_context):
|
||||
continue
|
||||
for index in range(shell_index + 1, len(segment) - 1):
|
||||
token = segment[index]
|
||||
if token.lower() == "/c" or (token.startswith("-") and token.lower().endswith("c")):
|
||||
nested = segment[index + 1]
|
||||
if _segment_mutates_sandbox_python_env(segment, shell_index):
|
||||
nested = f"PYTHONPATH=; {nested}"
|
||||
if _sandbox_python_startup_bypasses_guard(nested, depth + 1):
|
||||
return True
|
||||
break
|
||||
break
|
||||
if first == "env":
|
||||
for index, token in enumerate(segment):
|
||||
if token in {"-S", "--split-string"} and index + 1 < len(segment):
|
||||
if _sandbox_python_startup_bypasses_guard(segment[index + 1], depth + 1):
|
||||
return True
|
||||
elif token.startswith("--split-string="):
|
||||
if _sandbox_python_startup_bypasses_guard(token.split("=", 1)[1], depth + 1):
|
||||
return True
|
||||
python_index = _segment_python_index(segment)
|
||||
if python_index is not None:
|
||||
if environment_tainted or _segment_mutates_sandbox_python_env(segment, python_index):
|
||||
return True
|
||||
if _python_flags_skip_sitecustomize(segment[python_index + 1 :]):
|
||||
return True
|
||||
environment_tainted = (
|
||||
environment_tainted or _segment_persistently_mutates_sandbox_python_env(segment)
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _find_blocked_commands(command: str) -> set[str]:
|
||||
|
|
@ -2436,11 +2586,12 @@ _RENDER_HTML_SET_ATTRIBUTE_START_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
_RENDER_HTML_PROPERTY_ASSIGNMENT_START_RE = re.compile(
|
||||
r"\.\s*(?P<attr>src|href|srcset|action|formaction|poster|data|ping)\s*=(?!=)",
|
||||
r"\.\s*(?P<attr>src|href|srcset|action|formaction|poster|data|ping|srcdoc)\s*"
|
||||
r"(?P<operator>\+=|&&=|\|\|=|\?\?=|=(?!=))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RENDER_HTML_MARKUP_ASSIGNMENT_START_RE = re.compile(
|
||||
r"\.\s*(?:innerHTML|outerHTML)\s*=(?!=)",
|
||||
r"\.\s*(?:innerHTML|outerHTML)\s*(?:\+=|&&=|\|\|=|\?\?=|=(?!=))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RENDER_HTML_MARKUP_CALL_START_RE = re.compile(
|
||||
|
|
@ -2449,6 +2600,26 @@ _RENDER_HTML_MARKUP_CALL_START_RE = re.compile(
|
|||
r"\s*(?:\?\.\s*)?\(",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RENDER_HTML_COMPUTED_ASSIGNMENT_START_RE = re.compile(
|
||||
r"\[\s*(?P<member>[^\]]+)\s*\]\s*(?:\+=|&&=|\|\|=|\?\?=|=(?!=))",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_RENDER_HTML_COMPUTED_CALL_START_RE = re.compile(
|
||||
r"(?:(?P<document>\bdocument)\s*)?\[\s*(?P<member>[^\]]+)\s*\]\s*"
|
||||
r"(?:\?\.\s*)?\(",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_RENDER_HTML_REFLECT_SET_START_RE = re.compile(
|
||||
r"\bReflect\s*\.\s*set\s*(?:\?\.\s*)?\(", re.IGNORECASE
|
||||
)
|
||||
_RENDER_HTML_OBJECT_ASSIGN_START_RE = re.compile(
|
||||
r"\bObject\s*\.\s*assign\s*(?:\?\.\s*)?\(", re.IGNORECASE
|
||||
)
|
||||
_RENDER_HTML_OBJECT_PROPERTY_START_RE = re.compile(
|
||||
r"(?P<quote>['\"]?)(?P<member>src|href|srcset|action|formaction|poster|data|ping|"
|
||||
r"srcdoc|innerHTML|outerHTML)(?P=quote)\s*:\s*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RENDER_HTML_NETWORK_MEMBERS = frozenset(
|
||||
{
|
||||
"fetch",
|
||||
|
|
@ -2466,6 +2637,9 @@ _RENDER_HTML_NETWORK_ATTRIBUTES = frozenset(
|
|||
)
|
||||
_RENDER_HTML_URL_LIST_ATTRIBUTES = frozenset({"srcset", "ping"})
|
||||
_RENDER_HTML_URL_LIST_NETWORK_RE = re.compile(r"(?:^|[\s,])(?:https?:|/)", re.IGNORECASE)
|
||||
_RENDER_HTML_ACTIVE_DATA_MIME_TYPES = frozenset(
|
||||
{"text/html", "application/xhtml+xml", "image/svg+xml"}
|
||||
)
|
||||
|
||||
|
||||
def _leading_js_string(expression: str) -> tuple[str, int] | None:
|
||||
|
|
@ -2592,10 +2766,37 @@ def _static_js_assignment_string(expression: str) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def _render_html_attribute_reaches_network(name: str, value: str | None) -> bool:
|
||||
def _render_html_data_document_reaches_network(value: str, depth: int) -> bool:
|
||||
"""Inspect executable document payloads embedded in data: URLs."""
|
||||
if not value.lower().startswith("data:"):
|
||||
return False
|
||||
header, separator, payload = value[5:].partition(",")
|
||||
if not separator:
|
||||
return True
|
||||
parts = [part.strip() for part in header.split(";")]
|
||||
media_type = (parts[0] or "text/plain").lower()
|
||||
if media_type not in _RENDER_HTML_ACTIVE_DATA_MIME_TYPES:
|
||||
return False
|
||||
try:
|
||||
payload_bytes = urllib.parse.unquote_to_bytes(payload)
|
||||
if any(part.lower() == "base64" for part in parts[1:]):
|
||||
payload_bytes = base64.b64decode(b"".join(payload_bytes.split()), validate = True)
|
||||
markup = payload_bytes.decode("utf-8", errors = "replace")
|
||||
except (ValueError, binascii.Error):
|
||||
return True
|
||||
return _render_html_code_reaches_network(markup, depth + 1)
|
||||
|
||||
|
||||
def _render_html_attribute_reaches_network(
|
||||
name: str,
|
||||
value: str | None,
|
||||
depth: int = 0,
|
||||
) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
value = value.lstrip()
|
||||
if value.lower().startswith("data:"):
|
||||
return _render_html_data_document_reaches_network(value, depth)
|
||||
if name in _RENDER_HTML_URL_LIST_ATTRIBUTES:
|
||||
return bool(_RENDER_HTML_URL_LIST_NETWORK_RE.search(value))
|
||||
return value.lower().startswith(("http:", "https:", "/"))
|
||||
|
|
@ -2617,7 +2818,7 @@ class _RenderHtmlAttributeParser(HTMLParser):
|
|||
if name == "xlink:href":
|
||||
name = "href"
|
||||
if name in _RENDER_HTML_NETWORK_ATTRIBUTES and _render_html_attribute_reaches_network(
|
||||
name, value
|
||||
name, value, self.depth
|
||||
):
|
||||
self.reaches_network = True
|
||||
return
|
||||
|
|
@ -2667,6 +2868,51 @@ def _js_call_arguments(code: str, offset: int) -> list[str] | None:
|
|||
return None
|
||||
|
||||
|
||||
def _render_html_assigned_member_reaches_network(
|
||||
member: str, value: str | None, depth: int
|
||||
) -> bool:
|
||||
member = member.lower()
|
||||
if member in {"innerhtml", "outerhtml", "srcdoc"}:
|
||||
return value is None or _render_html_code_reaches_network(value, depth + 1)
|
||||
if member in _RENDER_HTML_NETWORK_ATTRIBUTES:
|
||||
return value is None or _render_html_attribute_reaches_network(member, value, depth)
|
||||
return False
|
||||
|
||||
|
||||
def _render_html_set_attribute_arguments(arguments: list[str], method: str, depth: int) -> bool:
|
||||
if method == "setattributens":
|
||||
name_index, value_index = 1, 2
|
||||
else:
|
||||
name_index, value_index = 0, 1
|
||||
if len(arguments) <= value_index:
|
||||
return False
|
||||
name = _static_js_string(arguments[name_index])
|
||||
value = _static_js_string(arguments[value_index])
|
||||
if name is None:
|
||||
return bool(
|
||||
value is None
|
||||
or _RENDER_HTML_URL_LIST_NETWORK_RE.search(value.lstrip())
|
||||
or _render_html_code_reaches_network(value, depth + 1)
|
||||
)
|
||||
name = name.lower().rsplit(":", 1)[-1]
|
||||
return _render_html_assigned_member_reaches_network(name, value, depth)
|
||||
|
||||
|
||||
def _render_html_markup_call_reaches_network(arguments: list[str], method: str, depth: int) -> bool:
|
||||
if method == "insertadjacenthtml":
|
||||
if len(arguments) < 2:
|
||||
return False
|
||||
markup = _static_js_string(arguments[1])
|
||||
else:
|
||||
if not arguments:
|
||||
return False
|
||||
parts = [_static_js_string(argument) for argument in arguments]
|
||||
markup = "".join(part for part in parts if part is not None)
|
||||
if any(part is None for part in parts):
|
||||
markup = None
|
||||
return markup is None or _render_html_code_reaches_network(markup, depth + 1)
|
||||
|
||||
|
||||
def _render_html_computed_network_access(code: str, depth: int = 0) -> bool:
|
||||
for match in _RENDER_HTML_GLOBAL_BRACKET_RE.finditer(code):
|
||||
expression = match.group(1)
|
||||
|
|
@ -2689,29 +2935,15 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool:
|
|||
arguments = _js_call_arguments(code, match.end())
|
||||
if arguments is None:
|
||||
return True
|
||||
if match.group("method").lower() == "setattributens":
|
||||
name_index, value_index = 1, 2
|
||||
else:
|
||||
name_index, value_index = 0, 1
|
||||
if len(arguments) <= value_index:
|
||||
continue
|
||||
name = _static_js_string(arguments[name_index])
|
||||
value = _static_js_string(arguments[value_index])
|
||||
if name is None:
|
||||
if value is None or _RENDER_HTML_URL_LIST_NETWORK_RE.search(value.lstrip()):
|
||||
return True
|
||||
continue
|
||||
name = name.lower().rsplit(":", 1)[-1]
|
||||
if name not in _RENDER_HTML_NETWORK_ATTRIBUTES:
|
||||
continue
|
||||
if value is None or _render_html_attribute_reaches_network(name, value):
|
||||
if _render_html_set_attribute_arguments(arguments, match.group("method").lower(), depth):
|
||||
return True
|
||||
|
||||
for match in _RENDER_HTML_PROPERTY_ASSIGNMENT_START_RE.finditer(code):
|
||||
value = _static_js_assignment_string(code[match.end() :])
|
||||
if value is None:
|
||||
return True
|
||||
if _render_html_attribute_reaches_network(match.group("attr").lower(), value):
|
||||
name = match.group("attr").lower()
|
||||
if _render_html_assigned_member_reaches_network(name, value, depth):
|
||||
return True
|
||||
|
||||
for match in _RENDER_HTML_MARKUP_ASSIGNMENT_START_RE.finditer(code):
|
||||
|
|
@ -2726,19 +2958,58 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool:
|
|||
if arguments is None:
|
||||
return True
|
||||
method = (match.group("insert") or match.group("write")).lower()
|
||||
if method == "insertadjacenthtml":
|
||||
if len(arguments) < 2:
|
||||
continue
|
||||
markup = _static_js_string(arguments[1])
|
||||
else:
|
||||
if not arguments:
|
||||
continue
|
||||
parts = [_static_js_string(argument) for argument in arguments]
|
||||
markup = "".join(part for part in parts if part is not None)
|
||||
if any(part is None for part in parts):
|
||||
markup = None
|
||||
if markup is None or _render_html_code_reaches_network(markup, depth + 1):
|
||||
if _render_html_markup_call_reaches_network(arguments, method, depth):
|
||||
return True
|
||||
|
||||
for match in _RENDER_HTML_COMPUTED_ASSIGNMENT_START_RE.finditer(code):
|
||||
member = _static_js_string(match.group("member"))
|
||||
if member is None:
|
||||
continue
|
||||
value = _static_js_assignment_string(code[match.end() :])
|
||||
if _render_html_assigned_member_reaches_network(member, value, depth):
|
||||
return True
|
||||
|
||||
for match in _RENDER_HTML_COMPUTED_CALL_START_RE.finditer(code):
|
||||
method = _static_js_string(match.group("member"))
|
||||
if method is None:
|
||||
continue
|
||||
method = method.lower()
|
||||
arguments = _js_call_arguments(code, match.end())
|
||||
if arguments is None:
|
||||
return True
|
||||
if method in {"setattribute", "setattributens"}:
|
||||
if _render_html_set_attribute_arguments(arguments, method, depth):
|
||||
return True
|
||||
elif method == "insertadjacenthtml" or (
|
||||
method in {"write", "writeln"} and match.group("document")
|
||||
):
|
||||
if _render_html_markup_call_reaches_network(arguments, method, depth):
|
||||
return True
|
||||
|
||||
for match in _RENDER_HTML_REFLECT_SET_START_RE.finditer(code):
|
||||
arguments = _js_call_arguments(code, match.end())
|
||||
if arguments is None:
|
||||
return True
|
||||
if len(arguments) < 3:
|
||||
continue
|
||||
member = _static_js_string(arguments[1])
|
||||
if member is None:
|
||||
continue
|
||||
value = _static_js_string(arguments[2])
|
||||
if _render_html_assigned_member_reaches_network(member, value, depth):
|
||||
return True
|
||||
|
||||
for match in _RENDER_HTML_OBJECT_ASSIGN_START_RE.finditer(code):
|
||||
arguments = _js_call_arguments(code, match.end())
|
||||
if arguments is None:
|
||||
return True
|
||||
for source in arguments[1:]:
|
||||
for property_match in _RENDER_HTML_OBJECT_PROPERTY_START_RE.finditer(source):
|
||||
value = _static_js_assignment_string(source[property_match.end() :])
|
||||
if _render_html_assigned_member_reaches_network(
|
||||
property_match.group("member"), value, depth
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -6217,6 +6488,8 @@ def _bash_exec(
|
|||
|
||||
# Block dangerous commands (skipped when the sandbox is disabled)
|
||||
if not disable_sandbox:
|
||||
if _sandbox_python_startup_bypasses_guard(command):
|
||||
return "Blocked: sandboxed Python cannot disable the Studio runtime guard."
|
||||
blocked = _find_blocked_commands(command)
|
||||
if blocked:
|
||||
return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}"
|
||||
|
|
|
|||
|
|
@ -159,6 +159,67 @@ def test_bash_blocklist_enforced_when_sandboxed(captured_popen):
|
|||
assert "cmd" not in captured_popen # never reached Popen
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
'python -S -c "import boto3"',
|
||||
'python -E -c "import boto3"',
|
||||
'python -I -c "import boto3"',
|
||||
'python --no-site -c "import boto3"',
|
||||
'python --ignore-environment -c "import boto3"',
|
||||
'python --isolated -c "import boto3"',
|
||||
'env -u PYTHONPATH python -c "import boto3"',
|
||||
'env --unset=UNSLOTH_STUDIO_SANDBOXED python3 -c "import boto3"',
|
||||
'env -i python -c "import boto3"',
|
||||
'PYTHONPATH= python -c "import boto3"',
|
||||
'unset PYTHONPATH; python -c "import boto3"',
|
||||
'export UNSLOTH_STUDIO_SANDBOXED=0; python -c "import boto3"',
|
||||
'uv run python -S -c "import boto3"',
|
||||
'bash -lc "python -I -c import\\ boto3"',
|
||||
'env -S "python -S -c import\\ boto3"',
|
||||
'env --split-string="python -I -c import\\ boto3"',
|
||||
'env -u PYTHONPATH sh -c "python -c import\\ boto3"',
|
||||
'command sh -c "python -I -c import\\ boto3"',
|
||||
'find . -exec python -S -c "import boto3" ;',
|
||||
],
|
||||
)
|
||||
def test_bash_blocks_python_startup_guard_bypasses(captured_popen, command):
|
||||
out = _bash_exec(command, None, 5, "t", disable_sandbox = False)
|
||||
assert "cannot disable the Studio runtime guard" in out
|
||||
assert "cmd" not in captured_popen
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
'python -c "print(1)"',
|
||||
"python script.py -S",
|
||||
"echo python -S",
|
||||
"python -c \"print('-S')\"",
|
||||
],
|
||||
)
|
||||
def test_bash_allows_python_without_startup_guard_bypass(captured_popen, command):
|
||||
out = _bash_exec(command, None, 5, "t", disable_sandbox = False)
|
||||
assert out == "FAKEOUT"
|
||||
assert "cmd" in captured_popen
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("command", "blocked"),
|
||||
[
|
||||
('py -3.12 -I -c "import boto3"', True),
|
||||
('C:\\Python312\\python.exe -S -c "import boto3"', True),
|
||||
('set PYTHONPATH= & python -c "import boto3"', True),
|
||||
('cmd /c "python -E -c import boto3"', True),
|
||||
('python.exe -c "print(1)"', False),
|
||||
("echo python -S", False),
|
||||
],
|
||||
)
|
||||
def test_python_startup_guard_windows_command_parsing(monkeypatch, command, blocked):
|
||||
monkeypatch.setattr(tools.sys, "platform", "win32")
|
||||
assert tools._sandbox_python_startup_bypasses_guard(command) is blocked
|
||||
|
||||
|
||||
def test_bash_blocklist_skipped_when_bypassed(captured_popen):
|
||||
out = _bash_exec("rm -rf /", None, 5, "t", disable_sandbox = True)
|
||||
assert out == "FAKEOUT" # blocklist skipped -> reached (faked) execution
|
||||
|
|
|
|||
|
|
@ -1080,8 +1080,34 @@ def test_render_html_gated_only_when_networked():
|
|||
assert rh("<script>const i={};i.src='./local.png'.replace('local','/api')</script>") is True
|
||||
assert rh("<script>const i={};i.srcset='local.png 1x, https://evil/x 2x'</script>") is True
|
||||
assert rh("<script>document.body.innerHTML='<img src=https://evil/x>'</script>") is True
|
||||
assert rh("<script>document.body.innerHTML += '<img src=https://evil/x>'</script>") is True
|
||||
assert rh("<script>document.body.innerHTML += '<p>Local</p>'</script>") is False
|
||||
assert rh("<script>document.body.innerHTML ||= '<img src=https://evil/x>'</script>") is True
|
||||
assert rh("<script>document.body.innerHTML='<p>Local</p>'</script>") is False
|
||||
assert rh("<script>document.body.innerHTML=markup</script>") is True
|
||||
assert rh("<script>frame.srcdoc='<img src=https://evil/x>'</script>") is True
|
||||
assert rh("<script>frame.srcdoc='<h1>Local</h1>'</script>") is False
|
||||
assert rh("<script>frame.srcdoc=markup</script>") is True
|
||||
assert rh("<script>frame.setAttribute('srcdoc','<img src=https://evil/x>')</script>") is True
|
||||
assert rh("<script>frame.setAttribute('srcdoc','<h1>Local</h1>')</script>") is False
|
||||
assert rh("<script>img['src']='https://evil/x'</script>") is True
|
||||
assert rh("<script>frame['srcdoc']='<img src=https://evil/x>'</script>") is True
|
||||
assert rh("<script>document.body['inner'+'HTML']='<img src=https://evil/x>'</script>") is True
|
||||
assert rh("<script>img['setAttribute']('src','https://evil/x')</script>") is True
|
||||
assert (
|
||||
rh("<script>node['insertAdjacentHTML']('beforeend','<img src=https://evil/x>')</script>")
|
||||
is True
|
||||
)
|
||||
assert rh("<script>Reflect.set(img,'src','https://evil/x')</script>") is True
|
||||
assert rh("<script>Object.assign(img,{src:'https://evil/x'})</script>") is True
|
||||
assert rh("<script>Object.assign(frame,{'srcdoc':'<img src=https://evil/x>'})</script>") is True
|
||||
assert rh("<script>img['src']='./local.png'</script>") is False
|
||||
assert rh("<script>img['setAttribute']('src','./local.png')</script>") is False
|
||||
assert rh("<script>Reflect.set(obj,'title','https://evil/x')</script>") is False
|
||||
assert (
|
||||
rh("<script>Object.assign(obj,{src:'./local.png',title:'https://evil/x'})</script>")
|
||||
is False
|
||||
)
|
||||
assert rh("<script>node.outerHTML='<script>fetch(1)<\\/script>'</script>") is True
|
||||
assert rh("<script>node.insertAdjacentHTML('beforeend','<img src=/api/x>')</script>") is True
|
||||
assert rh("<script>document.write('<img sr','c=https://evil/x>')</script>") is True
|
||||
|
|
@ -1103,6 +1129,20 @@ def test_render_html_gated_only_when_networked():
|
|||
)
|
||||
is False
|
||||
)
|
||||
assert rh('<iframe src="data:text/html,<img src=https://evil/x>"></iframe>') is True
|
||||
assert (
|
||||
rh('<iframe src="data:text/html,%3Cimg%20src%3Dhttps%3A%2F%2Fevil%2Fx%3E"></iframe>')
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
rh('<iframe src="data:text/html;base64,PGltZyBzcmM9aHR0cHM6Ly9ldmlsL3g+"></iframe>') is True
|
||||
)
|
||||
assert rh('<iframe src="data:text/html,<h1>Local</h1>"></iframe>') is False
|
||||
assert rh('<iframe src="data:text/plain,<img src=https://evil/x>"></iframe>') is False
|
||||
assert rh('<img src="data:image/png;base64,iVBORw0KGgo=">') is False
|
||||
assert rh('<object data="data:image/svg+xml,<image href=https://evil/x>"></object>') is True
|
||||
assert rh('<iframe src="data:text/html;base64,not-valid-***"></iframe>') is True
|
||||
assert rh("<script>frame.src='data:text/html,<img src=https://evil/x>'</script>") is True
|
||||
assert (
|
||||
rh(
|
||||
"<script>const i=document.createElement('img');"
|
||||
|
|
@ -1122,10 +1162,7 @@ def test_render_html_gated_only_when_networked():
|
|||
assert rh("<script>const i={};i.setAttribute('disabled')</script>") is False
|
||||
assert rh("<script>const i={};i.src='./local.png'</script>") is False
|
||||
assert (
|
||||
rh(
|
||||
"<script>const a=document.createElement('a');"
|
||||
"a.setAttribute('href','#section')</script>"
|
||||
)
|
||||
rh("<script>const a=document.createElement('a');a.setAttribute('href','#section')</script>")
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
|
|
|
|||
|
|
@ -144,14 +144,8 @@ class TestLowLevelNetworkModules:
|
|||
"load = getattr(importlib, 'import_' + 'module'); "
|
||||
"print(load('boto3').__name__)"
|
||||
),
|
||||
(
|
||||
"import importlib; "
|
||||
"print(getattr(importlib, 'import_module')(name='boto3').__name__)"
|
||||
),
|
||||
(
|
||||
"import importlib; "
|
||||
"print(importlib.import_module(name='botocore.session').__name__)"
|
||||
),
|
||||
("import importlib; print(getattr(importlib, 'import_module')(name='boto3').__name__)"),
|
||||
("import importlib; print(importlib.import_module(name='botocore.session').__name__)"),
|
||||
("import importlib; print(vars(importlib)['import_module']('httpcore').__name__)"),
|
||||
("import importlib; print(importlib.__dict__['import_module']('boto3').__name__)"),
|
||||
("import builtins; print(getattr(builtins, '__import__')('botocore').__name__)"),
|
||||
|
|
@ -164,10 +158,7 @@ class TestLowLevelNetworkModules:
|
|||
"code",
|
||||
[
|
||||
"m = __import__('statistics'); print(m.mean([1, 2]))",
|
||||
(
|
||||
"from importlib import import_module as load; "
|
||||
"print(load('statistics').mean([1, 2]))"
|
||||
),
|
||||
("from importlib import import_module as load; print(load('statistics').mean([1, 2]))"),
|
||||
(
|
||||
"import importlib; "
|
||||
"print(getattr(importlib, 'import_module')(name='statistics').mean([1, 2]))"
|
||||
|
|
@ -280,7 +271,7 @@ class TestUploadDenylist:
|
|||
)
|
||||
|
||||
def test_plain_post_json_not_blocked(self):
|
||||
_ok("import requests\n" 'requests.post("https://api.weather.gov/lookup", json={"k": "v"})')
|
||||
_ok('import requests\nrequests.post("https://api.weather.gov/lookup", json={"k": "v"})')
|
||||
|
||||
|
||||
class TestSandboxEnvIsolation:
|
||||
|
|
@ -462,6 +453,76 @@ class TestSandboxEnvIsolation:
|
|||
assert result.returncode != 0
|
||||
assert "Blocked: low-level network module 'httpcore'" in result.stderr
|
||||
|
||||
@pytest.mark.parametrize("module_name", ["loader", "httpx"])
|
||||
def test_runtime_import_guard_blocks_external_module_httpcore_import(
|
||||
self, tmp_path, module_name
|
||||
):
|
||||
from core.inference.tools import _build_safe_env
|
||||
|
||||
workdir = tmp_path / "sandbox"
|
||||
external = tmp_path / "external"
|
||||
workdir.mkdir()
|
||||
external.mkdir()
|
||||
(external / f"{module_name}.py").write_text(
|
||||
"name = ''.join(['http', 'core'])\nprint(__import__(name).__name__)\n",
|
||||
encoding = "utf-8",
|
||||
)
|
||||
code = f"import sys; sys.path.insert(0, {str(external)!r}); import {module_name}"
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
cwd = workdir,
|
||||
env = _build_safe_env(str(workdir)),
|
||||
capture_output = True,
|
||||
text = True,
|
||||
check = False,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Blocked: low-level network module 'httpcore'" in result.stderr
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
(
|
||||
"import httpx, sys; client = httpx.Client(); client.close(); "
|
||||
"print(sys.modules['httpcore'].request)"
|
||||
),
|
||||
(
|
||||
"import httpx, sys; client = httpx.Client(); client.close(); "
|
||||
"print(sys.modules['httpcore._sync.connection_pool'].ConnectionPool)"
|
||||
),
|
||||
(
|
||||
"import httpx, sys, types; client = httpx.Client(); client.close(); "
|
||||
"module = sys.modules['httpcore']; "
|
||||
"request = types.ModuleType.__getattribute__(module, 'request'); "
|
||||
"request('GET', 'http://127.0.0.1:9/probe')"
|
||||
),
|
||||
(
|
||||
"import asyncio, httpx, sys, types\n"
|
||||
"async def main():\n"
|
||||
" async with httpx.AsyncClient():\n"
|
||||
" pass\n"
|
||||
" module = sys.modules['httpcore']\n"
|
||||
" pool_type = types.ModuleType.__getattribute__(module, 'AsyncConnectionPool')\n"
|
||||
" async with pool_type() as pool:\n"
|
||||
" await pool.request('GET', 'http://127.0.0.1:9/probe')\n"
|
||||
"asyncio.run(main())"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_runtime_import_guard_blocks_cached_httpcore_access(self, tmp_path, code):
|
||||
from core.inference.tools import _build_safe_env
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
cwd = tmp_path,
|
||||
env = _build_safe_env(str(tmp_path)),
|
||||
capture_output = True,
|
||||
text = True,
|
||||
check = False,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Blocked: low-level network module 'httpcore'" in result.stderr
|
||||
|
||||
@pytest.mark.parametrize("module", ["httpx", "requests", "huggingface_hub"])
|
||||
def test_runtime_import_guard_keeps_supported_clients_available(self, tmp_path, module):
|
||||
from core.inference.tools import _build_safe_env
|
||||
|
|
@ -477,6 +538,21 @@ class TestSandboxEnvIsolation:
|
|||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == module
|
||||
|
||||
def test_runtime_import_guard_keeps_httpx_transport_available(self, tmp_path):
|
||||
from core.inference.tools import _build_safe_env
|
||||
|
||||
code = "import httpx; client = httpx.Client(); print(type(client).__name__); client.close()"
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
cwd = tmp_path,
|
||||
env = _build_safe_env(str(tmp_path)),
|
||||
capture_output = True,
|
||||
text = True,
|
||||
check = False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "Client"
|
||||
|
||||
def test_home_points_at_sandbox_workdir(self, tmp_path):
|
||||
from core.inference.tools import _build_safe_env
|
||||
|
||||
|
|
@ -693,15 +769,11 @@ class TestHfUploadImportGate:
|
|||
|
||||
def test_hf_bare_name_upload_folder_safe_allowed(self):
|
||||
_ok(
|
||||
"from huggingface_hub import upload_folder;"
|
||||
" upload_folder(folder_path='x', repo_id='r')"
|
||||
"from huggingface_hub import upload_folder; upload_folder(folder_path='x', repo_id='r')"
|
||||
)
|
||||
|
||||
def test_hf_bare_name_create_commit_safe_allowed(self):
|
||||
_ok(
|
||||
"from huggingface_hub import create_commit;"
|
||||
" create_commit(operations=[], repo_id='r')"
|
||||
)
|
||||
_ok("from huggingface_hub import create_commit; create_commit(operations=[], repo_id='r')")
|
||||
|
||||
def test_bare_name_upload_file_without_hf_import_allowed(self):
|
||||
# No HF import -- local helper named upload_file passes.
|
||||
|
|
|
|||
|
|
@ -34,14 +34,16 @@ def test_model_selector_trigger_label_uses_leading_tight():
|
|||
|
||||
def test_sidebar_account_block_uses_leading_tight():
|
||||
src = _read(APP_SIDEBAR)
|
||||
# Match the account-block parent div regardless of its gap utility; this
|
||||
# guard is about the leading-* class, not the spacing.
|
||||
# Match class membership without assuming utility order.
|
||||
pattern = re.compile(
|
||||
r'<div\s+className="flex\s+flex-col\s+gap-\S+\s+(\S+)\s+group-data-\[collapsible=icon\]:hidden">',
|
||||
r'<div\s+className="([^"]*\bflex\b[^"]*\bflex-col\b'
|
||||
r'[^"]*\bgroup-data-\[collapsible=icon\]:hidden\b[^"]*)">',
|
||||
)
|
||||
matches = pattern.findall(src)
|
||||
assert matches, "could not find sidebar account-block parent div"
|
||||
leading_classes = [m for m in matches if m.startswith("leading-")]
|
||||
leading_classes = [
|
||||
token for classes in matches for token in classes.split() if token.startswith("leading-")
|
||||
]
|
||||
assert leading_classes, f"no leading-* class on sidebar account-block parent: {matches}"
|
||||
for cls in leading_classes:
|
||||
assert cls == "leading-tight", f"sidebar account-block must use leading-tight, got: {cls}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue