safe_image 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +193 -0
- data/README.md +166 -11
- data/lib/safe_image/discourse_compat.rb +2 -13
- data/lib/safe_image/ico.rb +1 -1
- data/lib/safe_image/native.rb +24 -15
- data/lib/safe_image/optimizer.rb +79 -4
- data/lib/safe_image/processor.rb +1 -1
- data/lib/safe_image/remote.rb +174 -8
- data/lib/safe_image/runner.rb +9 -1
- data/lib/safe_image/sandbox.rb +41 -14
- data/lib/safe_image/svg_css.rb +314 -0
- data/lib/safe_image/svg_metadata.rb +179 -53
- data/lib/safe_image/svg_sanitizer.rb +524 -43
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/zygote.rb +619 -0
- data/lib/safe_image.rb +12 -0
- metadata +18 -2
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
|
|
8
|
+
module SafeImage
|
|
9
|
+
# Raised when a worker's request/reply channel breaks (closed or broken pipe,
|
|
10
|
+
# truncated reply, protocol garbage, or a reply-deadline overrun) as opposed
|
|
11
|
+
# to the operation itself failing. It is a CommandError so callers catching
|
|
12
|
+
# the documented error still handle it, but the pool treats it specially:
|
|
13
|
+
# the worker is discarded, never returned to the idle set. Process liveness
|
|
14
|
+
# is the wrong reuse signal — a worker can be alive with a dead pipe (e.g. a
|
|
15
|
+
# concurrent reconfigure closed it) — so channel health drives the decision.
|
|
16
|
+
class WorkerBroken < CommandError; end
|
|
17
|
+
|
|
18
|
+
# Pool of persistent pre-booted sandbox workers. The exec-per-operation
|
|
19
|
+
# worker in Sandbox.run_worker! pays ~55ms of Ruby boot + requires (plus
|
|
20
|
+
# ~27ms of libvips dlopen/init on the vips backend) on every call; a zygote
|
|
21
|
+
# pays it once, then serves each operation from a fork (~1ms) that sandboxes
|
|
22
|
+
# ITSELF before touching any untrusted input. After IDLE_SECONDS without work
|
|
23
|
+
# a zygote exits on its own, so no resident process outlives a burst.
|
|
24
|
+
#
|
|
25
|
+
# Concurrency: each zygote serves one operation at a time over its pipe (its
|
|
26
|
+
# forked child does the work, but replies stream back over one pipe). To let
|
|
27
|
+
# N threads run sandboxed operations at once — the exec worker had unbounded
|
|
28
|
+
# per-thread concurrency, a single zygote would serialise them — workers are
|
|
29
|
+
# pooled. A call checks out an idle worker (or spawns one, up to
|
|
30
|
+
# MAX_WORKERS), and returns it when done; offered concurrency past the cap
|
|
31
|
+
# blocks until a worker frees, which also bounds concurrent libvips memory.
|
|
32
|
+
#
|
|
33
|
+
# Trust model (same as the exec worker): a zygote is a fresh Ruby process
|
|
34
|
+
# booted with a scrubbed env that only ever parses requests from the parent —
|
|
35
|
+
# never untrusted bytes. Untrusted input is only opened in the forked
|
|
36
|
+
# grandchild, after it has applied rlimits, a per-operation Landlock policy
|
|
37
|
+
# (filesystem allowlist; all TCP denied via a handled-but-unmatchable port
|
|
38
|
+
# rule on ABI >= 4; abstract-unix-socket and signal scopes on ABI >= 6), and
|
|
39
|
+
# — where the landlock gem exposes it — the helper's deny-all-network seccomp
|
|
40
|
+
# filter (closing the non-TCP/UDP gap the in-process Landlock policy alone
|
|
41
|
+
# leaves open). Forking is sound because a zygote never runs an operation
|
|
42
|
+
# itself: libvips is initialised but quiescent (zero native threads) at every
|
|
43
|
+
# fork.
|
|
44
|
+
module Zygote
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# How long an idle zygote lingers before exiting. Idling is cheap — ~16MB
|
|
48
|
+
# private memory (the ~48MB RSS is mostly shared library pages), flat
|
|
49
|
+
# across operations, zero CPU (blocked in select) — and a parent that
|
|
50
|
+
# exits takes its zygotes with it immediately via stdin EOF, so the window
|
|
51
|
+
# is generous. Overridable via the SAFE_IMAGE_ZYGOTE_IDLE_SECONDS env var.
|
|
52
|
+
IDLE_SECONDS = 300
|
|
53
|
+
|
|
54
|
+
# Max concurrent sandboxed operations (= resident workers under load).
|
|
55
|
+
# Overridable via SAFE_IMAGE_ZYGOTE_WORKERS. The cap is backpressure: a
|
|
56
|
+
# burst of 50 uploads runs at most this many libvips decodes at once.
|
|
57
|
+
DEFAULT_MAX_WORKERS = 8
|
|
58
|
+
|
|
59
|
+
SPAWN_TIMEOUT = 30
|
|
60
|
+
# The parent's reply deadline is the worker's own operation timeout plus
|
|
61
|
+
# this grace: the worker enforces Runner::DEFAULT_TIMEOUT around the forked
|
|
62
|
+
# child (killing it and replying), and the grace covers the worker's reply
|
|
63
|
+
# serialization and child reaping so the parent only gives up — and kills
|
|
64
|
+
# the worker — when the worker itself has wedged, not merely when the
|
|
65
|
+
# operation ran long.
|
|
66
|
+
RESPONSE_GRACE = 10
|
|
67
|
+
MAX_RESPONSE_BYTES = 512 * 1024
|
|
68
|
+
|
|
69
|
+
# generation: the pool generation a worker was born into; shutdown!/fork
|
|
70
|
+
# bump the generation so a worker checked out under the old config is
|
|
71
|
+
# retired (never re-pooled) when it returns. owner_pid: the process that
|
|
72
|
+
# spawned it, so a worker inherited across fork is never killed by the
|
|
73
|
+
# child (it belongs to the parent).
|
|
74
|
+
# tmproot: a parent-created directory the worker puts its per-operation
|
|
75
|
+
# tmpdirs under. The worker removes it on graceful exit (at_exit); the
|
|
76
|
+
# parent removes it when it kills the worker, so a SIGKILL mid-operation
|
|
77
|
+
# (where the worker cannot clean up) does not leak the op's tmpdir.
|
|
78
|
+
Worker = Struct.new(:pid, :stdin, :stdout, :last_used, :generation, :owner_pid, :tmproot)
|
|
79
|
+
|
|
80
|
+
@mutex = Mutex.new
|
|
81
|
+
@free = ConditionVariable.new
|
|
82
|
+
@idle = [] # checked-in Workers of the current generation, MRU last
|
|
83
|
+
@count = 0 # live workers of the current generation: idle + checked out
|
|
84
|
+
@generation = 0 # bumped by shutdown!/fork to retire outstanding workers
|
|
85
|
+
@key = nil # [pid, backend, max_pixels] the pool was built for
|
|
86
|
+
|
|
87
|
+
def enabled?
|
|
88
|
+
ENV["SAFE_IMAGE_ZYGOTE"] != "0" && Process.respond_to?(:fork)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def max_workers
|
|
92
|
+
n = ENV["SAFE_IMAGE_ZYGOTE_WORKERS"].to_i
|
|
93
|
+
n.positive? ? n : DEFAULT_MAX_WORKERS
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Exposed for tests/diagnostics: the idle worker that a serial caller keeps
|
|
97
|
+
# reusing (nil mid-operation or when the pool is empty).
|
|
98
|
+
def pid
|
|
99
|
+
@mutex.synchronize { @idle.last&.pid }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def pids
|
|
103
|
+
@mutex.synchronize { @idle.map(&:pid) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def pool_size
|
|
107
|
+
@mutex.synchronize { @count }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def shutdown!
|
|
111
|
+
@mutex.synchronize do
|
|
112
|
+
@idle.each { |w| close_worker(w, kill: w.owner_pid == Process.pid) }
|
|
113
|
+
@idle.clear
|
|
114
|
+
@count = 0
|
|
115
|
+
@key = nil
|
|
116
|
+
# Retire any worker still checked out: its generation no longer matches,
|
|
117
|
+
# so checkin/discard will close it instead of returning it to the pool.
|
|
118
|
+
# This is what stops a worker booted under the old config from serving
|
|
119
|
+
# an operation after a reconfigure.
|
|
120
|
+
@generation += 1
|
|
121
|
+
@free.broadcast
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def call!(operation, request)
|
|
126
|
+
payload = JSON.dump(
|
|
127
|
+
operation: operation.to_s,
|
|
128
|
+
request: Sandbox.deep_encode_symbols(request),
|
|
129
|
+
paths: Sandbox.sandbox_paths(request, operation),
|
|
130
|
+
timeout: Runner::DEFAULT_TIMEOUT
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
attempts = 0
|
|
134
|
+
begin
|
|
135
|
+
attempts += 1
|
|
136
|
+
worker = checkout
|
|
137
|
+
# Every path below returns the worker to the pool exactly once
|
|
138
|
+
# (checkin if reusable, discard otherwise) so a slot is never leaked.
|
|
139
|
+
begin
|
|
140
|
+
worker.stdin.puts(payload)
|
|
141
|
+
rescue Errno::EPIPE, IOError
|
|
142
|
+
# The channel is gone before the request landed — the worker
|
|
143
|
+
# idle-exited, or a concurrent reconfigure closed its pipe. Nothing
|
|
144
|
+
# ran, so discard it and respawn once, transparently.
|
|
145
|
+
discard(worker)
|
|
146
|
+
retry if attempts == 1
|
|
147
|
+
raise CommandError.new("sandbox zygote is not accepting requests", command: ["zygote"])
|
|
148
|
+
rescue StandardError
|
|
149
|
+
discard(worker)
|
|
150
|
+
raise
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
begin
|
|
154
|
+
reply = read_reply(worker)
|
|
155
|
+
rescue WorkerBroken
|
|
156
|
+
# The channel broke (closed/broken pipe, truncated reply, protocol
|
|
157
|
+
# error, deadline). The worker is unusable regardless of whether its
|
|
158
|
+
# process is still alive — drop it, never return it to the pool.
|
|
159
|
+
discard(worker)
|
|
160
|
+
raise
|
|
161
|
+
rescue StandardError
|
|
162
|
+
# The worker replied with an operation failure (oxipng exited 1, ...)
|
|
163
|
+
# and is otherwise healthy, so return it to the pool for reuse.
|
|
164
|
+
checkin(worker)
|
|
165
|
+
raise
|
|
166
|
+
end
|
|
167
|
+
checkin(worker)
|
|
168
|
+
reply
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Block until a worker is available, spawning one (outside the lock) when
|
|
173
|
+
# the pool is below the cap.
|
|
174
|
+
def checkout
|
|
175
|
+
loop do
|
|
176
|
+
gen = nil
|
|
177
|
+
@mutex.synchronize do
|
|
178
|
+
drop_foreign_pool!
|
|
179
|
+
while (w = @idle.pop)
|
|
180
|
+
return w if worker_usable?(w)
|
|
181
|
+
|
|
182
|
+
drop_worker(w)
|
|
183
|
+
end
|
|
184
|
+
if @count < max_workers
|
|
185
|
+
@count += 1
|
|
186
|
+
@key ||= pool_key
|
|
187
|
+
gen = @generation
|
|
188
|
+
else
|
|
189
|
+
@free.wait(@mutex)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
next unless gen
|
|
193
|
+
|
|
194
|
+
begin
|
|
195
|
+
return spawn_worker(gen)
|
|
196
|
+
rescue StandardError
|
|
197
|
+
@mutex.synchronize do
|
|
198
|
+
# Release the reserved slot, but only against the generation it was
|
|
199
|
+
# reserved under — a concurrent shutdown! may have zeroed @count.
|
|
200
|
+
@count -= 1 if gen == @generation
|
|
201
|
+
@free.signal
|
|
202
|
+
end
|
|
203
|
+
raise
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def checkin(worker)
|
|
209
|
+
@mutex.synchronize do
|
|
210
|
+
if worker.generation == @generation
|
|
211
|
+
worker.last_used = monotonic
|
|
212
|
+
@idle.push(worker)
|
|
213
|
+
@free.signal
|
|
214
|
+
else
|
|
215
|
+
# Retired by a shutdown!/reconfigure while it was checked out.
|
|
216
|
+
drop_worker(worker)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def discard(worker)
|
|
222
|
+
@mutex.synchronize { drop_worker(worker) }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Close a worker and release its pool slot. The slot is only counted
|
|
226
|
+
# against the current generation — a worker retired by shutdown!/fork
|
|
227
|
+
# belongs to a generation whose @count was already zeroed, so its return
|
|
228
|
+
# must not push @count negative. A worker spawned by another process
|
|
229
|
+
# (inherited across fork) is closed but never killed.
|
|
230
|
+
def drop_worker(worker)
|
|
231
|
+
close_worker(worker, kill: worker.owner_pid == Process.pid)
|
|
232
|
+
@count -= 1 if worker.generation == @generation
|
|
233
|
+
@free.signal
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# A pool inherited across fork belongs to the parent: drop our copies of
|
|
237
|
+
# its pipes without killing the parent's processes, retire the generation
|
|
238
|
+
# (so a worker checked out across the fork is not reused), and rebuild
|
|
239
|
+
# lazily.
|
|
240
|
+
def drop_foreign_pool!
|
|
241
|
+
return unless @key && @key[0] != Process.pid
|
|
242
|
+
|
|
243
|
+
@idle.each { |w| close_worker(w, kill: false) }
|
|
244
|
+
@idle.clear
|
|
245
|
+
@count = 0
|
|
246
|
+
@key = nil
|
|
247
|
+
@generation += 1
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def pool_key
|
|
251
|
+
config = SafeImage.config
|
|
252
|
+
[Process.pid, config.backend, config.max_pixels]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def worker_usable?(worker)
|
|
256
|
+
worker.generation == @generation &&
|
|
257
|
+
alive?(worker.pid) &&
|
|
258
|
+
(monotonic - worker.last_used) < idle_seconds
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Falls back to the default on a missing, non-numeric, or non-positive
|
|
262
|
+
# value rather than raising or letting a negative reach the worker's
|
|
263
|
+
# IO.select idle timeout (which would raise there).
|
|
264
|
+
def idle_seconds
|
|
265
|
+
raw = ENV["SAFE_IMAGE_ZYGOTE_IDLE_SECONDS"]
|
|
266
|
+
return IDLE_SECONDS unless raw
|
|
267
|
+
|
|
268
|
+
value = begin
|
|
269
|
+
Float(raw)
|
|
270
|
+
rescue ArgumentError, TypeError
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
value&.positive? ? value : IDLE_SECONDS
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def monotonic
|
|
277
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def spawn_worker(generation)
|
|
281
|
+
require "landlock"
|
|
282
|
+
config = SafeImage.config
|
|
283
|
+
tmproot = Dir.mktmpdir("safe-image-zygote-")
|
|
284
|
+
boot = JSON.dump(
|
|
285
|
+
config: { backend: config.backend, max_pixels: config.max_pixels },
|
|
286
|
+
idle_seconds: idle_seconds,
|
|
287
|
+
tmproot: tmproot,
|
|
288
|
+
rlimits: Sandbox::DEFAULT_RLIMITS,
|
|
289
|
+
execute: Sandbox.existing_paths([*Landlock::SafeExec.default_execute_paths, File.dirname(RbConfig.ruby)]),
|
|
290
|
+
max_response_bytes: MAX_RESPONSE_BYTES
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
env = Runner.command_env(Dir.tmpdir).merge(
|
|
294
|
+
"SAFE_IMAGE_SANDBOX_CHILD" => "1",
|
|
295
|
+
"GEM_HOME" => ENV["GEM_HOME"].to_s,
|
|
296
|
+
"GEM_PATH" => ENV["GEM_PATH"].to_s,
|
|
297
|
+
"RUBYLIB" => $LOAD_PATH.select { |p| p && File.directory?(p) }.join(File::PATH_SEPARATOR)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
in_r, in_w = IO.pipe
|
|
301
|
+
out_r, out_w = IO.pipe
|
|
302
|
+
pid = Process.spawn(
|
|
303
|
+
env,
|
|
304
|
+
RbConfig.ruby,
|
|
305
|
+
"-I#{File.expand_path("../../", __dir__)}",
|
|
306
|
+
"-rjson",
|
|
307
|
+
"-e",
|
|
308
|
+
ZYGOTE_PROGRAM,
|
|
309
|
+
boot,
|
|
310
|
+
in: in_r, out: out_w, unsetenv_others: true, pgroup: true
|
|
311
|
+
)
|
|
312
|
+
Process.detach(pid)
|
|
313
|
+
in_r.close
|
|
314
|
+
out_w.close
|
|
315
|
+
in_w.sync = true
|
|
316
|
+
|
|
317
|
+
worker = Worker.new(pid, in_w, out_r, monotonic, generation, Process.pid, tmproot)
|
|
318
|
+
ready = read_line(worker, SPAWN_TIMEOUT)
|
|
319
|
+
raise CommandError.new("sandbox zygote failed to boot", command: ["zygote"]) unless ready && JSON.parse(ready)["ready"]
|
|
320
|
+
|
|
321
|
+
worker
|
|
322
|
+
rescue StandardError
|
|
323
|
+
if worker
|
|
324
|
+
close_worker(worker, kill: true)
|
|
325
|
+
else
|
|
326
|
+
in_w&.close rescue nil
|
|
327
|
+
out_r&.close rescue nil
|
|
328
|
+
FileUtils.remove_entry(tmproot) if tmproot && File.directory?(tmproot)
|
|
329
|
+
end
|
|
330
|
+
raise
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def read_reply(worker)
|
|
334
|
+
line = read_line(worker, Runner::DEFAULT_TIMEOUT + RESPONSE_GRACE)
|
|
335
|
+
raise WorkerBroken.new("sandbox zygote died mid-operation", command: ["zygote"]) if line.nil?
|
|
336
|
+
|
|
337
|
+
reply = JSON.parse(line, symbolize_names: true)
|
|
338
|
+
unless reply[:ok]
|
|
339
|
+
# The worker ran the operation and reported its failure: it is healthy
|
|
340
|
+
# and reusable, so this is a plain CommandError, not WorkerBroken.
|
|
341
|
+
raise CommandError.new(
|
|
342
|
+
"sandboxed operation failed: #{reply[:error].to_s[0, 2000]}",
|
|
343
|
+
command: ["zygote"],
|
|
344
|
+
status: reply[:status],
|
|
345
|
+
stderr: reply[:stderr].to_s
|
|
346
|
+
)
|
|
347
|
+
end
|
|
348
|
+
Sandbox.decode_payload(JSON.parse(reply.fetch(:body), symbolize_names: true))
|
|
349
|
+
rescue JSON::ParserError => e
|
|
350
|
+
kill_worker(worker)
|
|
351
|
+
raise WorkerBroken.new("sandbox zygote protocol error: #{e.message}", command: ["zygote"])
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Blocking line read with a deadline. Every channel-level failure — overrun,
|
|
355
|
+
# oversize, or the stdout being closed under us by a concurrent
|
|
356
|
+
# reconfigure — raises WorkerBroken so the caller discards the worker rather
|
|
357
|
+
# than returning a dead pipe to the pool.
|
|
358
|
+
def read_line(worker, timeout)
|
|
359
|
+
deadline = monotonic + timeout
|
|
360
|
+
buffer = +""
|
|
361
|
+
loop do
|
|
362
|
+
remaining = deadline - monotonic
|
|
363
|
+
if remaining <= 0
|
|
364
|
+
kill_worker(worker)
|
|
365
|
+
raise WorkerBroken.new("sandboxed operation timed out", command: ["zygote"])
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
chunk =
|
|
369
|
+
begin
|
|
370
|
+
next unless IO.select([worker.stdout], nil, nil, remaining)
|
|
371
|
+
|
|
372
|
+
worker.stdout.read_nonblock(65_536, exception: false)
|
|
373
|
+
rescue IOError
|
|
374
|
+
raise WorkerBroken.new("sandbox zygote channel closed", command: ["zygote"])
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
case chunk
|
|
378
|
+
when :wait_readable then next
|
|
379
|
+
when nil
|
|
380
|
+
return buffer.empty? ? nil : buffer
|
|
381
|
+
else
|
|
382
|
+
buffer << chunk
|
|
383
|
+
return buffer if buffer.end_with?("\n")
|
|
384
|
+
# 2x: the reply line wraps a body the zygote already caps at
|
|
385
|
+
# MAX_RESPONSE_BYTES, plus JSON escaping overhead.
|
|
386
|
+
if buffer.bytesize > MAX_RESPONSE_BYTES * 2
|
|
387
|
+
kill_worker(worker)
|
|
388
|
+
raise WorkerBroken.new("sandbox zygote response exceeded #{MAX_RESPONSE_BYTES} bytes", command: ["zygote"])
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def alive?(pid)
|
|
395
|
+
Process.kill(0, pid)
|
|
396
|
+
true
|
|
397
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
398
|
+
false
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def kill_worker(worker)
|
|
402
|
+
return unless worker&.pid
|
|
403
|
+
|
|
404
|
+
begin
|
|
405
|
+
# Kills the zygote's process group. An idle zygote (no in-flight
|
|
406
|
+
# operation) has no other group members, so this reaps it cleanly. A
|
|
407
|
+
# zygote killed mid-operation does NOT take its active operation child
|
|
408
|
+
# with it this way — that child setpgrp'd into its own group — so the
|
|
409
|
+
# child carries PR_SET_PDEATHSIG=SIGKILL to die when the zygote dies.
|
|
410
|
+
Process.kill("KILL", -worker.pid)
|
|
411
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
412
|
+
nil
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def close_worker(worker, kill:)
|
|
417
|
+
kill_worker(worker) if kill
|
|
418
|
+
worker.stdin&.close rescue nil
|
|
419
|
+
worker.stdout&.close rescue nil
|
|
420
|
+
# Only our own workers' tmp roots are ours to remove; an inherited
|
|
421
|
+
# (kill: false) worker's belongs to the parent. A gracefully-exited
|
|
422
|
+
# worker has already removed its own via at_exit; this catches the
|
|
423
|
+
# SIGKILLed-mid-operation case.
|
|
424
|
+
FileUtils.remove_entry(worker.tmproot) if kill && worker.tmproot && File.directory?(worker.tmproot)
|
|
425
|
+
rescue StandardError
|
|
426
|
+
nil
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# The resident process. Boots the gem once, then serves requests from
|
|
430
|
+
# stdin: one fork per request, sandboxed in the fork, one JSON reply line
|
|
431
|
+
# per request on stdout. Exits when idle or when the parent closes stdin.
|
|
432
|
+
ZYGOTE_PROGRAM = <<~'RUBY'
|
|
433
|
+
require "safe_image"
|
|
434
|
+
require "landlock"
|
|
435
|
+
require "tmpdir"
|
|
436
|
+
require "fileutils"
|
|
437
|
+
|
|
438
|
+
def deep_symbolize(value)
|
|
439
|
+
case value
|
|
440
|
+
when Hash
|
|
441
|
+
return value[:__sym__].to_sym if value.size == 1 && value[:__sym__].is_a?(String)
|
|
442
|
+
value.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
|
|
443
|
+
when Array
|
|
444
|
+
value.map { |v| deep_symbolize(v) }
|
|
445
|
+
else
|
|
446
|
+
value
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
ALLOWED_OPERATIONS = %w[
|
|
451
|
+
probe thumbnail type size dimensions info orientation dominant_color optimize resize crop downsize convert convert_to_jpeg fix_orientation
|
|
452
|
+
convert_favicon_to_png frame_count animated? letter_avatar optimize_image! sanitize_svg!
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
boot = JSON.parse(ARGV.fetch(0), symbolize_names: true)
|
|
456
|
+
config = boot.fetch(:config)
|
|
457
|
+
SafeImage.configure!(backend: config.fetch(:backend).to_sym, landlock: false, max_pixels: config.fetch(:max_pixels))
|
|
458
|
+
|
|
459
|
+
idle = boot.fetch(:idle_seconds)
|
|
460
|
+
rlimits = boot.fetch(:rlimits)
|
|
461
|
+
execute_paths = boot.fetch(:execute)
|
|
462
|
+
max_bytes = boot.fetch(:max_response_bytes)
|
|
463
|
+
tmproot = boot.fetch(:tmproot)
|
|
464
|
+
read_defaults = Landlock::SafeExec.default_read_paths +
|
|
465
|
+
SafeImage::Sandbox.runtime_read_paths
|
|
466
|
+
|
|
467
|
+
# Runs on graceful exit (idle timeout / parent stdin EOF) but not in the
|
|
468
|
+
# op child, which leaves via exit! — so only the long-lived zygote cleans
|
|
469
|
+
# its tmp root, and the parent covers the SIGKill case.
|
|
470
|
+
at_exit { FileUtils.remove_entry(tmproot) if File.directory?(tmproot) rescue nil }
|
|
471
|
+
|
|
472
|
+
zygote_pid = Process.pid
|
|
473
|
+
|
|
474
|
+
# libc prctl(2) for PR_SET_PDEATHSIG, so an operation child dies with the
|
|
475
|
+
# zygote even after it setpgrp's out of the zygote's process group (where
|
|
476
|
+
# a parent-side group-kill can no longer reach it). nil if unavailable;
|
|
477
|
+
# the CPU rlimit remains a backstop either way.
|
|
478
|
+
prctl =
|
|
479
|
+
begin
|
|
480
|
+
require "fiddle"
|
|
481
|
+
Fiddle::Function.new(
|
|
482
|
+
Fiddle::Handle::DEFAULT["prctl"],
|
|
483
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG],
|
|
484
|
+
Fiddle::TYPE_INT
|
|
485
|
+
)
|
|
486
|
+
rescue StandardError
|
|
487
|
+
nil
|
|
488
|
+
end
|
|
489
|
+
pr_set_pdeathsig = 1
|
|
490
|
+
sigkill = 9
|
|
491
|
+
|
|
492
|
+
$stdout.sync = true
|
|
493
|
+
$stdout.puts(JSON.dump(ready: true, pid: Process.pid))
|
|
494
|
+
|
|
495
|
+
loop do
|
|
496
|
+
exit 0 unless IO.select([$stdin], nil, nil, idle)
|
|
497
|
+
line = $stdin.gets
|
|
498
|
+
exit 0 if line.nil?
|
|
499
|
+
|
|
500
|
+
req = JSON.parse(line, symbolize_names: true)
|
|
501
|
+
operation = req.fetch(:operation)
|
|
502
|
+
raise ArgumentError, "unsupported sandbox operation: #{operation}" unless ALLOWED_OPERATIONS.include?(operation)
|
|
503
|
+
|
|
504
|
+
tmpdir = Dir.mktmpdir("op-", tmproot)
|
|
505
|
+
out_r, out_w = IO.pipe
|
|
506
|
+
err_r, err_w = IO.pipe
|
|
507
|
+
|
|
508
|
+
pid = fork do
|
|
509
|
+
out_r.close
|
|
510
|
+
err_r.close
|
|
511
|
+
$stdin.reopen(File::NULL)
|
|
512
|
+
$stdout.reopen(err_w)
|
|
513
|
+
$stderr.reopen(err_w)
|
|
514
|
+
Process.setpgrp # own group, so the zygote's per-op timeout kill (-pid) reaps tools too
|
|
515
|
+
|
|
516
|
+
# Die if the zygote dies: once we setpgrp out of its group a
|
|
517
|
+
# parent-side group-kill can no longer reach us, so request a SIGKILL
|
|
518
|
+
# on the zygote's death. PR_SET_PDEATHSIG only fires on a *future*
|
|
519
|
+
# parent death, so re-check the zygote is still our parent to close
|
|
520
|
+
# the fork→prctl race where it died in between.
|
|
521
|
+
prctl&.call(pr_set_pdeathsig, sigkill, 0, 0, 0)
|
|
522
|
+
exit!(1) unless Process.ppid == zygote_pid
|
|
523
|
+
|
|
524
|
+
ENV["TMPDIR"] = tmpdir
|
|
525
|
+
ENV["HOME"] = tmpdir
|
|
526
|
+
ENV["XDG_CACHE_HOME"] = tmpdir
|
|
527
|
+
ENV["MAGICK_TEMPORARY_PATH"] = tmpdir
|
|
528
|
+
|
|
529
|
+
Process.setrlimit(:CPU, rlimits.fetch(:cpu_seconds))
|
|
530
|
+
Process.setrlimit(:AS, rlimits.fetch(:memory_bytes))
|
|
531
|
+
Process.setrlimit(:FSIZE, rlimits.fetch(:file_size_bytes))
|
|
532
|
+
Process.setrlimit(:NOFILE, rlimits.fetch(:open_files))
|
|
533
|
+
|
|
534
|
+
abi = Landlock.abi_version
|
|
535
|
+
# Port 1 is never used: handling the TCP rights with an unmatchable
|
|
536
|
+
# rule denies all TCP connect/bind.
|
|
537
|
+
net = abi >= 4 ? { connect_tcp: [1], bind_tcp: [1] } : {}
|
|
538
|
+
scope = abi >= 6 ? %i[abstract_unix_socket signal] : []
|
|
539
|
+
existing = ->(paths) { paths.compact.map(&:to_s).reject(&:empty?).select { |p| File.exist?(p) }.uniq }
|
|
540
|
+
Landlock.restrict!(
|
|
541
|
+
read: existing.call(read_defaults + req.dig(:paths, :read) + [tmpdir]),
|
|
542
|
+
write: existing.call(req.dig(:paths, :write) + [tmpdir]),
|
|
543
|
+
execute: existing.call(execute_paths),
|
|
544
|
+
scope: scope,
|
|
545
|
+
**net
|
|
546
|
+
)
|
|
547
|
+
# landlock >= the version that ships it: the helper's deny-all
|
|
548
|
+
# seccomp filter, self-applied — closes the UDP gap the in-process
|
|
549
|
+
# Landlock policy alone leaves open.
|
|
550
|
+
Landlock.seccomp_deny_network! if Landlock.respond_to?(:seccomp_deny_network!)
|
|
551
|
+
|
|
552
|
+
request = deep_symbolize(req.fetch(:request))
|
|
553
|
+
result = SafeImage.__send__(operation, *(request[:args] || []), **(request[:kwargs] || {}))
|
|
554
|
+
|
|
555
|
+
body =
|
|
556
|
+
if defined?(SafeImage::Result) && result.is_a?(SafeImage::Result)
|
|
557
|
+
{ __type: "Result", data: result.to_h }
|
|
558
|
+
elsif defined?(SafeImage::Info) && result.is_a?(SafeImage::Info)
|
|
559
|
+
{ __type: "Info", data: result.to_h }
|
|
560
|
+
else
|
|
561
|
+
{ __type: "Value", data: result }
|
|
562
|
+
end
|
|
563
|
+
out_w.write(JSON.dump(body))
|
|
564
|
+
out_w.close
|
|
565
|
+
exit!(0)
|
|
566
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- the fork must never escape into the zygote loop
|
|
567
|
+
err_w.write("#{e.class}: #{e.message}") rescue nil
|
|
568
|
+
exit!(1)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
out_w.close
|
|
572
|
+
err_w.close
|
|
573
|
+
|
|
574
|
+
body = +""
|
|
575
|
+
stderr = +""
|
|
576
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + req.fetch(:timeout)
|
|
577
|
+
timed_out = false
|
|
578
|
+
readers = [out_r, err_r]
|
|
579
|
+
until readers.empty?
|
|
580
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
581
|
+
if remaining <= 0
|
|
582
|
+
timed_out = true
|
|
583
|
+
Process.kill("KILL", -pid) rescue nil
|
|
584
|
+
break
|
|
585
|
+
end
|
|
586
|
+
ready = IO.select(readers, nil, nil, remaining) or next
|
|
587
|
+
ready[0].each do |io|
|
|
588
|
+
chunk = io.read_nonblock(65_536, exception: false)
|
|
589
|
+
if chunk.nil?
|
|
590
|
+
readers.delete(io)
|
|
591
|
+
elsif chunk != :wait_readable
|
|
592
|
+
(io == out_r ? body : stderr) << chunk
|
|
593
|
+
if body.bytesize + stderr.bytesize > max_bytes
|
|
594
|
+
timed_out = false
|
|
595
|
+
Process.kill("KILL", -pid) rescue nil
|
|
596
|
+
readers.clear
|
|
597
|
+
stderr = "operation output exceeded #{max_bytes} bytes"
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
_, status = Process.waitpid2(pid)
|
|
603
|
+
out_r.close
|
|
604
|
+
err_r.close
|
|
605
|
+
FileUtils.remove_entry(tmpdir) rescue nil
|
|
606
|
+
|
|
607
|
+
if timed_out
|
|
608
|
+
$stdout.puts(JSON.dump(ok: false, error: "operation timed out", stderr: stderr[0, 8192], status: nil))
|
|
609
|
+
elsif status.success? && !body.empty?
|
|
610
|
+
$stdout.puts(JSON.dump(ok: true, body: body))
|
|
611
|
+
else
|
|
612
|
+
detail = stderr.strip
|
|
613
|
+
detail = "exit status #{status.exitstatus.inspect}" if detail.empty?
|
|
614
|
+
$stdout.puts(JSON.dump(ok: false, error: detail[0, 8192], stderr: stderr[0, 8192], status: status.exitstatus))
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
RUBY
|
|
618
|
+
end
|
|
619
|
+
end
|
data/lib/safe_image.rb
CHANGED
|
@@ -35,9 +35,11 @@ require_relative "safe_image/native"
|
|
|
35
35
|
require_relative "safe_image/result"
|
|
36
36
|
require_relative "safe_image/runner"
|
|
37
37
|
require_relative "safe_image/sandbox"
|
|
38
|
+
require_relative "safe_image/zygote"
|
|
38
39
|
require_relative "safe_image/path_safety"
|
|
39
40
|
require_relative "safe_image/optimizer"
|
|
40
41
|
require_relative "safe_image/svg_metadata"
|
|
42
|
+
require_relative "safe_image/svg_css"
|
|
41
43
|
require_relative "safe_image/svg_sanitizer"
|
|
42
44
|
require_relative "safe_image/remote"
|
|
43
45
|
require_relative "safe_image/ico"
|
|
@@ -86,6 +88,10 @@ module SafeImage
|
|
|
86
88
|
raise Error, "landlock: true requested but the Landlock sandbox is unavailable on this host"
|
|
87
89
|
end
|
|
88
90
|
|
|
91
|
+
# The zygote bakes the backend and max_pixels in at boot; a reconfigure
|
|
92
|
+
# must not serve from a stale one.
|
|
93
|
+
Zygote.shutdown!
|
|
94
|
+
|
|
89
95
|
@config = Config.new(backend: backend, landlock: landlock, max_pixels: max_pixels)
|
|
90
96
|
end
|
|
91
97
|
|
|
@@ -374,6 +380,12 @@ module SafeImage
|
|
|
374
380
|
end
|
|
375
381
|
|
|
376
382
|
def sanitize_svg!(*args, **kwargs)
|
|
383
|
+
# Validate the required id_namespace in the parent (after the configured
|
|
384
|
+
# check) so omitting/malformed values raise ArgumentError consistently —
|
|
385
|
+
# otherwise, under the sandbox, the worker raises and it surfaces as a
|
|
386
|
+
# sandbox CommandError instead of the documented ArgumentError.
|
|
387
|
+
config
|
|
388
|
+
SvgSanitizer.resolve_namespace(kwargs.fetch(:id_namespace, SvgSanitizer::NAMESPACE_REQUIRED))
|
|
377
389
|
maybe_sandbox(:sanitize_svg!, args: args, kwargs: kwargs) { SvgSanitizer.sanitize!(*args, **kwargs) }
|
|
378
390
|
end
|
|
379
391
|
end
|