rootwrite

package
v0.85.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 8, 2026 License: MIT Imports: 6 Imported by: 0

Documentation

Overview

Package rootwrite catches writes whose containment under a root is resolved lexically only: os.WriteFile / os.Create / os.OpenFile(write flag) / os.MkdirAll / os.Remove on a path built under a root — filepath.Join, or a flat `root + "/" + x` concatenation — where the root is a caller-supplied parameter or field — with no filepath.EvalSymlinks on that path's chain — plus the archive twin: zip.Writer entry names assembled from a parameter with no path.Clean on the entry-name chain.

The bug class: lexical prefix checks cannot see symlinks. Probes TestApplyRefusesSymlinkEscape (framework/contracts report.go containedPath/Apply, fixed in 77fdbaf4: a diagnostic whose path crossed a symlinked directory was written outside the project root even though Join+HasPrefix said "contained") and TestPackZipPrefixCannotEscapeDir (framework/sdk zip.go PackZip, fixed in 1501a555: a "../" prefix placed archive entries above the target directory on extract).

The 2026-09-04 red-probe round proved two blind spots in battery/storage local.go (probe TestLocalStorageSymlinkEscapeRefused) and both are now in the shape:

  • the Join lives in a same-package helper that RETURNS the path (fullPath: Join(ls.BaseDir, key)) and the caller acts on the result. A path bound to such a helper's result — the helper joins a root/base/dir-named parameter or receiver field, and its body resolves no symlinks — counts as root-derived at the caller;
  • the write is not a plain create: os.Rename / os.Link / os.Symlink DESTINATION arguments (the rename-into-place a storage Save does), os.Remove (a Delete unlinks through a symlinked directory just as Save writes through one), and os.MkdirAll(filepath.Dir(<root-derived path>)) — creating the parent chain of an escaped path creates it outside the root.

The same round's reviewer mutation removed both EvalSymlinks calls from core/upload Save and this rule stayed SILENT on every one of its sinks — the joined component had passed through sanitizeKey, whose RESULT replaced it, and a result-replacing sanitizer used to shield. That shield is gone (the 2026-09-04 posture change): a sanitizer strips "..", it cannot see a symlinked directory, which is exactly how the probes escaped on the read side — rootread never carried the shield. Only RESOLUTION postures gate now, and os.Root is the strongest of them.

Every gate is per write and on the write's own dataflow: resolution or cleaning on an unrelated path (or consulted for a boolean and leaving the path components untouched) gates nothing.

Silent postures, deliberately — os.Root first, as the fix posture of first resort on Go 1.27:

  • writes made through an *os.Root method (os.OpenRoot(root) + root.Create / OpenFile / WriteFile / Mkdir / MkdirAll / Remove / RemoveAll / Rename / Link / Symlink): containment is enforced by the kernel — a symlink under the root cannot lead the write out, and there is no TOCTOU window between check and use. Root methods are no os.* sink, so the rule is quiet by construction (the stashViaRoot fixture keeps it that way);
  • the write's path (or a component of it, or the Join's root) is bound to a filepath.EvalSymlinks result, or an EvalSymlinks ran on this path expression or on its Dir — resolution on the chain (the fix posture; core/upload Save resolves the storage root and the destination's parent directory before creating anything);
  • calls to symlink-named guards (EnsureNoSymlinkPath): resolution by another name;
  • O_NOFOLLOW in a write-open's flags, and an Lstat of the sink's own target consulted with ModeSymlink: the leaf postures — documented partial fixes; the directory components above the leaf remain the writer's problem;
  • a sanitizer or validator in ANY spelling — result-replacing or boolean: neither can see a symlinked directory, so neither gates. Until the 2026-09-04 posture change a result-replacing one kept the write quiet (fixture d's sanitizerResultStillFires and cleanHelperStillFires are positives now, exactly the silence the mutation proof broke);
  • roots that are not parameters or root/base/dir-named fields (a constant or computed root has no caller-controlled boundary to defend), including a rooty local resolved through one;
  • builds whose every non-root argument is a literal — nothing caller-controlled is appended under the root. This holds at the helper hop too: a same-package containment helper called with literal-only non-root arguments stays quiet, and a helper whose body resolves symlinks is the fix posture;
  • temp roots: a local bound to os.MkdirTemp or t.TempDir is throwaway by construction;
  • zip entry names assembled only from literals or non-parameter values (a wrapper forwarding its own name parameter composes nothing), and entry names whose assembly is bound to a path.Clean / filepath.Clean result;
  • reads (os.Open, os.ReadFile, O_RDONLY) by construction — rootread owns that side;
  • _test.go files.

Index

Constants

This section is empty.

Variables

View Source
var Analyzer = &analysis.Analyzer{
	Name: "rootwrite",
	Doc:  "forbids writes under a root whose containment is lexical only: prefer os.OpenRoot (kernel-enforced containment), or resolve with filepath.EvalSymlinks; and path.Clean zip entry names",
	Run:  run,
}

Functions

This section is empty.

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL