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

    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