Module: Senren::Rails::SafeWrite
- Defined in:
- lib/senren/rails/safe_write.rb
Overview
Containment for everything this gem writes into a host app.
The threat model is a repository you clone and run senren:install in.
The content written is gem-controlled, so this is a file-creation and
overwrite primitive rather than an arbitrary-content write — but a
checkout that ships app/components/senren as a symlink pointing outside
the project could previously redirect every copied file, and
write_adapter_file reads its destination before rewriting it, so
pre-existing content outside the checkout was modified too.
Two independent defects made that possible, and both are fixed here:
1. Containment used Pathname#expand_path, which normalises `..`
lexically and does NOT resolve symlinks. An escaping path therefore
passed the start_with? check. Containment must compare realpath.
2. Symlinks were refused on the leaf only, never on an intermediate
directory, and Pathname#mkpath stops at File.directory? — which
follows a link — so a symlinked parent was preserved rather than
replaced.
Defined Under Namespace
Classes: Escape
Constant Summary collapse
- MAX_LINK_DEPTH =
32
Class Method Summary collapse
- .assert_inside!(path, root, label) ⇒ Object
-
.assert_writable!(path, root, label) ⇒ Object
An in-repo symlink is left in place: the write goes to what it points at, so
ln -s AGENTS.md CLAUDE.mdsurvives the write instead of being replaced by a regular file. -
.atomically(target) ⇒ Object
The temporary was the last way out of the app root, and containment never looked at it.
- .copy!(source, path, root, label) ⇒ Object
-
.inside?(path, root) ⇒ Boolean
True when a write to
pathwould land insideroot. -
.mkdir_p!(dir, root, label) ⇒ Object
Creates
dir, refusing only when it would land outside the root. -
.real_target(path, depth = 0) ⇒ Object
Where a write to
pathwould actually land, with symlinks followed -- including a DANGLING one, by reading its declared target rather than asking the filesystem to resolve it. -
.resolve(dest, root, label, io: $stdout) ⇒ Object
Resolves a destination for writing, or returns nil when it must be skipped.
-
.resolve_existing_prefix(path) ⇒ Object
For a destination that does not exist yet: resolve the deepest ancestor that does, then re-append the part that is still missing.
-
.write!(path, content, root, label) ⇒ Object
Every write this gem performs goes through here or #copy!.
Class Method Details
.assert_inside!(path, root, label) ⇒ Object
96 97 98 99 100 101 102 |
# File 'lib/senren/rails/safe_write.rb', line 96 def assert_inside!(path, root, label) return real_target(path) if inside?(path, root) raise Escape, "Refusing to write outside the app root: #{path} resolves to " \ "#{real_target(path)} (#{label})" end |
.assert_writable!(path, root, label) ⇒ Object
An in-repo symlink is left in place: the write goes to what it points
at, so ln -s AGENTS.md CLAUDE.md survives the write instead of being
replaced by a regular file.
123 124 125 |
# File 'lib/senren/rails/safe_write.rb', line 123 def assert_writable!(path, root, label) assert_inside!(path, root, label) end |
.atomically(target) ⇒ Object
The temporary was the last way out of the app root, and containment never looked at it.
<dest>.<pid>.tmp is guessable, and git stores symlinks, so a
repository could ship .senren/registry.yml.<pid>.tmp as a link to
something outside the checkout. File.write follows a symlink, so the
write landed there with every containment check having passed —
verified before this was changed.
Two independent fixes, because either alone is thin. A random suffix removes the guess, and O_CREAT|O_EXCL refuses to open an existing path at all and does not follow a final symlink, so a lucky guess still fails. 0o644 rather than 0o600 keeps the permissions a plain write would have produced.
159 160 161 162 163 164 165 166 |
# File 'lib/senren/rails/safe_write.rb', line 159 def atomically(target, &) tmp = Pathname.new("#{target}.#{SecureRandom.hex(12)}.tmp") File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o644, &) File.rename(tmp, target) target ensure FileUtils.rm_f(tmp) if tmp&.exist? end |
.copy!(source, path, root, label) ⇒ Object
140 141 142 143 |
# File 'lib/senren/rails/safe_write.rb', line 140 def copy!(source, path, root, label) target = assert_writable!(path, root, label) atomically(target) { |io| IO.copy_stream(source.to_s, io) } end |
.inside?(path, root) ⇒ Boolean
True when a write to path would land inside root.
The property is containment, NOT the absence of symlinks. An earlier
version refused every link on the path regardless of where it pointed,
which broke ln -s AGENTS.md CLAUDE.md -- an ordinary way to keep one
set of agent instructions -- and took senren:add down with it.
88 89 90 91 92 93 94 |
# File 'lib/senren/rails/safe_write.rb', line 88 def inside?(path, root) real_root = Pathname.new(root). real_root = real_root.realpath if real_root.exist? target = real_target(path) target == real_root || target.to_s.start_with?("#{real_root}#{File::SEPARATOR}") end |
.mkdir_p!(dir, root, label) ⇒ Object
Creates dir, refusing only when it would land outside the root.
105 106 107 108 109 |
# File 'lib/senren/rails/safe_write.rb', line 105 def mkdir_p!(dir, root, label) target = assert_inside!(dir, root, label) FileUtils.mkdir_p(target) target end |
.real_target(path, depth = 0) ⇒ Object
Where a write to path would actually land, with symlinks followed --
including a DANGLING one, by reading its declared target rather than
asking the filesystem to resolve it.
Following the declared target is what makes the dangling case decidable.
exist? is false on a broken link, so a check that resolved only
existing ancestors would clear the parent directory and let the write
land wherever the link points.
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
# File 'lib/senren/rails/safe_write.rb', line 43 def real_target(path, depth = 0) current = Pathname.new(path). return current if depth > MAX_LINK_DEPTH if current.symlink? link = Pathname.new(File.readlink(current)) link = current.dirname + link unless link.absolute? return real_target(link, depth + 1) end return current.realpath if current.exist? resolve_existing_prefix(current) rescue SystemCallError Pathname.new(path). end |
.resolve(dest, root, label, io: $stdout) ⇒ Object
Resolves a destination for writing, or returns nil when it must be skipped. Raises when the destination escapes the app root.
113 114 115 116 117 118 |
# File 'lib/senren/rails/safe_write.rb', line 113 def resolve(dest, root, label, io: $stdout) assert_inside!(dest, root, label) rescue Escape => e io.puts " skip #{dest} (#{e.}) [#{label}]" nil end |
.resolve_existing_prefix(path) ⇒ Object
For a destination that does not exist yet: resolve the deepest ancestor that does, then re-append the part that is still missing.
symlink? is part of the loop condition, not just exist?. A dangling
link is invisible to exist?, so a walk that only tested existence
stepped straight over an intermediate one and rebuilt the tail
lexically -- reporting a path inside the root for a write that would
land wherever the link pointed. Not reachable through today's callers,
because each one runs mkdir_p! on the parent first and that catches it,
but that is an implicit contract between caller and helper, and this
cycle has already broken two of those.
70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/senren/rails/safe_write.rb', line 70 def resolve_existing_prefix(path) tail = [] current = path until current.exist? || current.symlink? || current.root? tail.unshift(current.basename) current = current.parent end current = real_target(current) if current.symlink? current = current.realpath if current.exist? tail.reduce(current) { |acc, part| acc.join(part) } end |
.write!(path, content, root, label) ⇒ Object
Every write this gem performs goes through here or #copy!.
Hand-rolled File.write / FileUtils.cp is what let component source escape twice: once through a symlinked directory, and once -- after that was fixed -- through a symlinked destination file, because containment had been applied to the parent only. Writing via a temporary and renaming also means a killed process cannot truncate the file: rename is atomic and does not follow a symlink at the target.
135 136 137 138 |
# File 'lib/senren/rails/safe_write.rb', line 135 def write!(path, content, root, label) target = assert_writable!(path, root, label) atomically(target) { |io| io.write(content) } end |