ComposioHQ/composio · critical · UnsafePathComponentError
Path traversal detected: {label} {name!r} resolves to {candi
Error message
Path traversal detected: {label} {name!r} resolves to {candidate.resolve()}, which is outside {resolved_root}. What it means
secure_basename_join detected that base joined with the sanitized basename resolves (via Path.resolve, following symlinks and ..) to a location outside the trusted root. This is the final containment check ensuring an untrusted filename cannot escape the configured directory even when the base itself is derived from untrusted input.
Source
Thrown at python/composio/utils/safe_path.py:253
) -> Path:
"""Join a single untrusted filename under ``base``, contained within ``root``.
The filename counterpart to :func:`secure_join`, which cannot be reused here
because it forbids ``.`` — correct for a slug, wrong for ``report.pdf``.
``root`` defaults to ``base`` but is separate for the download path, where
``base`` is a per-tool subdirectory that untrusted slugs helped build and
only the configured ``root`` above it is trusted. Anchoring on ``base``
there would check the result against a directory those slugs had moved.
:raises UnsafePathComponentError: when ``name`` is unsafe, or when the
result escapes ``root``.
"""
resolved_base = resolve_root(base)
resolved_root = resolved_base if root is None else resolve_root(root)
candidate = resolved_base / safe_basename(name, label=label)
if not is_inside_dir(candidate.resolve(), resolved_root):
raise UnsafePathComponentError(
f"Path traversal detected: {label} {name!r} resolves to "
f"{candidate.resolve()}, which is outside {resolved_root}."
)
return candidate
def secure_join(root: t.Union[str, Path], *components: str) -> Path:
"""Join untrusted ``components`` beneath the trusted ``root``.
``root`` is the sole anchor of trust and must not itself be derived from
untrusted input — that is the whole point. Each component is validated by
:func:`assert_safe_path_component`, then the joined result is resolved and
re-checked against the resolved root. The second check is belt-and-braces:
it catches a symlink inside ``root`` pointing outside it, which per-component
validation cannot see.
Performs no filesystem writes; the caller creates directories only after
this returns.View on GitHub (pinned to 64b1b85502)
Solutions
- Pass a constant, trusted root directory as both base and root (secure_basename_join(root, name, root=root))
- Never derive base from untrusted input; anchor containment on a literal/module constant
- Ensure root is the resolved form of the directory you intend to contain writes to
Example fix
# before secure_basename_join(api_base_dir, filename, root=my_root) # after secure_basename_join(my_root, filename, root=my_root)
Defensive patterns
Strategy: try-catch
Validate before calling
from composio.utils.safe_path import resolve_root, is_inside_dir
from pathlib import Path
def join_stays_inside(base, name, root):
cand = (resolve_root(base) / Path(name).name)
return is_inside_dir(cand.resolve(), resolve_root(root)) Try / catch
from composio.exceptions import UnsafePathComponentError
from composio.utils.safe_path import secure_basename_join
try:
p = secure_basename_join(base, filename, root=trusted_root)
except UnsafePathComponentError as e:
log_security_event(e)
p = secure_basename_join(trusted_root, filename, root=trusted_root) Prevention
- Pass a constant trusted root as both base and root
- Expand ~ and resolve symlinks in base before the call, or better, don't derive base from untrusted input
When it happens
Trigger: secure_basename_join(base, name, root=root) where base is '~' expanded to the home directory or a symlinked path while root points elsewhere — the resolved candidate lands outside resolved_root. Used by the SDK's download/save helpers.
Common situations: Passing a user-controlled base (from an API field like '~/...' or a symlinked cache dir) instead of a constant root; tilde paths expanded before the call; root and base disagreeing after symlink resolution.
Related errors
- Unsafe path component: {e}
- Refusing to build a path from an empty or non-string {label}
- Refusing to build a path from a {label} containing path sepa
- Path traversal detected: {label} {name!r} leaves no usable b
- Refusing to write outside the configured directory: {compone
AI-assisted analysis of ComposioHQ/composio@64b1b85502 (2026-08-28).
Data as JSON: /api/errors/75e2638da2a25038.
Report an issue: GitHub.