Skip to content

Commit f136bea

Browse files
committed
fix(skill): confine ROS bridge filesystem capabilities
1 parent 258d3fd commit f136bea

1 file changed

Lines changed: 46 additions & 62 deletions

File tree

‎skills/alicloud-ros-agent/scripts/ros_agent.py‎

Lines changed: 46 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -138,49 +138,53 @@ def _json_bytes(value: Any) -> bytes:
138138
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
139139

140140

141+
def _resolve_user_owned_path(raw_path: str, code: str, label: str) -> pathlib.Path:
142+
"""Resolve a local path and confine it to the user's home or temp tree."""
143+
144+
expanded = os.path.expandvars(os.path.expanduser(raw_path))
145+
normalized = os.path.normcase(os.path.realpath(expanded))
146+
allowed_roots = (
147+
os.path.normcase(os.path.realpath(str(pathlib.Path.home()))),
148+
os.path.normcase(os.path.realpath(tempfile.gettempdir())),
149+
)
150+
for allowed_root in allowed_roots:
151+
prefix = allowed_root.rstrip(os.sep) + os.sep
152+
if normalized.startswith(prefix):
153+
return pathlib.Path(normalized)
154+
raise BridgeError(code, "{} must be inside the current user's home or temporary directory.".format(label))
155+
156+
141157
def _state_root() -> pathlib.Path:
142158
configured = os.environ.get(STATE_DIR_ENV)
143159
if configured:
144-
return pathlib.Path(os.path.expandvars(os.path.expanduser(configured))).resolve()
160+
return _resolve_user_owned_path(configured, "invalid_config", "The ROS Agent state directory")
145161
return pathlib.Path(os.path.expanduser("~/.cache/alicloud-ros-agent")).resolve()
146162

147163

148164
def _secure_directory(path: pathlib.Path) -> None:
149-
# The bridge only calls this with its process-local state root or a child
150-
# path derived from a validated job identifier.
151-
# codeql[py/path-injection]
152165
path.mkdir(parents=True, exist_ok=True)
153166
if os.name != "nt":
154-
# codeql[py/path-injection]
155167
os.chmod(str(path), 0o700)
156168

157169

158170
def _atomic_json(path: pathlib.Path, value: Dict[str, Any], mode: int = 0o600) -> None:
159171
_secure_directory(path.parent)
160-
# Atomic state files are always beneath the bridge-owned state directory.
161-
# codeql[py/path-injection]
162172
descriptor, temporary = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
163173
try:
164174
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
165175
json.dump(value, handle, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
166176
handle.flush()
167177
os.fsync(handle.fileno())
168178
if os.name != "nt":
169-
# codeql[py/path-injection]
170179
os.chmod(temporary, mode)
171-
# codeql[py/path-injection]
172180
os.replace(temporary, str(path))
173181
finally:
174182
with contextlib.suppress(OSError):
175-
# codeql[py/path-injection]
176183
os.unlink(temporary)
177184

178185

179186
def _load_state_json(path: pathlib.Path, code: str = "job_not_found") -> Dict[str, Any]:
180187
try:
181-
# Callers pass only bridge state paths derived from validated local
182-
# identifiers; remote StartChat payloads cannot select this path.
183-
# codeql[py/path-injection]
184188
with path.open("r", encoding="utf-8") as handle:
185189
value = json.load(handle)
186190
except (OSError, ValueError) as exc:
@@ -451,24 +455,22 @@ def sanitize_text(value: Any, maximum: int = 4000, preserve_lines: bool = False)
451455

452456

453457
def _workspace(raw_path: Optional[str] = None) -> pathlib.Path:
454-
# The optional value comes only from the authenticated loopback manager;
455-
# existence and directory type are checked before it is used.
456-
# codeql[py/path-injection]
457-
path = pathlib.Path(raw_path or os.getcwd()).expanduser().resolve()
458+
path = (
459+
_resolve_user_owned_path(raw_path, "invalid_input", "The workspace")
460+
if raw_path is not None
461+
else pathlib.Path.cwd().resolve()
462+
)
458463
if not path.is_dir():
459464
raise BridgeError("invalid_input", "The workspace must be an existing directory.")
460465
return path
461466

462467

463468
def _read_workspace_file(workspace: pathlib.Path, raw_path: str, maximum: int, label: str) -> str:
464-
# The resolved path is rejected below unless it remains inside the already
465-
# validated workspace. Constructing it alone performs no filesystem read.
466-
# codeql[py/path-injection]
467-
path = pathlib.Path(raw_path).expanduser().resolve()
468-
try:
469-
path.relative_to(workspace)
470-
except ValueError as exc:
471-
raise BridgeError("invalid_input", "{} must be inside the workspace.".format(label)) from exc
469+
workspace_path = os.path.normcase(os.path.realpath(str(workspace)))
470+
resolved_path = os.path.normcase(os.path.realpath(os.path.expanduser(raw_path)))
471+
if not resolved_path.startswith(workspace_path.rstrip(os.sep) + os.sep):
472+
raise BridgeError("invalid_input", "{} must be inside the workspace.".format(label))
473+
path = pathlib.Path(resolved_path)
472474
try:
473475
data = path.read_bytes()
474476
except OSError as exc:
@@ -594,18 +596,10 @@ def build_permission_query(
594596

595597
def resolve_aliyun(raw_path: str) -> str:
596598
expanded = os.path.expanduser(raw_path)
597-
if os.path.dirname(expanded):
598-
path = os.path.abspath(expanded)
599-
# An explicit CLI path is local installation policy. It is checked as
600-
# a regular file and later executed without a shell.
601-
# codeql[py/path-injection]
602-
if not os.path.isfile(path):
603-
raise BridgeError("cli_not_found", "Alibaba Cloud CLI was not found at the requested path.")
604-
return path
605599
resolved = shutil.which(expanded)
606600
if not resolved:
607601
raise BridgeError("cli_not_found", "Alibaba Cloud CLI is not installed or is not on PATH.")
608-
return resolved
602+
return os.path.abspath(resolved)
609603

610604

611605
def build_start_chat_parameters(
@@ -2407,11 +2401,8 @@ def _finish_job(
24072401
if isinstance(job.get(key), str):
24082402
boundary[key] = job[key]
24092403
data = _json_bytes(boundary) + b"\n"
2410-
# The spool belongs to a validated job under the bridge state root.
2411-
# codeql[py/path-injection]
24122404
current_size = spool.stat().st_size if spool.exists() else 0
24132405
if current_size + len(data) <= MAX_SPOOL_BYTES:
2414-
# codeql[py/path-injection]
24152406
with spool.open("ab") as handle:
24162407
handle.write(data)
24172408
handle.flush()
@@ -2583,12 +2574,9 @@ def _fail_sideband_job(
25832574

25842575

25852576
def _read_spool(spool: pathlib.Path) -> List[Dict[str, Any]]:
2586-
# Spool paths are produced only by _job_paths after job-id validation.
2587-
# codeql[py/path-injection]
25882577
if not spool.exists():
25892578
return []
25902579
values = []
2591-
# codeql[py/path-injection]
25922580
with spool.open("r", encoding="utf-8") as handle:
25932581
for line in handle:
25942582
try:
@@ -2631,18 +2619,14 @@ def _follow_timeout_result(job_id: str, start_cursor: int) -> Optional[Dict[str,
26312619
"time": int(time.time()),
26322620
}
26332621
data = _json_bytes(marker) + b"\n"
2634-
# The spool belongs to a validated job under the bridge state root.
2635-
# codeql[py/path-injection]
26362622
current_size = spool.stat().st_size if spool.exists() else 0
26372623
if current_size + len(data) > MAX_SPOOL_BYTES:
26382624
raise BridgeError("stream_failed", "The bounded ROS Agent event spool is full.")
2639-
# codeql[py/path-injection]
26402625
with spool.open("ab") as handle:
26412626
handle.write(data)
26422627
handle.flush()
26432628
os.fsync(handle.fileno())
26442629
if os.name != "nt":
2645-
# codeql[py/path-injection]
26462630
os.chmod(str(spool), 0o600)
26472631
return _job_result(
26482632
job_id,
@@ -3127,35 +3111,28 @@ def _stop_process(process: Any) -> None:
31273111

31283112
def _spawn_worker(job_id: str, request: Dict[str, Any]) -> int:
31293113
root, job_path, _spool = _job_paths(job_id)
3130-
request_path = root / ("request-{}.json".format(uuid.uuid4().hex))
3114+
request_token = uuid.uuid4().hex
3115+
request_path = root / ("request-{}.json".format(request_token))
31313116
_atomic_json(request_path, request)
3117+
canonical_job_id = uuid.UUID(job_id).hex
31323118
command = [
31333119
sys.executable,
31343120
str(pathlib.Path(__file__).resolve()),
31353121
"_worker",
31363122
"--job-id",
3137-
job_id,
3138-
"--request-file",
3139-
str(request_path),
3123+
canonical_job_id,
3124+
"--request-token",
3125+
request_token,
31403126
]
31413127
log_path = root / "worker.log"
31423128
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0
3143-
# Worker paths are generated beneath a validated job directory and the
3144-
# launched argv fixes both the interpreter and this bridge script.
3145-
# codeql[py/path-injection]
31463129
if log_path.exists() and log_path.stat().st_size > MAX_DIAGNOSTIC_BYTES:
3147-
# codeql[py/path-injection]
31483130
with log_path.open("wb"):
31493131
pass
31503132
try:
3151-
# codeql[py/path-injection]
31523133
with log_path.open("ab", buffering=0) as log:
31533134
if os.name != "nt":
3154-
# codeql[py/path-injection]
31553135
os.chmod(str(log_path), 0o600)
3156-
# command is a fixed interpreter/script pair plus a validated job
3157-
# id and a bridge-generated request filename; shell=False is used.
3158-
# codeql[py/command-line-injection]
31593136
process = subprocess.Popen(
31603137
command,
31613138
stdin=subprocess.DEVNULL,
@@ -3166,7 +3143,6 @@ def _spawn_worker(job_id: str, request: Dict[str, Any]) -> int:
31663143
)
31673144
except OSError as exc:
31683145
with contextlib.suppress(OSError):
3169-
# codeql[py/path-injection]
31703146
request_path.unlink()
31713147
request_seq = int(request.get("requestSeq") or 0)
31723148
error = BridgeError("worker_start_failed", "The StartChat worker could not be started.", True)
@@ -3631,8 +3607,16 @@ def _cancel_job_local(payload: Dict[str, Any]) -> Dict[str, Any]:
36313607
return result
36323608

36333609

3634-
def run_worker(job_id: str, request_file: str) -> int:
3635-
request_path = pathlib.Path(request_file).resolve()
3610+
def run_worker(job_id: str, request_token: str) -> int:
3611+
try:
3612+
canonical_job_id = uuid.UUID(job_id).hex
3613+
canonical_request_token = uuid.UUID(request_token).hex
3614+
except (AttributeError, ValueError) as exc:
3615+
raise BridgeError("invalid_input", "The worker launch capability is invalid.") from exc
3616+
if canonical_job_id != job_id or canonical_request_token != request_token:
3617+
raise BridgeError("invalid_input", "The worker launch capability is invalid.")
3618+
root, _job_path, _spool = _job_paths(canonical_job_id)
3619+
request_path = root / ("request-{}.json".format(canonical_request_token))
36363620
request = _load_state_json(request_path, "invalid_input")
36373621
with contextlib.suppress(OSError):
36383622
request_path.unlink()
@@ -4262,7 +4246,7 @@ def build_parser() -> argparse.ArgumentParser:
42624246
server.add_argument("--record-file", required=True)
42634247
worker = subparsers.add_parser("_worker", help=argparse.SUPPRESS)
42644248
worker.add_argument("--job-id", required=True)
4265-
worker.add_argument("--request-file", required=True)
4249+
worker.add_argument("--request-token", required=True)
42664250
return parser
42674251

42684252

@@ -4276,7 +4260,7 @@ def main(argv: Optional[List[str]] = None) -> int:
42764260
if args.command == "_server":
42774261
return run_manager_server(args.record_file)
42784262
if args.command == "_worker":
4279-
return run_worker(args.job_id, args.request_file)
4263+
return run_worker(args.job_id, args.request_token)
42804264
apply_skill_config(args, load_skill_config())
42814265
if args.command == "check":
42824266
result = run_check(args)

0 commit comments

Comments
 (0)