Skip to content

fix(config): confine config-driven filesystem and env access - #586

Merged
goosewobbler merged 1 commit into
mainfrom
fix/config-access-confinement
Jul 19, 2026
Merged

goosewobbler merged 1 commit into
mainfrom
fix/config-access-confinement

Conversation

@goosewobbler

@goosewobbler goosewobbler commented Jul 19, 2026 •

Copy link
Copy Markdown
Owner

Summary

From the 2026-07-19 security review (#559): config-supplied paths and the {file:}/{env:} substitution primitive were unconfined, letting a config drive writes outside the repo and reads of arbitrary files. This grouped fix confines all three surfaces to the repository root.

  • Manifest writes — version.cargo.paths / version.pub.paths are resolved and confined to the workspace root before writing. A ../… or absolute entry that escapes the tree is rejected with a clear VersionError (UNSAFE_CONFIG_PATH) instead of rewriting a foreign Cargo.toml / pubspec.yaml. Applied to both the async PackageProcessor and the sync/single updateCargoFiles write paths.
  • npm.copyFiles — both the copy source and destination must resolve inside the repo root, else the prepare stage throws a FILE_COPY_ERROR.
  • {file:} substitution — confined to the config file's directory; the previous ~ home-directory expansion (the {file:~/.ssh/id_rsa} exfiltration vector) is removed. An out-of-bounds reference throws a ConfigError; an in-bounds but unreadable path still resolves to an empty string.
  • {env:} substitution — intentionally left unconstrained: it is the trusted-operator injection point for secrets (npm/LLM tokens), so allowlisting it would break every consumer. The trust boundary — config values can read files/env and may surface in bot-authored output — is documented on the substitution helper and in the config README.

The boundary predicate isPathWithinRoot is added to @releasekit/core and shared across config, version, and publish.

Test plan

  • New/updated unit tests: @releasekit/core paths.spec.ts; @releasekit/version confinedPath.spec.ts + versionStrategies.spec.ts (threaded root); @releasekit/config substitute.spec.ts (confinement + escape cases); @releasekit/publish prepare.spec.ts (escape rejected).
  • Rebased onto current main (which now includes Publish hardening: leading-dash guard on gh calls; 0600 mode on temp .npmrc #561); the two @releasekit/core index exports coexist. Re-verified the CI way: biome check . exit 0, pnpm schema:check up to date, forced turbo run typecheck test → 24/24.

Closes #559

🤖 Generated with Claude Code

https://claude.ai/code/session_01BT6fVBZrmExXZj7vQ29881

Greptile Summary

This PR closes the config-driven path-traversal attack surface identified in security review #559 by introducing a shared isPathWithinRoot boundary predicate in @releasekit/core and applying it consistently across the three unconfined surfaces.

  • {file:} substitution — confined to the config file's own directory; the ~ home-directory expansion vector is removed and out-of-bounds references now throw a ConfigError rather than silently reading arbitrary files.
  • version.cargo.paths / version.pub.paths manifest writes — both the PackageProcessor (async strategy) and the updateCargoFiles helper (sync/single strategies) now resolve config-supplied paths through resolveConfinedManifestPath, throwing UNSAFE_CONFIG_PATH for any path that escapes the workspace root.
  • npm.copyFiles — both the read source and the write destination are checked against cwd before the copy executes; an escaping entry throws FILE_COPY_ERROR.

Confidence Score: 5/5

Safe to merge — all three unconfined surfaces are now gated by isPathWithinRoot before any filesystem read or write, with no regressions to the happy path.

The boundary predicate is correct for POSIX and Windows cross-drive paths, the resolveConfinedManifestPath helper is applied consistently across both the async PackageProcessor and the sync/single updateCargoFiles paths, and the {file:} confinement throws before any readFileSync call. The ~-expansion exfiltration vector is fully removed. Tests cover equal-root, child, sibling-prefix, ..-escape, absolute-outside, and the ..foo name edge case. No off-by-one or partial-coverage gaps were found.

No files require special attention.

Important Files Changed

Filename Overview
packages/core/src/paths.ts New shared boundary predicate isPathWithinRoot; handles equal-path, ..-escape, and Windows cross-drive cases correctly. The !path.isAbsolute(relative) guard is intentionally retained for Windows even though it is always false on POSIX.
packages/config/src/substitute.ts Removes ~-expansion, adds isPathWithinRoot guard before every fs.readFileSync; os import retained for loadAuth/saveAuth. The rootDir default of process.cwd() is a safe fallback for the public API surface.
packages/config/src/load.ts Correctly threads path.dirname(configPath) as the confinement root into substituteInObject, confining {file:} reads to the config file's directory.
packages/version/src/utils/confinedPath.ts New helper resolveConfinedManifestPath; correctly resolves config-supplied paths and rejects escaping entries with a typed VersionError(UNSAFE_CONFIG_PATH).
packages/version/src/package/packageProcessor.ts Adds repoRoot parameter to processPackages; routes both cargoPaths and dartPaths entries through resolveConfinedManifestPath.
packages/version/src/core/versionStrategies.ts Threads repoRoot (derived from packages.root ?? process.cwd()) through all three strategy paths (sync, single, async); the process.cwd() fallback is conservative and documented.
packages/publish/src/stages/prepare.ts Adds pre-copy confinement check for both src (resolved via path.resolve) and dest (joined via path.join); the src check catches absolute-path injection even when path.join embeds the absolute component as a relative segment.
packages/version/src/errors/versionError.ts Adds UNSAFE_CONFIG_PATH error code with a clear user-facing message and actionable suggestions.
packages/core/src/index.ts Re-exports isPathWithinRoot from the new paths.ts module.
packages/core/test/unit/paths.spec.ts Good coverage: equal-root, child, relative-inside, parent-escape, deep-escape, sibling-prefix, and absolute-outside cases all covered. The ..foo edge case (name starts with ..) correctly passes.
packages/config/test/unit/substitute.spec.ts Drops the ~-expansion test (removed feature), adds confinement and escape tests. Calls without rootDir are all env-only scenarios so the process.cwd() default is never exercised with a {file:} reference.
packages/version/test/unit/utils/confinedPath.spec.ts New spec for resolveConfinedManifestPath; covers in-bounds, sibling-within-root, ..-escape, absolute-escape, and error-code assertions.
packages/version/test/unit/core/versionStrategies.spec.ts Updates assertions to expect the repoRoot argument ('/test/workspace') in processPackages calls.
packages/publish/test/unit/stages/prepare.spec.ts New integration test verifies that a ../../escape.txt entry in copyFiles causes runPrepareStage to throw with a message matching /outside the repository root/.
packages/config/README.md Updates the API table to reflect the new rootDir parameter on substituteInObject.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Config-supplied value] --> B{Which surface?}

    B --> C["{file:PATH} substitution\n(substitute.ts)"]
    B --> D["cargo.paths / pub.paths\n(confinedPath.ts + packageProcessor.ts)"]
    B --> E["npm.copyFiles\n(prepare.ts)"]
    B --> F["{env:NAME} substitution"]

    C --> C1["path.resolve(rootDir, filePath)"]
    C1 --> G{isPathWithinRoot?}
    G -- No --> H["throw ConfigError\n(no read performed)"]
    G -- Yes --> I["fs.readFileSync\n(or '' on ENOENT)"]

    D --> D1["resolveConfinedManifestPath(repoRoot, pkgDir, path, file)"]
    D1 --> D2["path.resolve(pkgDir, configuredPath, manifest)"]
    D2 --> G2{isPathWithinRoot?}
    G2 -- No --> J["throw VersionError\nUNSAFE_CONFIG_PATH"]
    G2 -- Yes --> K["Write manifest"]

    E --> E1["src = path.resolve(cwd, file)\ndest = path.join(pkgDir, file)"]
    E1 --> G3{"isPathWithinRoot(cwd, src)\n&& isPathWithinRoot(cwd, dest)?"}
    G3 -- No --> L["throw PublishError\nFILE_COPY_ERROR"]
    G3 -- Yes --> M["fs.copyFileSync(src, dest)"]

    F --> N["process.env lookup\n(unconstrained — operator trust boundary)"]

    style H fill:#f88,color:#000
    style J fill:#f88,color:#000
    style L fill:#f88,color:#000
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Config-supplied value] --> B{Which surface?}

    B --> C["{file:PATH} substitution\n(substitute.ts)"]
    B --> D["cargo.paths / pub.paths\n(confinedPath.ts + packageProcessor.ts)"]
    B --> E["npm.copyFiles\n(prepare.ts)"]
    B --> F["{env:NAME} substitution"]

    C --> C1["path.resolve(rootDir, filePath)"]
    C1 --> G{isPathWithinRoot?}
    G -- No --> H["throw ConfigError\n(no read performed)"]
    G -- Yes --> I["fs.readFileSync\n(or '' on ENOENT)"]

    D --> D1["resolveConfinedManifestPath(repoRoot, pkgDir, path, file)"]
    D1 --> D2["path.resolve(pkgDir, configuredPath, manifest)"]
    D2 --> G2{isPathWithinRoot?}
    G2 -- No --> J["throw VersionError\nUNSAFE_CONFIG_PATH"]
    G2 -- Yes --> K["Write manifest"]

    E --> E1["src = path.resolve(cwd, file)\ndest = path.join(pkgDir, file)"]
    E1 --> G3{"isPathWithinRoot(cwd, src)\n&& isPathWithinRoot(cwd, dest)?"}
    G3 -- No --> L["throw PublishError\nFILE_COPY_ERROR"]
    G3 -- Yes --> M["fs.copyFileSync(src, dest)"]

    F --> N["process.env lookup\n(unconstrained — operator trust boundary)"]

    style H fill:#f88,color:#000
    style J fill:#f88,color:#000
    style L fill:#f88,color:#000
Loading

Comments Outside Diff (1)

  1. packages/core/src/paths.ts, line 231-232 (link)

    P2 Two unreachable conditions in boundary predicate

    The early-return if (resolvedTarget === resolvedRoot) return true on line 228 ensures that when resolvedTarget equals resolvedRoot, we return early. After that branch, path.relative(resolvedRoot, resolvedTarget) will never return '' (which only happens when the two paths are identical), making relative !== '' permanently true and therefore redundant. Similarly, !path.isAbsolute(relative) is always true on POSIX because path.relative never produces an absolute path. Neither condition is harmful, but both add noise to the security-critical boundary predicate and could mislead a future reader into thinking they guard meaningful cases.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Claude Code Fix in Cursor

Reviews (3): Last reviewed commit: "fix(config): confine config-driven files..." | Re-trigger Greptile

@goosewobbler goosewobbler added this to the 0.42.0 milestone Jul 19, 2026
@github-actions

github-actions Bot commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

Standing release PR: #582 · v0.41.1 queued (4 packages) · open 2h · ✅ ready to merge

Release Preview — v0.41.0 → v0.41.1

Note: Labels on this PR are advisory in standing-pr mode. Bumps come from conventional commits in the standing PR; override by editing labels on the standing PR itself. Add release:immediate to bypass the standing PR and release this PR directly.

These changes will be added to the release PR (#582) when merged:

Changelog

monorepo 0.41.0 → 0.41.1

Fixed

  • confine config-driven filesystem and env access (config) (#559)

All packages → 0.41.1 (sync versioning)

  • @releasekit/version
  • @releasekit/notes
  • @releasekit/publish
  • @releasekit/release

After merge — predicted release

No version escalation — this PR's changes will be included in the queued release without affecting the projected versions.

Package Standing PR This PR After merge
monorepo 0.41.1 0.41.1 0.41.1

Updated automatically by ReleaseKit

@goosewobbler
goosewobbler force-pushed the fix/config-access-confinement branch from 1a6f1b6 to 4f026ad Compare July 19, 2026 22:42
Prevent a malicious or mistaken config from driving reads/writes outside
the repository.

- version.cargo.paths / version.pub.paths manifest writes are confined to
  the workspace root, in both the async PackageProcessor path and the
  sync/single updateCargoFiles path; an entry escaping the root via `..`
  or an absolute path is rejected with a VersionError (UNSAFE_CONFIG_PATH).
- npm.copyFiles source and destination are confined to the repo root; a
  `../` or absolute entry is rejected with a FILE_COPY_ERROR.
- {file:} substitution is confined to the config file's directory (the old
  `~` home-dir expansion is dropped) so a config can no longer read files
  such as ~/.ssh/id_rsa or /etc/passwd; an escaping reference throws a
  ConfigError, while an in-bounds-but-unreadable path still resolves to
  empty. {env:} is unchanged — it is the trusted-operator secret-injection
  mechanism — and the trust boundary is documented.

The shared boundary check lives in @releasekit/core (isPathWithinRoot).

Closes #559

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BT6fVBZrmExXZj7vQ29881
@goosewobbler
goosewobbler force-pushed the fix/config-access-confinement branch from 4f026ad to 0d17ccc Compare July 19, 2026 23:07
@goosewobbler

goosewobbler commented Jul 19, 2026 •

Copy link
Copy Markdown
Owner Author

Re: Greptile "Two unreachable conditions in boundary predicate" (packages/core/src/paths.ts) —

Partially applied:

  • relative !== '' — agreed, redundant (the resolvedTarget === resolvedRoot early-return is the only case path.relative yields ''). Removed.
  • !path.isAbsolute(relative) — kept deliberately; it is not dead on Windows. path.relative returns an absolute path when root and target are on different drives (e.g. path.relative('C:\\a', 'D:\\b') → 'D:\\b'), so without this check a different-drive target would slip past the .. / ../… checks and be treated as in-root. Windows is a supported OS, so this guard is load-bearing. Added an in-code comment noting the cross-drive rationale so it isn't mistaken for dead code again.

"Always true on POSIX" is correct, but the predicate is cross-platform.

@goosewobbler
goosewobbler merged commit 4ce2a60 into main Jul 19, 2026
31 checks passed
@goosewobbler
goosewobbler deleted the fix/config-access-confinement branch July 19, 2026 23:33
@github-actions github-actions Bot mentioned this pull request Jul 19, 2026
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.

Confine config-driven filesystem & env access (path traversal on writes; {file:}/{env:} read primitive)

1 participant