Skip to content

Path traversal: untrusted slug_hint / artifact id can write approved artifacts outside the KB #170

Description

@seekmistar01

Summary

Artifact ids are interpolated directly into filesystem paths on the durable write path, with no validation. Because an untrusted proposer fully controls the id (via slug_hint), an approved artifact can be written outside the KB, defeating the review gate.

Where

  • Sink: src/vouch/storage.py_yaml, _page_path, _source_dir build paths as kb_dir / sub / f"{obj_id}.yaml" etc. with obj_id used verbatim.
  • Taint source: src/vouch/proposals.pypropose_claim/propose_page/propose_entity/propose_relation set "id": slug_hint or _slugify(...), i.e. slug_hint is used raw.
  • Missing validation: src/vouch/models.pyClaim.id, Page.id, Entity.id, Relation.id have no validator (only Source.id is validated as a hex sha256).

Impact

The project's threat model assumes the proposing agent is not fully trusted ("they shouldn't get to write whatever they want either"). A malicious or prompt-injected agent can propose a claim/page/entity with slug_hint="../../../../../../tmp/evil". On approval, Claim(**payload) (no id validation) is handed to store.put_claim, which resolves:

<root>/.vouch/claims/../../../../../../tmp/evil.yaml  →  /tmp/evil.yaml

i.e. an attacker-controlled YAML/markdown file written anywhere the process can write (overwrite configs, drop files into other repos, clobber dotfiles), or overwrite other artifacts inside the KB. This is fully autonomous under the trusted-agent auto-approve mode.

The read path is already hardened (read_under_root with containment + O_NOFOLLOW + fstat) and bundle import already rejects ../absolute/nul member names (bundle._unsafe_name_reason) — but the primary write path has no equivalent guard. That asymmetry is the bug.

Steps to reproduce

from vouch.storage import KBStore
from vouch.proposals import propose_claim, approve

store = KBStore.init(tmp_path)
src = store.put_source(b"x")
pr = propose_claim(store, text="t", evidence=[src.id],
                   proposed_by="agent", slug_hint="../../../../evil")
approve(store, pr.id, approved_by="reviewer")   # writes evil.yaml OUTSIDE the KB

Expected

Approval (and any direct put_*) must refuse an id that contains a path separator, .., an absolute prefix, or a nul byte.

Fix

Add a write-side validator at the single point where ids become filenames (mirrors the existing read-side / bundle hardening), so all entrypoints (MCP, JSONL, CLI, direct KBStore) are covered. PR attached.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions