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

32

Class Method Summary collapse

Class Method Details

.assert_inside!(path, root, label) ⇒ Object

Raises:



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.

Returns:

  • (Boolean)


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).expand_path
  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).expand_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).expand_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.message}) [#{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