Skip to content

Commit 6bd88fe

Browse files
fix(sandbox): block host bash traversal escapes (bytedance#2560)
* fix(sandbox): block host bash traversal escapes Fixes bytedance#2535 * fix(sandbox): harden local bash path guards * fix(sandbox): avoid bash cd argument false positives * Fix the lint error Add function to resolve and validate user data path. * Fix the lint error --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
1 parent 39c5da9 commit 6bd88fe

2 files changed

Lines changed: 373 additions & 25 deletions

File tree

backend/packages/harness/deerflow/sandbox/tools.py

Lines changed: 253 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222

2323
_ABSOLUTE_PATH_PATTERN = re.compile(r"(?<![:\w])(?<!:/)/(?:[^\s\"'`;&|<>()]+)")
2424
_FILE_URL_PATTERN = re.compile(r"\bfile://\S+", re.IGNORECASE)
25+
_URL_WITH_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
26+
_URL_IN_COMMAND_PATTERN = re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s\"'`;&|<>()]+", re.IGNORECASE)
27+
_DOTDOT_PATH_SEGMENT_PATTERN = re.compile(r"(?:^|[/\\=])\.\.(?:$|[/\\])")
2528
_LOCAL_BASH_SYSTEM_PATH_PREFIXES = (
2629
"/bin/",
2730
"/usr/bin/",
@@ -37,6 +40,42 @@
3740
_MAX_GLOB_MAX_RESULTS = 1000
3841
_DEFAULT_GREP_MAX_RESULTS = 100
3942
_MAX_GREP_MAX_RESULTS = 500
43+
_LOCAL_BASH_CWD_COMMANDS = {"cd", "pushd"}
44+
_LOCAL_BASH_COMMAND_WRAPPERS = {"command", "builtin"}
45+
_LOCAL_BASH_COMMAND_PREFIX_KEYWORDS = {"!", "{", "case", "do", "elif", "else", "for", "if", "select", "then", "time", "until", "while"}
46+
_LOCAL_BASH_COMMAND_END_KEYWORDS = {"}", "done", "esac", "fi"}
47+
_LOCAL_BASH_ROOT_PATH_COMMANDS = {
48+
"awk",
49+
"cat",
50+
"cp",
51+
"du",
52+
"find",
53+
"grep",
54+
"head",
55+
"less",
56+
"ln",
57+
"ls",
58+
"more",
59+
"mv",
60+
"rm",
61+
"sed",
62+
"tail",
63+
"tar",
64+
}
65+
_SHELL_COMMAND_SEPARATORS = {";", "&&", "||", "|", "|&", "&", "(", ")"}
66+
_SHELL_REDIRECTION_OPERATORS = {
67+
"<",
68+
">",
69+
"<<",
70+
">>",
71+
"<<<",
72+
"<>",
73+
">&",
74+
"<&",
75+
"&>",
76+
"&>>",
77+
">|",
78+
}
4079

4180

4281
def _get_skills_container_path() -> str:
@@ -635,6 +674,214 @@ def _resolve_and_validate_user_data_path(path: str, thread_data: ThreadDataState
635674
return str(resolved)
636675

637676

677+
def _is_non_file_url_token(token: str) -> bool:
678+
"""Return True for URL tokens that should not be interpreted as paths."""
679+
values = [token]
680+
if "=" in token:
681+
values.append(token.split("=", 1)[1])
682+
683+
for value in values:
684+
match = _URL_WITH_SCHEME_PATTERN.match(value)
685+
if match and not value.lower().startswith("file://"):
686+
return True
687+
return False
688+
689+
690+
def _non_file_url_spans(command: str) -> list[tuple[int, int]]:
691+
spans = []
692+
for match in _URL_IN_COMMAND_PATTERN.finditer(command):
693+
if not match.group().lower().startswith("file://"):
694+
spans.append(match.span())
695+
return spans
696+
697+
698+
def _is_in_spans(position: int, spans: list[tuple[int, int]]) -> bool:
699+
return any(start <= position < end for start, end in spans)
700+
701+
702+
def _has_dotdot_path_segment(token: str) -> bool:
703+
if _is_non_file_url_token(token):
704+
return False
705+
return bool(_DOTDOT_PATH_SEGMENT_PATTERN.search(token))
706+
707+
708+
def _split_shell_tokens(command: str) -> list[str]:
709+
try:
710+
normalized = command.replace("\r\n", "\n").replace("\r", "\n").replace("\n", " ; ")
711+
lexer = shlex.shlex(normalized, posix=True, punctuation_chars=True)
712+
lexer.whitespace_split = True
713+
lexer.commenters = ""
714+
return list(lexer)
715+
except ValueError:
716+
# The shell will reject malformed quoting later; keep validation
717+
# best-effort instead of turning syntax errors into security messages.
718+
return command.split()
719+
720+
721+
def _is_shell_command_separator(token: str) -> bool:
722+
return token in _SHELL_COMMAND_SEPARATORS
723+
724+
725+
def _is_shell_redirection_operator(token: str) -> bool:
726+
return token in _SHELL_REDIRECTION_OPERATORS
727+
728+
729+
def _is_shell_assignment(token: str) -> bool:
730+
name, separator, _ = token.partition("=")
731+
if not separator or not name:
732+
return False
733+
return bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name))
734+
735+
736+
def _is_allowed_local_bash_absolute_path(path: str, allowed_paths: list[str], *, allow_system_paths: bool) -> bool:
737+
# Check for MCP filesystem server allowed paths
738+
if any(path.startswith(allowed_path) or path == allowed_path.rstrip("/") for allowed_path in allowed_paths):
739+
_reject_path_traversal(path)
740+
return True
741+
742+
if path == VIRTUAL_PATH_PREFIX or path.startswith(f"{VIRTUAL_PATH_PREFIX}/"):
743+
_reject_path_traversal(path)
744+
return True
745+
746+
# Allow skills container path (resolved by tools.py before passing to sandbox)
747+
if _is_skills_path(path):
748+
_reject_path_traversal(path)
749+
return True
750+
751+
# Allow ACP workspace path (path-traversal check only)
752+
if _is_acp_workspace_path(path):
753+
_reject_path_traversal(path)
754+
return True
755+
756+
# Allow custom mount container paths
757+
if _is_custom_mount_path(path):
758+
_reject_path_traversal(path)
759+
return True
760+
761+
if allow_system_paths and any(path == prefix.rstrip("/") or path.startswith(prefix) for prefix in _LOCAL_BASH_SYSTEM_PATH_PREFIXES):
762+
return True
763+
764+
return False
765+
766+
767+
def _next_cd_target(tokens: list[str], start_index: int) -> tuple[str | None, int]:
768+
index = start_index
769+
while index < len(tokens):
770+
token = tokens[index]
771+
if _is_shell_command_separator(token):
772+
return None, index
773+
if _is_shell_redirection_operator(token):
774+
index += 2
775+
continue
776+
if token == "--":
777+
index += 1
778+
continue
779+
if token in {"-L", "-P", "-e", "-@"}:
780+
index += 1
781+
continue
782+
if token.startswith("-") and token != "-":
783+
index += 1
784+
continue
785+
return token, index + 1
786+
return None, index
787+
788+
789+
def _validate_local_bash_cwd_target(command_name: str, target: str | None, allowed_paths: list[str]) -> None:
790+
if target is None or target == "-":
791+
raise PermissionError(f"Unsafe working directory change in command: {command_name}. Use paths under {VIRTUAL_PATH_PREFIX}")
792+
if target.startswith(("$", "`")):
793+
raise PermissionError(f"Unsafe working directory change in command: {command_name} {target}. Use paths under {VIRTUAL_PATH_PREFIX}")
794+
if target.startswith("~"):
795+
raise PermissionError(f"Unsafe working directory change in command: {command_name} {target}. Use paths under {VIRTUAL_PATH_PREFIX}")
796+
if target.startswith("/"):
797+
_reject_path_traversal(target)
798+
if not _is_allowed_local_bash_absolute_path(target, allowed_paths, allow_system_paths=False):
799+
raise PermissionError(f"Unsafe working directory change in command: {command_name} {target}. Use paths under {VIRTUAL_PATH_PREFIX}")
800+
801+
802+
def _looks_like_unsafe_cwd_target(target: str | None) -> bool:
803+
if target is None:
804+
return False
805+
return target == "-" or target.startswith(("$", "`", "~", "/", "..")) or _has_dotdot_path_segment(target)
806+
807+
808+
def _validate_local_bash_root_path_args(command_name: str, tokens: list[str], start_index: int) -> None:
809+
if command_name not in _LOCAL_BASH_ROOT_PATH_COMMANDS:
810+
return
811+
812+
index = start_index
813+
while index < len(tokens):
814+
token = tokens[index]
815+
if _is_shell_command_separator(token):
816+
return
817+
if _is_shell_redirection_operator(token):
818+
index += 2
819+
continue
820+
if token == "/" and not _is_non_file_url_token(token):
821+
raise PermissionError(f"Unsafe absolute paths in command: /. Use paths under {VIRTUAL_PATH_PREFIX}")
822+
index += 1
823+
824+
825+
def _validate_local_bash_shell_tokens(command: str, allowed_paths: list[str]) -> None:
826+
"""Conservatively reject relative path escapes missed by absolute-path scanning."""
827+
if re.search(r"\$\([^)]*\b(?:cd|pushd)\b", command):
828+
raise PermissionError(f"Unsafe working directory change in command substitution. Use paths under {VIRTUAL_PATH_PREFIX}")
829+
830+
tokens = _split_shell_tokens(command)
831+
832+
for token in tokens:
833+
if _is_shell_command_separator(token) or _is_shell_redirection_operator(token):
834+
continue
835+
if _has_dotdot_path_segment(token):
836+
raise PermissionError("Access denied: path traversal detected")
837+
838+
at_command_start = True
839+
index = 0
840+
while index < len(tokens):
841+
token = tokens[index]
842+
843+
if _is_shell_command_separator(token):
844+
at_command_start = True
845+
index += 1
846+
continue
847+
848+
if _is_shell_redirection_operator(token):
849+
index += 1
850+
continue
851+
852+
if at_command_start and _is_shell_assignment(token):
853+
index += 1
854+
continue
855+
856+
command_name = token.rsplit("/", 1)[-1]
857+
if at_command_start and command_name in _LOCAL_BASH_COMMAND_PREFIX_KEYWORDS | _LOCAL_BASH_COMMAND_END_KEYWORDS:
858+
index += 1
859+
continue
860+
861+
if not at_command_start:
862+
index += 1
863+
continue
864+
865+
at_command_start = False
866+
if command_name in _LOCAL_BASH_COMMAND_WRAPPERS and index + 1 < len(tokens):
867+
wrapped_name = tokens[index + 1].rsplit("/", 1)[-1]
868+
if wrapped_name in _LOCAL_BASH_CWD_COMMANDS:
869+
target, next_index = _next_cd_target(tokens, index + 2)
870+
_validate_local_bash_cwd_target(wrapped_name, target, allowed_paths)
871+
index = next_index
872+
continue
873+
_validate_local_bash_root_path_args(wrapped_name, tokens, index + 2)
874+
875+
if command_name not in _LOCAL_BASH_CWD_COMMANDS:
876+
_validate_local_bash_root_path_args(command_name, tokens, index + 1)
877+
index += 1
878+
continue
879+
880+
target, next_index = _next_cd_target(tokens, index + 1)
881+
_validate_local_bash_cwd_target(command_name, target, allowed_paths)
882+
index = next_index
883+
884+
638885
def resolve_and_validate_user_data_path(path: str, thread_data: ThreadDataState) -> str:
639886
"""Resolve a /mnt/user-data virtual path and validate it stays in bounds."""
640887
return _resolve_and_validate_user_data_path(path, thread_data)
@@ -665,33 +912,14 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState
665912

666913
unsafe_paths: list[str] = []
667914
allowed_paths = _get_mcp_allowed_paths()
915+
_validate_local_bash_shell_tokens(command, allowed_paths)
916+
url_spans = _non_file_url_spans(command)
668917

669-
for absolute_path in _ABSOLUTE_PATH_PATTERN.findall(command):
670-
# Check for MCP filesystem server allowed paths
671-
if any(absolute_path.startswith(path) or absolute_path == path.rstrip("/") for path in allowed_paths):
672-
_reject_path_traversal(absolute_path)
673-
continue
674-
675-
if absolute_path == VIRTUAL_PATH_PREFIX or absolute_path.startswith(f"{VIRTUAL_PATH_PREFIX}/"):
676-
_reject_path_traversal(absolute_path)
677-
continue
678-
679-
# Allow skills container path (resolved by tools.py before passing to sandbox)
680-
if _is_skills_path(absolute_path):
681-
_reject_path_traversal(absolute_path)
918+
for match in _ABSOLUTE_PATH_PATTERN.finditer(command):
919+
if _is_in_spans(match.start(), url_spans):
682920
continue
683-
684-
# Allow ACP workspace path (path-traversal check only)
685-
if _is_acp_workspace_path(absolute_path):
686-
_reject_path_traversal(absolute_path)
687-
continue
688-
689-
# Allow custom mount container paths
690-
if _is_custom_mount_path(absolute_path):
691-
_reject_path_traversal(absolute_path)
692-
continue
693-
694-
if any(absolute_path == prefix.rstrip("/") or absolute_path.startswith(prefix) for prefix in _LOCAL_BASH_SYSTEM_PATH_PREFIXES):
921+
absolute_path = match.group()
922+
if _is_allowed_local_bash_absolute_path(absolute_path, allowed_paths, allow_system_paths=True):
695923
continue
696924

697925
unsafe_paths.append(absolute_path)

0 commit comments

Comments
 (0)