Skip to content

Path traversal in template resolution: local:<id> read escape, plus name:-derived credential staging that leaks another tenant's .mcp.json #1900

Description

@AndriiPasternak31

Summary

template_service.get_local_template() builds a filesystem path by joining a user-supplied template id onto the local-templates directory with no containment check:

https://github.com/Abilityai/trinity/blob/dev/src/backend/services/template_service.py#L1174-L1182

def get_local_template(template_id: str) -> Optional[dict]:
    """Get a single local template by `local:<name>` id."""
    if not template_id.startswith("local:"):
        return None
    name = template_id[len("local:"):]
    template_dir = _local_templates_dir() / name   # <-- `name` is caller-controlled
    if not template_dir.is_dir():
        return None
    return _build_local_template(template_dir)

_build_local_template then reads template_dir / "template.yaml" and returns fields from it (display_name, description, …).

Reachability

routers/templates.py:

@router.get("/{template_id:path}")
async def get_template(template_id: str, current_user: User = Depends(get_current_user)):
  • The :path converter permits /, so .. segments are expressible in the id.
  • The gate is get_current_user only — any authenticated user, any role. No owner/admin check, no require_role.

Verified behaviour

Measured against the real _local_templates_dir():

id suffix is_dir() resolves to
sage True <base>/sage (intended)
.. True <repo>/configoutside the templates root
../agent-templates/sage True back inside, via traversal
../../../../etc False <...>/etc (didn't exist on my box)

So the join does escape the templates root. The read is then <escaped-dir>/template.yaml.

Impact

Bounded but real: an authenticated low-privilege user can probe for directories outside the templates root and, where such a directory contains a file named template.yaml that parses as a YAML mapping, have selected fields of it reflected back in the API response.

It is not arbitrary file read — the filename is fixed (template.yaml), the content must be a YAML mapping, and only specific keys are echoed. Treat it as directory-existence probing + narrow content disclosure, not full LFI.

Not a regression — pre-existing

get_local_template is byte-identical on dev and was not introduced or modified by trinity-enterprise#128 (PR-A #1835 / PR-B #1899).

It surfaced because ent#128 PR-B briefly added a second path operation on the same tainted value (a .resolve() used only to pick a trust label), which CodeQL flagged as py/path-injection (alert 260, high). That line has since been removed in #1899 (5c0f712f) — the trust label now comes from the caller, so the new sink is gone. The underlying traversal here is untouched and still open, which is why it needs its own issue rather than riding along in a credential-declaration PR.

Suggested fix

Containment check before touching the filesystem — reject the id rather than normalise it, so the failure is loud:

name = template_id[len("local:"):]
if not name or name in (".", "..") or "/" in name or "\\" in name:
    return None

A single plain path segment is by construction a direct child of the curated root. Prior art in this repo for "validate the id, don't sanitise the path": the strict ^[A-Za-z0-9._-]+$ id guard on the MCP pipelines reader (#919), cited in architecture.md as the path-traversal guard pattern.

Worth checking whether the by-name create path (services/agent_service/crud.py, which resolves local templates by directory name) shares the same shape.

Acceptance criteria

Provenance

Found while validating trinity-enterprise#128 PR-B (#1899) — CodeQL alert 260 pointed at an adjacent line; tracing the taint to its source turned up this.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions