Skip to content

Fix: reject files: paths that escape the project root (High: info disclosure)#298

Merged
0xLeif merged 3 commits into
mainfrom
fix/files-path-escape
Jul 2, 2026
Merged

Fix: reject files: paths that escape the project root (High: info disclosure)#298
0xLeif merged 3 commits into
mainfrom
fix/files-path-escape

Conversation

@0xLeif

@0xLeif 0xLeif commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a High info-disclosure bug found while dogfooding: a spec's files: entries had no path containment, so an entry resolving outside the project root (absolute path, .., or an in-repo symlink pointing out) was validated — the out-of-root file counted as covered and its exported identifiers leaked into check/score/diff output, PR comments, and the MCP tools.

Fix (closes the whole class)

  • source_within_root(root, file) (shared pub(crate) guard) canonicalizes both the root and the resolved path (resolving .. and symlinks) and requires the file to sit inside the canonical root.
  • Applied at every site that reads a files: entry's content and extracts identifiers:
    • validate_spec (check) — reports out-of-root entries as an error and skips extraction
    • score (and the MCP score tool)
    • check --fix (both export-collection loops — it persists identifiers into the spec)
    • diff
    • new
  • compute_coverage needs no guard: it enumerates real files under source_dirs and uses files: only as a membership set, so an out-of-root path contributes zero LOC (verified: a 20k-line out-of-root file → <100 LOC).

Why class-wide

The first version guarded only validate_spec; review then reproduced the identical leak through specsync score. Rather than patch one site, the guard is now a single shared helper applied at all extraction sites.

Verified

  • Real CLI: an in-repo symlink to /etc/passwd is rejected, the spec fails (not a silent pass), no passwd identifiers leak.
  • Unit tests: absolute + .. escapes rejected with no export leak; score on an out-of-root file no longer leaks identifiers into suggestions.
  • Repo's own 60 specs still pass at 100% (no over-blocking); full suite (658 unit + 149 integration), clippy, fmt clean.

Known lower-severity follow-up (not in scope)

depends_on .exists() probes (validator/report/stale/scoring) are an existence-oracle only — they reveal a boolean, not identifiers. Happy to extend containment there separately.

🤖 Generated with Claude Code

…ure)

A spec's `files:` entries were resolved with a bare `root.join(file)` and then
read/extracted with no containment check, so an absolute path (`/etc/passwd`), a
`..` traversal, or an in-repo symlink pointing outside the project all passed
validation: the out-of-root file counted as covered LOC and its exported
identifiers leaked into `check`/coverage output and PR comments. A hostile repo
could exfiltrate identifiers from arbitrary readable host files in CI — the same
"committed repo runs on the victim" threat model as the aiCommand RCE.

Source files a spec validates must live inside the project root. New
`source_within_root` canonicalizes root and the resolved path (resolving `..`
and symlinks) and requires the file to be inside root. The files-exist check now
reports out-of-root entries as an error, and export extraction skips them so no
out-of-root identifier is ever read. Non-existent paths are unaffected (the
existence check still handles those); in-root relative paths and in-root symlinks
still work (verified: the repo's own 60 specs pass at 100%).

Tests: an absolute out-of-root path and a `..` escape are both rejected with an
"outside the project root" error, and their exports do not leak into warnings.

Found by dogfooding the CLI end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m
@0xLeif 0xLeif requested a review from a team as a code owner July 2, 2026 15:15
@0xLeif 0xLeif requested review from 0xGaspar, Kyntrin and tofu-ux July 2, 2026 15:15

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces path validation checks to ensure that source files referenced in specifications resolve within the project root, preventing path traversal and information disclosure vulnerabilities. It adds a helper function source_within_root and corresponding unit tests. The reviewer pointed out a potential security bypass in the helper function's fallback behavior when canonicalization fails, suggesting a fail-closed approach by checking if the file exists instead of unconditionally returning true.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/validator.rs
Comment on lines +826 to +829
match (root.canonicalize(), full.canonicalize()) {
(Ok(canon_root), Ok(canon_full)) => canon_full.starts_with(&canon_root),
_ => true,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-high high

The current fallback behavior of returning true when canonicalization fails is a potential security bypass (fail-open). If an attacker can trigger a canonicalization failure on either root or full (for example, if root is unreadable/deleted, or if there is a temporary filesystem/I/O error, or if a path contains special characters that fail canonicalization but can still be read), the function will return true, allowing the path containment check to be bypassed and potentially leaking files.

To make this check fail-secure (fail-closed) while still allowing the existence check to handle non-existent files with a better error message, we can return !full.exists() on failure. This ensures that if the file actually exists but cannot be verified to be within the root, it is safely rejected.

Suggested change
match (root.canonicalize(), full.canonicalize()) {
(Ok(canon_root), Ok(canon_full)) => canon_full.starts_with(&canon_root),
_ => true,
}
match (root.canonicalize(), full.canonicalize()) {
(Ok(canon_root), Ok(canon_full)) => canon_full.starts_with(&canon_root),
_ => !full.exists(),
}

github-actions[bot]
github-actions Bot previously approved these changes Jul 2, 2026

@github-actions github-actions 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.

✅ Corvin says...

      _
    <(^\  .oO(Caw! ^v^)
     |/(\
      \(\\
      " "\\

"Caw! Your code sparkles like a dropped french fry."

CI Summary

Check Status
Validate action.yml ✅ Passed
Dependency Audit ✅ Passed
Code Coverage ✅ Passed
Format Check ✅ Passed
Docs Site ✅ Passed
Spec Validation ✅ Passed
Tests (build, test, clippy) ✅ Passed
VS Code Extension ✅ Passed
📋 Spec Validation Details

✅ SpecSync: Passed

Metric Value
Specs checked 60
Passed 60
Errors 0
Warnings 0
File coverage 100% (76/76)
LOC coverage 100% (34846/34846)

Generated by specsync · Run specsync check --format github to reproduce


Powered by corvid-pet

…st check

Review found the first fix guarded only `validate_spec` (the `check` path), so
the identical out-of-root identifier leak stayed live through sibling commands
that read `fm.files` and extract exports:
- `score` (and the MCP `score` tool) surfaced out-of-root identifiers in
  suggestions — empirically reproduced;
- `check --fix` read AND persisted them into the spec's Public API table;
- `diff` surfaced them as new_exports; `new` scaffolded them in.

Promote `source_within_root` to a shared `pub(crate)` guard and apply it at every
export-extraction site (scoring.rs, commands/check.rs ×2, commands/diff.rs,
commands/new.rs), closing the class rather than one instance. `compute_coverage`
needs no guard — it enumerates real files under source_dirs and uses `files:`
only as a membership set, so an out-of-root path contributes zero LOC.

Documents the new `source_within_root` export in the validator spec (v4 +
changelog) per the repo's own export-drift rule.

Test: `score` on a spec listing an absolute out-of-root file no longer leaks its
identifiers into suggestions.

Known lower-severity follow-up (existence-oracle only, reveals a bool not
identifiers): depends_on/`.exists()` probes in validator/report/stale/scoring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m
github-actions[bot]
github-actions Bot previously approved these changes Jul 2, 2026

@github-actions github-actions 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.

✅ Corvin says...

      _
    <(^\  .oO(Caw! ^v^)
     |/(\
      \(\\
      " "\\

"Caw! Your code sparkles like a dropped french fry."

CI Summary

Check Status
Validate action.yml ✅ Passed
Dependency Audit ✅ Passed
Code Coverage ✅ Passed
Format Check ✅ Passed
Docs Site ✅ Passed
Spec Validation ✅ Passed
Tests (build, test, clippy) ✅ Passed
VS Code Extension ✅ Passed
📋 Spec Validation Details

✅ SpecSync: Passed

Metric Value
Specs checked 60
Passed 60
Errors 0
Warnings 0
File coverage 100% (76/76)
LOC coverage 100% (34910/34910)

Generated by specsync · Run specsync check --format github to reproduce


Powered by corvid-pet

Re-review confirmed the export-identifier leak is closed at all extraction sites,
but flagged two remaining out-of-root CONTENT reads that my own "every content
read site" comment implied were covered:

- `deps` (`check_undeclared_imports`) read out-of-root `files:` and extracted
  imports (a near-boolean oracle: only surfaces imports matching a known module).
- `check` AI-regen (`regenerate_spec_with_ai`) read the FULL content of out-of-root
  `files:` into the AI prompt — a bigger disclosure, though gated behind the user
  having set SPECSYNC_AI_COMMAND + explicit regen.

Both now skip entries failing `source_within_root`, so no `files:` content read
in the crate touches a path outside the project root. Makes the doc comment true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m

@github-actions github-actions 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.

✅ Corvin says...

      _
    <(^\  .oO(Caw! ^v^)
     |/(\
      \(\\
      " "\\

"Caw! Your code sparkles like a dropped french fry."

CI Summary

Check Status
Validate action.yml ✅ Passed
Dependency Audit ✅ Passed
Code Coverage ✅ Passed
Format Check ✅ Passed
Docs Site ✅ Passed
Spec Validation ✅ Passed
Tests (build, test, clippy) ✅ Passed
VS Code Extension ✅ Passed
📋 Spec Validation Details

✅ SpecSync: Passed

Metric Value
Specs checked 60
Passed 60
Errors 0
Warnings 0
File coverage 100% (76/76)
LOC coverage 100% (34920/34920)

Generated by specsync · Run specsync check --format github to reproduce


Powered by corvid-pet

@0xLeif 0xLeif merged commit 31d6715 into main Jul 2, 2026
16 checks passed
@0xLeif 0xLeif deleted the fix/files-path-escape branch July 2, 2026 16:21
0xLeif added a commit that referenced this pull request Jul 2, 2026
0xLeif added a commit that referenced this pull request Jul 2, 2026
The release now includes the two fixes merged after this branch was cut:
#298 (files: path-escape / info disclosure, Security) and #297 (merge data-loss,
Fixed). Merged main in so the release tag ships the actual code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m
@0xLeif 0xLeif mentioned this pull request Jul 2, 2026
2 tasks
0xLeif added a commit that referenced this pull request Jul 2, 2026
* chore: release v4.6.1

Patch release rolling up the three post-4.6.0 correctness/security fixes:

- Security: aiCommand is honored only from SPECSYNC_AI_COMMAND (malicious-repo
  RCE closed) — #294
- Fixed: non-UTF-8 source files no longer pass validation silently — #293
- Fixed: incremental check re-validates on schema/config change — #295

Bumps Cargo.toml + Cargo.lock and adds the dated CHANGELOG section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m

* Update: expand 4.6.1 changelog to cover all five fixes (#297, #298)

The release now includes the two fixes merged after this branch was cut:
#298 (files: path-escape / info disclosure, Security) and #297 (merge data-loss,
Fixed). Merged main in so the release tag ships the actual code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m

* Fix: flaky ai_command test race on SPECSYNC_AI_COMMAND env var

`resolve_ai_provider` reads the process-global `SPECSYNC_AI_COMMAND` env var above
the config `ai_command` tier. Two unit tests exercised those two tiers:
`resolve_with_env_var` set the env var while `resolve_with_ai_command_in_config`
assumed it unset. Env vars are shared across test threads, so when the two ran
concurrently the config test could observe the leaked `env-ai-tool` value and
fail `assert_eq!("env-ai-tool", "my-custom-ai")`. It passed locally and on main by
scheduling luck but failed deterministically often enough to block CI on all three
platforms.

Serialize every test that touches the var through a shared `ENV_LOCK` mutex
(poison-tolerant) and have the config test clear the var before asserting, so the
two tiers are exercised in isolation regardless of thread interleaving. Verified:
2 env tests pass 25/25 in isolation and the full 666-test binary passes 6/6 under
full parallelism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant