Skip to content

Bundle import writes unvalidated content to committed artifact directories #13

Description

@plind-junior

Summary

import_apply writes raw bytes from a tar archive directly to committed artifact directories (claims/, pages/, sources/, etc.) without validating file content against the Pydantic models. A crafted bundle can inject malformed YAML or schema-violating artifacts that persist on disk and poison all subsequent list/read operations.

This is distinct from the path-traversal issue (documented in ISSUE_security_audit_findings.md Finding 1). This is about content integrity — the imported data becomes "trusted" (lives in committed directories) without ever being validated.

Location

  • src/vouch/bundle.py:224-239import_apply writes raw bytes with no schema check
  • src/vouch/bundle.py:166-197import_check only validates path-level conflicts and hash matching, not content

Buggy code

# bundle.py:224-239
for member in tar.getmembers():
    if member.name == MANIFEST_NAME or not member.isfile():
        continue
    if member.name not in recorded:
        continue
    dest = kb_dir / member.name
    if (
        dest.exists()
        and on_conflict == "skip"
        and sha256_hex(dest.read_bytes()) != recorded[member.name]["sha256"]
    ):
        skipped.append(member.name)
        continue
    dest.parent.mkdir(parents=True, exist_ok=True)
    dest.write_bytes(tar.extractfile(member).read())  # raw bytes, no validation
    written.append(member.name)

The subsequent rebuild_index (called by server.py:535 and jsonl_server.py:378) will crash on malformed YAML, but the corrupt files remain on disk.

Reproduce

import tarfile, io, json

# Craft a bundle with an invalid claim (confidence > 1.0, missing required fields)
bad_claim = b"id: bad\ntext: null\nconfidence: 999.0\n"
manifest = {
    "spec": "vouch-bundle-0.1",
    "bundle_id": "abc",
    "files": [{"path": "claims/bad.yaml", "size": len(bad_claim),
               "sha256": sha256_hex(bad_claim)}],
    "counts": {"claims": 1},
    "safety": {"has_proposed": False, "has_state_db": False, "has_audit_log": False},
}

with tarfile.open("evil.tar.gz", "w:gz") as tar:
    info = tarfile.TarInfo("claims/bad.yaml")
    info.size = len(bad_claim)
    tar.addfile(info, io.BytesIO(bad_claim))
    mf = json.dumps(manifest).encode()
    info = tarfile.TarInfo("manifest.json")
    info.size = len(mf)
    tar.addfile(info, io.BytesIO(mf))

# import_check passes (no content validation)
# import_apply writes claims/bad.yaml to disk
# Every subsequent list_claims() call raises a Pydantic ValidationError

Reach

Any vouch import-apply or kb.import_apply call. The corrupted files persist across restarts and poison all list_claims(), list_pages(), etc. calls — a single bad file in claims/ makes the entire claim listing fail.

A crafted bundle can also inject:

  • Claims with confidence: 999 (bypasses ge=0.0, le=1.0 validators)
  • Sources with mismatched id vs actual content hash (breaks content-addressing invariant)
  • Valid YAML but with fields that don't match required Pydantic types

Recommended fix

After extracting each file, validate it against the appropriate Pydantic model based on subdirectory before writing:

# bundle.py — add after reading bytes, before writing
from .models import Claim, Page, Entity, Relation, Evidence, Session, Source, Proposal

VALIDATORS = {
    "claims": lambda data: Claim.model_validate(yaml.safe_load(data)),
    "pages": lambda data: _deserialize_page(data.decode()),
    "entities": lambda data: Entity.model_validate(yaml.safe_load(data)),
    "relations": lambda data: Relation.model_validate(yaml.safe_load(data)),
    "evidence": lambda data: Evidence.model_validate(yaml.safe_load(data)),
    "sessions": lambda data: Session.model_validate(yaml.safe_load(data)),
    "decided": lambda data: Proposal.model_validate(yaml.safe_load(data)),
}

body = tar.extractfile(member).read()
subdir = member.name.split("/")[0]
if subdir in VALIDATORS:
    try:
        VALIDATORS[subdir](body)
    except Exception as e:
        issues.append(f"schema validation failed: {member.name}: {e}")
        continue
dest.write_bytes(body)

Ideally, also add content validation to import_check so issues surface before import_apply is called.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions