Skip to content

feat(linter): route file access through os.Root to prevent path trave…#16

Merged
nozaq merged 6 commits into
mainfrom
claude/path-traversal-os-root-ofl7ul
Jul 14, 2026
Merged

feat(linter): route file access through os.Root to prevent path trave…#16
nozaq merged 6 commits into
mainfrom
claude/path-traversal-os-root-ofl7ul

Conversation

@nozaq

@nozaq nozaq commented Jul 14, 2026

Copy link
Copy Markdown
Member

…rsal

Upcoming Feature/dependsOn resolution will read files at paths derived from untrusted configuration content (e.g. local Feature references like "./features/foo"). As a proactive defense, LintDir and FindConfigs now perform all filesystem access through a single os.Root opened on the directory being linted, so any path derived from configuration content cannot escape it.

Behavior change: a configuration file reached via a symbolic link that escapes the lint directory (including any link with an absolute target) is now treated as nonexistent instead of being followed. Links that resolve within the directory keep working.

Claude-Session: https://claude.ai/code/session_012ujRvJhfjXKaXwNVGwgxv4

claude added 5 commits July 14, 2026 00:32
…rsal

Upcoming Feature/dependsOn resolution will read files at paths derived
from untrusted configuration content (e.g. local Feature references
like "./features/foo"). As a proactive defense, LintDir and FindConfigs
now perform all filesystem access through a single os.Root opened on
the directory being linted, so any path derived from configuration
content cannot escape it.

Behavior change: a configuration file reached via a symbolic link that
escapes the lint directory (including any link with an absolute target)
is now treated as nonexistent instead of being followed. Links that
resolve within the directory keep working.

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

The os.Root opened by LintDir covers the lint-target directory, which
is the parent of .devcontainer, so a symbolic link such as
.devcontainer/devcontainer.json -> ../secret.json could still reach
unrelated files in the project root. Configuration files under
.devcontainer are now discovered and read through a sub-root confined
to that directory. The boundary is .devcontainer itself rather than
each config's own subfolder, because Features stored inside the active
.devcontainer directory can become dependsOn targets once Feature
resolution is implemented.

Also remove the exported FindConfigs, which lost its last production
caller in the os.Root refactoring; discovery is tested directly against
the unexported findConfigs instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ujRvJhfjXKaXwNVGwgxv4
Move the os.Root creation out of LintDir and into the CLI: main.go opens
each lint path as an os.Root and LintDir only reads files and directories
through the root it receives. The not-a-directory check now falls out of
os.OpenRoot itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ujRvJhfjXKaXwNVGwgxv4
…r root once

Replace findConfigs/readConfig with visitConfigs, which walks the
configuration files and hands each one to a callback together with the
os.Root it must be read through. The whole walk completes within one
call, so the .devcontainer sub-root is opened once and closed by a
defer, instead of being reopened by readConfig for every file. The
per-file work is extracted into lintConfig, giving each file its own
function scope for deferred cleanup. A broken file still does not stop
the remaining files from being linted; only a callback error (context
cancellation) aborts the visit. The now-unused exported ConfigFile type
is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ujRvJhfjXKaXwNVGwgxv4
The feature branch returned fn's error directly, while every other
branch used an explicit if-err check because it has work to do
afterward. Match the feature branch to that style for consistency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ujRvJhfjXKaXwNVGwgxv4
@nozaq nozaq marked this pull request as ready for review July 14, 2026 12:49
…test files

linter_test.go relied on the production rules package's no-image-latest
rule just to exercise LintDir's file discovery and error handling,
which are independent of which rules are registered. Replace it with a
package-local test double (like the existing panicRule), removing
linter's only dependency on rules from its test binary.

Also fold linter_internal_test.go's white-box visitConfigs tests into
this file (both now package linter), so the duplicated openRoot helper
collapses to one copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ujRvJhfjXKaXwNVGwgxv4
@nozaq nozaq merged commit b1377b4 into main Jul 14, 2026
8 checks passed
@nozaq nozaq deleted the claude/path-traversal-os-root-ofl7ul branch July 14, 2026 22:31
nozaq pushed a commit that referenced this pull request Jul 14, 2026
Resolves the conflict from #16's os.Root-based path traversal
hardening in linter.go/linter_test.go:

- linter/linter_test.go: PR #16 rewrote this file to use the
  internal "package linter" test convention (dropping the
  linter_test/rules imports); the Transform tests added on this
  branch (flagRule, TestLintTransform) are adapted to that
  convention, unqualifying references to the now-local package.
- cmd/decolint/main.go, linter/linter.go: auto-merged cleanly;
  runLint's SetTransform wiring for -merge-features is unaffected,
  since lintPath/LintDir's os.Root confinement only changes how the
  main devcontainer.json/Feature/Template files are discovered and
  read, not the *Context passed to Transform.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrZdqcow5W3yq4uGMMPS8D
nozaq pushed a commit that referenced this pull request Jul 14, 2026
#16 routed the main devcontainer.json/Feature/Template file discovery
through os.Root to prevent path traversal, but local Feature
references resolved during -merge-features (a relative path under
"features") still went through plain os.ReadFile/os.Stat, bypassing
that protection.

Thread the same os.Root boundary through: linter.Context gains Dir
(the os.Root LintDir already confines the file to) and FileDir (the
file's own directory relative to it), populated by lintConfig and
left zero when Lint is called directly on in-memory content (as
existing tests do). feature.Fetch/Merge now take that (dir, fileDir)
pair instead of a bare baseDir string, and resolve local references
by joining fileDir with the reference and reading it through dir, so
a reference cannot escape the same boundary the referencing
devcontainer.json was itself read through.

Note this changes local Feature semantics slightly: a Feature stored
outside the confining directory (e.g. a project-root sibling of
.devcontainer, referenced from .devcontainer/devcontainer.json via
"../") no longer resolves, even though it did before #16. This
mirrors the boundary #16 already established for the file discovery
its own comment anticipated extending to Feature resolution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrZdqcow5W3yq4uGMMPS8D
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.

2 participants