Skip to content

fix(storage): reject path-traversal artifact ids on the write path - #171

Closed
seekmistar01 wants to merge 1 commit into
vouchdev:mainfrom
seekmistar01:fix/artifact-id-path-traversal
Closed

fix(storage): reject path-traversal artifact ids on the write path#171
seekmistar01 wants to merge 1 commit into
vouchdev:mainfrom
seekmistar01:fix/artifact-id-path-traversal

Conversation

@seekmistar01

@seekmistar01 seekmistar01 commented Jun 5, 2026

Copy link
Copy Markdown

Summary

Closes #170

Rejects path-traversal artifact ids on the durable write path. Artifact ids are interpolated into filenames (claims/<id>.yaml, pages/<id>.md, sources/<id>/, …), but an untrusted proposer fully controls the id via slug_hint (proposals.py), and Claim/Page/Entity/Relation.id are unvalidated (only Source.id is). So slug_hint="../../../../evil" lets an approved artifact be written outside the KB — defeating the review gate.

Reads are already containment-checked (read_under_root) and bundle import already rejects ../absolute/nul names (bundle._unsafe_name_reason); this adds the missing write-side guard at the one point where ids become filenames, so every entrypoint (MCP, JSONL, CLI, direct KBStore) is covered with one chokepoint.

Changes

  • src/vouch/storage.py
    • Add _validate_artifact_id(obj_id) — rejects ids containing a path separator (/ or \), .., an absolute prefix, a nul byte, or ./...
    • Route _yaml, _page_path, _source_dir through it (covers _claim_path, _entity_path, _relation_path, _evidence_path, _session_path, _proposal_path, _decided_path, which all delegate to _yaml).
  • tests/test_storage.py
    • test_put_rejects_path_traversal_ids — parametrized over ../evil, .., sub/evil, a\b, /abs, ., nul — asserts put_claim/put_page/put_entity raise ValueError.
    • test_approve_with_traversal_slug_hint_writes_nothing — end-to-end: a malicious slug_hint does not write any file outside the KB on approve.

Why it's safe (no regressions)

Legitimate ids never contain these characters: _slugify emits [a-z0-9-] only; source ids are 64-char hex; proposal ids are YYYYMMDD-HHMMSS-<hex>; existing tests use ids like c1, auth-uses-jwt, r1. The guard fires only on the previously-unguarded malicious case.

Verification

  • pytest tests/test_storage.py39 passed (31 existing + 8 new).
  • ruff check src/vouch/storage.py tests/test_storage.py — clean.
  • Full pytest: the only failures are 4 pre-existing, Windows-only cases unrelated to this change — os.O_NOFOLLOW is absent on Windows (in the existing read_under_root) and a bundle frontmatter round-trip; both fail identically on main without this patch (CI runs on Linux).

Summary by CodeRabbit

  • Bug Fixes

    • Fixed a security vulnerability by implementing validation to prevent path traversal attacks through artifact IDs. The system now ensures files cannot be written outside the knowledge base directory.
  • Tests

    • Added security regression tests to verify path traversal attempts are properly rejected.

Artifact ids flow into filenames (claims/<id>.yaml, pages/<id>.md,
sources/<id>/, ...). The proposal layer takes an attacker-controlled
`slug_hint` verbatim as the id (proposals.py), and the Claim/Page/Entity/
Relation models do not validate `id` (only Source.id is checked). An
untrusted proposer could therefore set `slug_hint="../../../../evil"` and,
on approval, have an artifact written outside the KB — the very thing the
review gate is meant to prevent.

Reads are already containment-checked (`read_under_root`) and bundle import
already rejects `..`/absolute/nul member names; this adds the missing
write-side guard at the single point where ids become filenames, so every
caller (MCP, JSONL, CLI, direct KBStore) is covered.

Add `_validate_artifact_id` (rejects path separators, `..`, absolute
prefixes, and nul bytes) and route `_yaml`, `_page_path`, and `_source_dir`
through it. Legitimate slug/sha/timestamp ids are unaffected. Adds
regression tests for the storage builders and the slug_hint approve path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Lost in the diff? Review this PR in Change Stack to follow the change map from intent to exact ranges.

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds artifact ID validation to prevent path traversal attacks in the storage layer. A _validate_artifact_id() function rejects unsafe IDs (containing separators, .., absolute paths, or NUL bytes), and all path builders integrate this check to ensure approved artifacts cannot be written outside the KB directory.

Changes

Path Traversal Mitigation

Layer / File(s) Summary
Artifact ID validation and path construction hardening
src/vouch/storage.py
_validate_artifact_id() rejects empty, non-string, path-separated, absolute, ./..-containing, and NUL-byte IDs. Path builders (_yaml(), pages(), sources()) apply this validation when constructing filesystem paths.
Security regression tests
tests/test_storage.py
Parametrized test verifies put_claim, put_page, and put_entity reject unsafe IDs. End-to-end test confirms approve() with traversal slug_hint fails and prevents writes outside the KB.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

  • #170: Addresses the core path traversal vulnerability via untrusted slug_hint / artifact ID writing outside the KB; this PR implements the fix specified in that issue.

Poem

🐰 A rabbit hops through paths both near and far,
Validates each ID like a shining star,
No ../ tricks can lead astray,
All artifacts stay safe inside the KB today! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the main change: adding path-traversal rejection for artifact IDs on the write path in storage.
Linked Issues check ✅ Passed The PR fully addresses issue #170 by implementing the proposed fix: a write-side validator that rejects unsafe artifact IDs containing path separators, .., absolute paths, ./.. segments, and NUL bytes across all write entrypoints.
Out of Scope Changes check ✅ Passed All changes directly support the security fix scope: validation logic in storage.py, integration into path builders, and comprehensive tests for the fix with no unrelated modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/vouch/storage.py`:
- Around line 130-153: The _validate_artifact_id function currently only rejects
".." when it's a standalone Path part; update the validation to explicitly
reject any raw substring ".." in obj_id (e.g., add a check for '".." in obj_id')
so IDs like "..evil" or "evil..yaml" are considered unsafe, while keeping the
existing checks (empty/non-string, "/" or "\" or NUL, os.path.isabs, "."/".."
exact matches, and Path.parts check) intact; modify the conditional in
_validate_artifact_id to include the raw-substring test and raise the same
ValueError on match.

In `@tests/test_storage.py`:
- Around line 377-393: The test
test_approve_with_traversal_slug_hint_writes_nothing must assert the actual
resolved filesystem target for the malicious slug_hint, not just tmp_path and
tmp_path.parent; compute the exact resolved path by joining store.kb_dir /
"claims" with the provided slug_hint ("../../../../evil") and resolving it
(e.g., via Path(...).resolve()) and then assert that that resolved target (and
resolved_target.with_suffix(".yaml") if claims use .yaml) does not exist after
approve(store, pr.id, ...); update the assertions to check the resolved path(s)
instead of tmp_path / "evil.yaml" and tmp_path.parent / "evil.yaml".
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 91e47231-dad4-4a8a-a712-1df72de17979

📥 Commits

Reviewing files that changed from the base of the PR and between 3beb821 and 840c57a.

📒 Files selected for processing (2)
  • src/vouch/storage.py
  • tests/test_storage.py

Comment thread src/vouch/storage.py
Comment on lines +130 to +153
def _validate_artifact_id(obj_id: str) -> str:
"""Reject artifact ids that would escape their subdirectory when used as a
filename. Ids are flat slugs (claims/<id>.yaml, pages/<id>.md, …); a value
containing a path separator, ``..``, an absolute prefix, or a nul byte must
never reach a path builder, or an untrusted proposer could write an approved
artifact outside the KB (e.g. ``slug_hint="../../../../etc/evil"``).

This is the write-side counterpart to ``read_under_root``: reads are already
containment-checked, and bundle import already rejects ``..``/absolute/nul
member names (see ``bundle._unsafe_name_reason``), but the durable write path
had no equivalent guard. Validating at the single point where ids become
filenames covers every caller (MCP, JSONL, CLI, direct ``KBStore``).
"""
if not obj_id or not isinstance(obj_id, str):
raise ValueError("artifact id must be a non-empty string")
if (
"/" in obj_id
or "\\" in obj_id
or "\x00" in obj_id
or os.path.isabs(obj_id)
or obj_id in (".", "..")
or ".." in Path(obj_id).parts
):
raise ValueError(f"unsafe artifact id (path traversal): {obj_id!r}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject .. substrings explicitly.

Line 151 only rejects .. when it is a standalone Path part. IDs like "..evil" or "evil..yaml" still pass, which is looser than the PR contract/docstring and the linked issue’s “contains ..” rule. If the intent is strict rejection, check the raw string directly instead of relying on Path.parts.

Suggested fix
     if (
         "/" in obj_id
         or "\\" in obj_id
         or "\x00" in obj_id
         or os.path.isabs(obj_id)
-        or obj_id in (".", "..")
-        or ".." in Path(obj_id).parts
+        or obj_id == "."
+        or ".." in obj_id
     ):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _validate_artifact_id(obj_id: str) -> str:
"""Reject artifact ids that would escape their subdirectory when used as a
filename. Ids are flat slugs (claims/<id>.yaml, pages/<id>.md, …); a value
containing a path separator, ``..``, an absolute prefix, or a nul byte must
never reach a path builder, or an untrusted proposer could write an approved
artifact outside the KB (e.g. ``slug_hint="../../../../etc/evil"``).
This is the write-side counterpart to ``read_under_root``: reads are already
containment-checked, and bundle import already rejects ``..``/absolute/nul
member names (see ``bundle._unsafe_name_reason``), but the durable write path
had no equivalent guard. Validating at the single point where ids become
filenames covers every caller (MCP, JSONL, CLI, direct ``KBStore``).
"""
if not obj_id or not isinstance(obj_id, str):
raise ValueError("artifact id must be a non-empty string")
if (
"/" in obj_id
or "\\" in obj_id
or "\x00" in obj_id
or os.path.isabs(obj_id)
or obj_id in (".", "..")
or ".." in Path(obj_id).parts
):
raise ValueError(f"unsafe artifact id (path traversal): {obj_id!r}")
if (
"/" in obj_id
or "\\" in obj_id
or "\x00" in obj_id
or os.path.isabs(obj_id)
or obj_id == "."
or ".." in obj_id
):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/storage.py` around lines 130 - 153, The _validate_artifact_id
function currently only rejects ".." when it's a standalone Path part; update
the validation to explicitly reject any raw substring ".." in obj_id (e.g., add
a check for '".." in obj_id') so IDs like "..evil" or "evil..yaml" are
considered unsafe, while keeping the existing checks (empty/non-string, "/" or
"\" or NUL, os.path.isabs, "."/".." exact matches, and Path.parts check) intact;
modify the conditional in _validate_artifact_id to include the raw-substring
test and raise the same ValueError on match.

Comment thread tests/test_storage.py
Comment on lines +377 to +393
def test_approve_with_traversal_slug_hint_writes_nothing(
store: KBStore, tmp_path: Path
) -> None:
# End-to-end: an untrusted proposer supplies a malicious slug_hint; the
# proposal may file, but approval must not write an artifact outside the KB.
src = store.put_source(b"e")
pr = propose_claim(
store,
text="t",
evidence=[src.id],
proposed_by="agent",
slug_hint="../../../../evil",
)
with pytest.raises((ProposalError, ValueError)):
approve(store, pr.id, approved_by="reviewer")
assert not (tmp_path / "evil.yaml").exists()
assert not (tmp_path.parent / "evil.yaml").exists()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Assert against the real escaped write target.

Lines 392-393 do not cover the location this payload would hit. From store.kb_dir / "claims", ../../../../evil.yaml resolves to tmp_path.parent.parent / "evil.yaml", so this test can still pass if the traversal write regresses. Compute that exact resolved target and assert it never appears.

Suggested fix
-def test_approve_with_traversal_slug_hint_writes_nothing(
-    store: KBStore, tmp_path: Path
-) -> None:
+def test_approve_with_traversal_slug_hint_writes_nothing(store: KBStore) -> None:
+    escaped = (store.kb_dir / "claims" / "../../../../evil.yaml").resolve()
     # End-to-end: an untrusted proposer supplies a malicious slug_hint; the
     # proposal may file, but approval must not write an artifact outside the KB.
     src = store.put_source(b"e")
     pr = propose_claim(
         store,
@@
     )
     with pytest.raises((ProposalError, ValueError)):
         approve(store, pr.id, approved_by="reviewer")
-    assert not (tmp_path / "evil.yaml").exists()
-    assert not (tmp_path.parent / "evil.yaml").exists()
+    assert not escaped.exists()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_approve_with_traversal_slug_hint_writes_nothing(
store: KBStore, tmp_path: Path
) -> None:
# End-to-end: an untrusted proposer supplies a malicious slug_hint; the
# proposal may file, but approval must not write an artifact outside the KB.
src = store.put_source(b"e")
pr = propose_claim(
store,
text="t",
evidence=[src.id],
proposed_by="agent",
slug_hint="../../../../evil",
)
with pytest.raises((ProposalError, ValueError)):
approve(store, pr.id, approved_by="reviewer")
assert not (tmp_path / "evil.yaml").exists()
assert not (tmp_path.parent / "evil.yaml").exists()
def test_approve_with_traversal_slug_hint_writes_nothing(store: KBStore) -> None:
escaped = (store.kb_dir / "claims" / "../../../../evil.yaml").resolve()
# End-to-end: an untrusted proposer supplies a malicious slug_hint; the
# proposal may file, but approval must not write an artifact outside the KB.
src = store.put_source(b"e")
pr = propose_claim(
store,
text="t",
evidence=[src.id],
proposed_by="agent",
slug_hint="../../../../evil",
)
with pytest.raises((ProposalError, ValueError)):
approve(store, pr.id, approved_by="reviewer")
assert not escaped.exists()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_storage.py` around lines 377 - 393, The test
test_approve_with_traversal_slug_hint_writes_nothing must assert the actual
resolved filesystem target for the malicious slug_hint, not just tmp_path and
tmp_path.parent; compute the exact resolved path by joining store.kb_dir /
"claims" with the provided slug_hint ("../../../../evil") and resolving it
(e.g., via Path(...).resolve()) and then assert that that resolved target (and
resolved_target.with_suffix(".yaml") if claims use .yaml) does not exist after
approve(store, pr.id, ...); update the assertions to check the resolved path(s)
instead of tmp_path / "evil.yaml" and tmp_path.parent / "evil.yaml".

@plind-junior

Copy link
Copy Markdown
Member

Review

Summary: Fixes a path-traversal write primitive (issue #170) by adding _validate_artifact_id() at the single point where artifact ids become filesystem paths (_yaml, _page_path, _source_dir). The guard rejects ids containing /, \\, \x00, an absolute prefix, ., .., or any path component equal to ... Two regression tests are added: a parametrized unit test over the bad-id matrix and an end-to-end approve-with-malicious-slug-hint test.

What works

  • src/vouch/storage.py:130–155 — The validator is well-placed as a chokepoint: routing through _yaml, _page_path, and _source_dir covers _claim_path, _entity_path, _relation_path, _evidence_path, _session_path, _proposal_path, and _decided_path without needing to touch each caller individually.
  • src/vouch/storage.py:148–155 — The condition is defence-in-depth: explicit separator checks (/, \\) block the common case fast; os.path.isabs catches Windows C:\… and UNC paths; obj_id in (".", "..") rejects the degenerate single-component case; and ".." in Path(obj_id).parts catches a/../b even if the separator checks somehow missed it. Together these are comprehensive.
  • src/vouch/storage.py:143–144 — The non-empty/non-str guard at the top of the validator catches None and empty-string ids that would otherwise silently produce nonsense paths, which is a nice bonus.
  • tests/test_storage.py:359–372 — Parametrized over 7 distinct bad-id shapes. Covers all three write entrypoints (put_claim, put_page, put_entity) so the test would catch a regression that only fixed one path builder.
  • tests/test_storage.py:375–393 — The end-to-end test verifies the real attack described in the issue: slug_hint="../../../../evil" on propose_claimapprove must not produce evil.yaml outside the KB. Checking both tmp_path / "evil.yaml" and tmp_path.parent / "evil.yaml" is a good belt-and-suspenders assertion.
  • No changes to proposals.py or models.py — the PR correctly argues that validating at the write chokepoint is safer than relying on caller-side hygiene, and it keeps the diff minimal.

Suggestions

  • [non-blocking] tests/test_storage.py:393tmp_path.parent in the end-to-end test is pytest's temp root, which can contain files from other tests. A more targeted assertion would be assert not any((tmp_path.parent).glob("evil.yaml")) or, better, asserting the exploit path directly: assert not (tmp_path / "evil.yaml").exists() is already there, so the parent check is mostly redundant noise. Not a correctness issue.
  • [non-blocking] src/vouch/storage.py:148not isinstance(obj_id, str) implies the function accepts Any, but the signature is typed str. If mypy/pyright is run in strict mode this branch is unreachable by type. Consider either typing the argument as Any to match the runtime guard, or dropping the isinstance branch and trusting the type system. As-is it's harmless but slightly inconsistent.
  • [non-blocking] src/vouch/storage.py:155 — The ".." in Path(obj_id).parts check is technically redundant given the "/" in obj_id or "\\" in obj_id checks above it (on POSIX, Path("a/../b").parts is ("a", "..", "b") which requires / in the string; on Windows, \\ is needed). It doesn't hurt to keep it — it reads as self-documenting belt-and-suspenders — but worth noting the redundancy so a future refactor doesn't assume the parts check provides independent coverage.

Verdict

approve — The fix is correct, minimal, and well-tested. It directly addresses all three sinks identified in issue #170, is asymptotically safe (all future write paths that go through _yaml/_page_path/_source_dir get the guard for free), and introduces no regressions. The suggestions above are polish-level only.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants