Sicherheit: report/screen maskieren Shell-Eingaben, Sperren privat und atomar

report setzt Zellwerte in <!befehl!> maskiert ein, screen verlangt gültige
Variablennamen, lock/unlock nutzen /tmp/rdb-locks-<UID> mit flock, vilock
sperrt per Hardlink. EDITOR darf Argumente haben. Doku und Tests ergänzt.
This commit is contained in:
wkh
2026-10-30 09:45:18 +01:00
parent df8bcdfaf1
commit 9f6c450492
7 changed files with 203 additions and 14 deletions
+50 -13
View File
@@ -185,44 +185,81 @@ Rdb.register('enter', 'Add rows to a table or list file without an editor', 'ent
end
require 'digest'
require 'shellwords'
# ---- locks -------------------------------------------------------------------
# Lock files live in a private per-user directory, not loose in the shared
# /tmp, so other users can neither forge nor delete them.
def lock_dir
dir = File.join(Dir.tmpdir, "rdb-locks-#{Process.uid}")
Dir.mkdir(dir, 0o700) unless File.directory?(dir)
st = File.lstat(dir)
Rdb.die("#{dir}: not a private directory of yours") if st.symlink? || st.uid != Process.uid || (st.mode & 0o077) != 0
dir
end
def lock_file(name)
File.join(Dir.tmpdir, "L#{File.basename(name)}.#{Digest::MD5.hexdigest(File.expand_path(name))[0, 8]}")
File.join(lock_dir, "L#{File.basename(name)}.#{Digest::MD5.hexdigest(File.expand_path(name))[0, 8]}")
end
# Read-modify-write of a lock file under an exclusive flock, so two lock or
# unlock calls cannot interleave. The block gets the held locks and returns
# the new list of lines, or nil to leave the file alone.
def with_lock_file(name)
File.open(lock_file(name), File::RDWR | File::CREAT, 0o600) do |f|
f.flock(File::LOCK_EX)
held = f.read.lines(chomp: true).map { |l| l.split.map(&:to_i) }
lines = yield held
next unless lines
f.rewind
f.truncate(0)
f.write(lines.map { |l| "#{l}\n" }.join)
end
end
Rdb.register('lock', 'Lock a record or field of a file', 'lock filename processid from to indexfrom indexto') do |args|
die 'usage: lock filename processid from to indexfrom indexto' if args.size < 4
name, pid, from, to = args
lf = lock_file(name)
held = File.exist?(lf) ? File.readlines(lf, chomp: true).map { |l| l.split.map(&:to_i) } : []
clash = held.find { |p, f, t| p != pid.to_i && f < to.to_i && from.to_i < t }
clash = nil
with_lock_file(name) do |held|
clash = held.find { |p, f, t| p != pid.to_i && f < to.to_i && from.to_i < t }
clash ? nil : held.map { |l| l.join(' ') } << args.drop(1).join(' ')
end
if clash
warn "lock: #{name}: bytes #{from}-#{to} are locked by process #{clash[0]}"
exit 1
end
File.open(lf, 'a') { |f| f.puts args.drop(1).join(' ') }
end
Rdb.register('unlock', 'Unlock a record or field of a file', 'unlock tableorlist processid from to indexfrom indexto') do |args|
die 'usage: unlock filename processid from to indexfrom indexto' if args.size < 4
name = args.shift
lf = lock_file(name)
next unless File.exist?(lf)
next unless File.exist?(lock_file(name))
keep = File.readlines(lf, chomp: true).reject { |l| l.split.first(3) == args.first(3) }
keep.empty? ? File.delete(lf) : File.write(lf, keep.join("\n") + "\n")
with_lock_file(name) do |held|
held.map { |l| l.join(' ') }.reject { |l| l.split.first(3) == args.first(3) }
end
lf = lock_file(name)
File.delete(lf) if File.zero?(lf)
end
Rdb.register('vilock', 'Lock a table before editing, unlock afterward', 'vilock tableorlist') do |args|
file = args.first or die 'usage: vilock tableorlist'
locked = File.join(File.dirname(file), "LOCK#{File.basename(file)}")
die "#{file} is locked or missing" if File.exist?(locked) || !File.exist?(file)
die "#{file} is locked or missing" unless File.exist?(file)
File.rename(file, locked)
# link fails if LOCK<file> exists, so exactly one caller gets the table
# (File.exist? followed by rename would leave a race in between).
begin
system(ENV['EDITOR'] || 'vi', locked)
File.link(file, locked)
rescue Errno::EEXIST
die "#{file} is locked or missing"
end
File.unlink(file)
begin
system(*Shellwords.split(ENV['EDITOR'] || 'vi'), locked)
ensure
File.rename(locked, file)
end
@@ -239,7 +276,7 @@ def edit_cells(head, cells)
Tempfile.create('rdb-edit') do |f|
f.write Rdb::Table.new(head, [cells], list: true).to_s
f.flush
system(ENV['EDITOR'] || 'vi', f.path)
system(*Shellwords.split(ENV['EDITOR'] || 'vi'), f.path)
t = Rdb::Table.parse(File.read(f.path))
return nil if t.rows.empty?
+7 -1
View File
@@ -2,13 +2,17 @@
# Reports, forms and mailing lists.
require 'shellwords'
# Fill in a form for one row: <Column> is replaced by the value, <!command!>
# by the output of the shell command, and <!cmd <Column>!> passes data to it.
# Values inside a command are shell-escaped, so a cell can never add commands
# of its own; they arrive as one word each (do not put them in quotes).
def fill_form(form, head, row)
values = head.zip(row).to_h { |h, v| [h.strip, v.to_s] }
form.gsub(/<!(.*?)!>|<([^<>!\n]*)>/m) do
if Regexp.last_match(1)
cmd = Regexp.last_match(1).gsub(/<([^<>!\n]*)>/) { values.fetch(Regexp.last_match(1).strip, Regexp.last_match(0)) }
cmd = Regexp.last_match(1).gsub(/<([^<>!\n]*)>/) { Shellwords.escape(values.fetch(Regexp.last_match(1).strip, Regexp.last_match(0))) }
`#{cmd}`
else
name = Regexp.last_match(2).strip
@@ -57,6 +61,8 @@ Rdb.register('screen', 'Convert a form into a screen-input shell program', 'scre
reads << format('cursor %2d %2d ; %s;', n + 1, col, m[1].strip)
else
names << m[2].strip
# the name becomes a shell variable in the generated program
die "screen: '#{m[2].strip}' is not a valid shell variable name" unless names.last.match?(/\A[A-Za-z_]\w*\z/)
reads << format('cursor %2d %2d ; read %s;', n + 1, col, m[2].strip)
end
out[m.begin(0), m[0].size] = ' ' * m[0].size
+1
View File
@@ -230,6 +230,7 @@ end
Rdb.register('testall', 'Test all /rdb programs in directory $RDB/demo', 'testall [ > testall.new ]') do |_args|
ENV['PATH'] = "#{File.join(Rdb::ROOT, 'bin')}:#{ENV['PATH']}"
ENV['EDITOR'] = 'true' # no real editor in an unattended run (vilock, update.rdb e)
demo = File.join(ENV['RDB'] || Rdb::ROOT, 'demo')
script = File.join(demo, 'testall.cmds')
File.exist?(script) or die "#{script}: missing"