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>/config — outside 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.
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
_build_local_templatethen readstemplate_dir / "template.yaml"and returns fields from it (display_name,description, …).Reachability
routers/templates.py::pathconverter permits/, so..segments are expressible in the id.get_current_useronly — any authenticated user, any role. No owner/admin check, norequire_role.Verified behaviour
Measured against the real
_local_templates_dir():is_dir()sage<base>/sage(intended)..<repo>/config— outside the templates root../agent-templates/sage../../../../etc<...>/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.yamlthat 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_templateis byte-identical ondevand 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 aspy/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:
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 inarchitecture.mdas 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
GET /api/templates/local:..(andlocal:../<anything>) returns 404, not a built entryGET /api/templates/local:sagestill resolves normally (no regression to the curated catalog or tohidden: trueby-id resolution, bug: agent template catalog surfaces internal test/canary fixtures + a broken stub as real templates #1513)..,., empty,/-bearing and\-bearing idsProvenance
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.