safe_image 0.1.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.
data/README.md ADDED
@@ -0,0 +1,703 @@
1
+ # Safe Image
2
+
3
+ Safe Image is a small Ruby image-processing boundary for untrusted uploads.
4
+
5
+ It gives an application one narrow API for probing, thumbnailing, resizing,
6
+ cropping, converting, optimising, SVG sanitising, animation checks, favicon
7
+ conversion, and letter-avatar generation. The default fast path uses a tiny
8
+ native extension that calls `libvips` directly. Compatibility paths use
9
+ ImageMagick, but with shell-free command execution and a restrictive bundled
10
+ policy.
11
+
12
+ Safe Image started as a Discourse extraction: the public surface intentionally
13
+ covers the image operations Discourse performs today. The useful part is more
14
+ general: hostile image bytes are a lousy thing to spread across model callbacks,
15
+ upload helpers, optimizer wrappers, and hand-built command strings.
16
+
17
+ ## Security model
18
+
19
+ Safe Image is not magic pixie dust. It is a deliberately small choke point.
20
+
21
+ What it does:
22
+
23
+ - uses explicit argv arrays for external commands, never shell strings
24
+ - starts external commands with an allowlisted environment, private temp/home/cache
25
+ directories, bounded stdout/stderr, and process-group timeout cleanup
26
+ - uses explicit libvips loaders selected from allowlisted extensions
27
+ - enables libvips' untrusted-operation block in-process
28
+ - blocks libvips ImageMagick loader classes in the native extension
29
+ - disables libvips cache by default in-process
30
+ - strips metadata on generated images where applicable
31
+ - rejects symlinked local input/output paths and symlinked path components for
32
+ untrusted file-processing paths
33
+ - caps decoded pixels before expensive work: the libvips path enforces a
34
+ default `SafeImage::DEFAULT_MAX_PIXELS` (128MP) ceiling even when no
35
+ `max_pixels` is given, and callers can raise or lower it per call
36
+ - ships a restrictive ImageMagick `policy.xml`
37
+ - denies Ghostscript-backed formats and dangerous ImageMagick features:
38
+ - `PS`, `PS2`, `PS3`, `EPS`, `EPSF`, `PDF`, `XPS`, `PCL`
39
+ - `MSL`, `MVG`
40
+ - `HTTP`, `HTTPS`, `URL`
41
+ - delegates, filters, and `@file` indirection
42
+ - supports optional Landlock subprocess sandboxing on Linux
43
+
44
+ The ImageMagick backend is explicit. Safe Image will not silently fall from the
45
+ libvips path into generic ImageMagick decoding.
46
+
47
+ ## Install
48
+
49
+ System dependency:
50
+
51
+ - `libvips` headers and library, discoverable through `pkg-config vips`
52
+
53
+ Optional command dependencies:
54
+
55
+ - `magick` / `convert` and `identify` for ImageMagick compatibility operations
56
+ - `jpegoptim` for JPEG optimisation
57
+ - `oxipng` for PNG lossless optimisation
58
+ - `pngquant` for optional lossy PNG optimisation
59
+ - `cjpegli` for optional Jpegli JPEG encoding of generated JPEGs
60
+
61
+ Ruby runtime dependency:
62
+
63
+ - `rexml` for SVG sanitising
64
+
65
+ Optional Ruby dependency:
66
+
67
+ - `landlock` for Linux subprocess sandboxing
68
+
69
+ `landlock` is intentionally **not** a gem dependency. Install it in the host
70
+ application if you want sandboxing.
71
+
72
+ `cjpegli` is also intentionally optional. On Arch it is provided by `libjxl`;
73
+ on macOS it is commonly installed via `brew install jpeg-xl`; Debian/Ubuntu
74
+ package names vary by release (`libjpegli-tools` where available). Safe Image
75
+ detects it at runtime and falls back unless the caller explicitly requests
76
+ `encoder: :cjpegli`.
77
+
78
+ ```bash
79
+ gem build safe_image.gemspec
80
+ gem install ./safe_image-0.1.0.gem
81
+ ```
82
+
83
+ ```ruby
84
+ require "safe_image"
85
+
86
+ result = SafeImage.thumbnail(
87
+ input: "upload.jpg",
88
+ output: "thumb.jpg",
89
+ width: 600,
90
+ height: 400,
91
+ max_pixels: 40_000_000
92
+ )
93
+
94
+ puts "#{result.backend}: #{result.width}x#{result.height} #{result.filesize} bytes"
95
+
96
+ SafeImage.convert(
97
+ "upload.png",
98
+ "upload.jpg",
99
+ format: "jpg",
100
+ quality: 85,
101
+ max_pixels: 40_000_000
102
+ )
103
+ ```
104
+
105
+ ## Return values
106
+
107
+ Image-producing operations return `SafeImage::Result`:
108
+
109
+ ```ruby
110
+ SafeImage::Result[
111
+ input:, # source path or "generated"
112
+ output:, # output path, nil for probe
113
+ input_format:, # "jpg", "png", "webp", "heic", "avif", etc.
114
+ output_format:, # output format, nil for probe
115
+ width:,
116
+ height:,
117
+ filesize:,
118
+ backend:, # e.g. "libvips-direct", "imagemagick", "cjpegli",
119
+ # "libvips-direct+cjpegli", or "sandboxed-..."
120
+ duration_ms:,
121
+ optimizer: # optimizer tool list for thumbnail path, otherwise nil
122
+ ]
123
+ ```
124
+
125
+ Optimizer operations return a hash:
126
+
127
+ ```ruby
128
+ {
129
+ format: "jpg",
130
+ before_bytes: 123_456,
131
+ after_bytes: 120_000,
132
+ saved_bytes: 3_456,
133
+ tools: ["jpegoptim"]
134
+ }
135
+ ```
136
+
137
+ ## Core API
138
+
139
+ ### `SafeImage.probe(path, max_pixels: nil)`
140
+
141
+ Reads image metadata through the direct libvips backend.
142
+
143
+ Supported inputs:
144
+
145
+ - `jpg` / `jpeg`
146
+ - `png`
147
+ - `webp`
148
+ - `heic` / `heif`
149
+ - `avif`
150
+
151
+ ```ruby
152
+ info = SafeImage.probe("upload.jpg", max_pixels: 40_000_000)
153
+ puts "#{info.width}x#{info.height} #{info.input_format}"
154
+ ```
155
+
156
+ Raises `SafeImage::LimitError` if `width * height > max_pixels`.
157
+
158
+ ### `SafeImage.thumbnail(...)`
159
+
160
+ Creates a center-cropped thumbnail.
161
+
162
+ ```ruby
163
+ result = SafeImage.thumbnail(
164
+ input: "upload.jpg",
165
+ output: "thumb.jpg",
166
+ width: 600,
167
+ height: 400,
168
+ format: nil, # inferred from output extension when nil
169
+ quality: 85,
170
+ max_pixels: 40_000_000,
171
+ backend: :vips, # :vips or :imagemagick
172
+ optimize: true,
173
+ optimize_mode: :lossless, # :lossless or :lossy for PNG optimisation
174
+ execution: :inline, # :inline, :sandbox, :sandbox_if_available
175
+ encoder: :auto, # :auto, :cjpegli, :vips, :imagemagick for JPEG output
176
+ chroma_subsampling: :auto # :auto, "420", "422", "444"
177
+ )
178
+ ```
179
+
180
+ Supported outputs for the direct libvips backend:
181
+
182
+ - `jpg` / `jpeg`
183
+ - `png`
184
+ - `webp`
185
+ - `avif`
186
+
187
+ `execution: :sandbox` is fail-closed: it raises if Landlock is unavailable.
188
+ `execution: :sandbox_if_available` uses the sandbox only when available.
189
+
190
+ ## JPEG encoder selection
191
+
192
+ Safe Image separates **encoding generated JPEGs** from **optimising existing
193
+ JPEGs**. This avoids hiding a lossy re-encode behind a method named `optimize`.
194
+
195
+ | Operation | `encoder: :auto` behavior |
196
+ | --- | --- |
197
+ | `thumbnail` / `resize` / `crop` / `downsize` to JPEG with `backend: :vips` | use `cjpegli` when installed; otherwise use normal libvips JPEG output |
198
+ | `convert("input.png", "output.jpg", format: "jpg")` | use `cjpegli` when installed; otherwise use ImageMagick compatibility path |
199
+ | `convert` from HEIC/WebP/AVIF/GIF/JPEG to JPEG | fall back to ImageMagick compatibility path; `cjpegli` is not treated as a universal decoder |
200
+ | `optimize("existing.jpg")` | use `jpegoptim`; never use `cjpegli` by default |
201
+
202
+ Encoder controls:
203
+
204
+ | Option | Meaning |
205
+ | --- | --- |
206
+ | `encoder: :auto` | best available default with safe fallback |
207
+ | `encoder: :cjpegli` | require Jpegli and fail closed if unavailable/unsupported |
208
+ | `encoder: :vips` | force normal libvips JPEG output where available |
209
+ | `encoder: :imagemagick` | force ImageMagick compatibility output |
210
+
211
+ `cjpegli` output is ordinary browser-compatible JPEG. It is optional because it
212
+ is a system binary, not a Ruby dependency. Safe Image detects it at runtime.
213
+
214
+ `chroma_subsampling: :auto` uses `4:4:4` for PNG-sourced JPEG conversion and
215
+ `4:2:0` otherwise. Pass `"420"`, `"422"`, or `"444"` to force a value.
216
+
217
+ ## Local metadata helpers
218
+
219
+ These helpers are intended to cover the local-file parts of APIs like
220
+ `FastImage.type`, `FastImage.size`, and `FastImage#orientation` without adding a
221
+ Ruby dependency. They do not fetch remote URLs.
222
+
223
+ ### `SafeImage.type(path, max_pixels: nil)`
224
+
225
+ Returns a FastImage-style symbol for a local file:
226
+
227
+ ```ruby
228
+ SafeImage.type("upload.jpg") # => :jpeg
229
+ SafeImage.type("upload.png") # => :png
230
+ SafeImage.type("icon.svg") # => :svg
231
+ ```
232
+
233
+ JPEG is returned as `:jpeg`, not `:jpg`, to match common Ruby image-probing
234
+ conventions.
235
+
236
+ ### `SafeImage.size(path, max_pixels: nil)` / `SafeImage.dimensions(path, max_pixels: nil)`
237
+
238
+ Returns `[width, height]` for a local file:
239
+
240
+ ```ruby
241
+ SafeImage.size("upload.jpg") # => [1600, 1200]
242
+ SafeImage.dimensions("upload.png") # => [800, 600]
243
+ SafeImage.size("icon.svg") # => [120, 80]
244
+ ```
245
+
246
+ SVG metadata is handled by a dedicated parser, not ImageMagick or libvips. It is
247
+ limited to local `.svg` files, caps input size/tree depth/element/attribute
248
+ counts, rejects `DOCTYPE` and non-XML processing instructions, requires an `<svg>`
249
+ root, and derives dimensions from numeric `width`/`height` or `viewBox`.
250
+
251
+ ### `SafeImage.orientation(path, max_pixels: nil)`
252
+
253
+ Returns the EXIF orientation integer for a local file, defaulting to `1` when no
254
+ orientation is present or when ImageMagick cannot report it.
255
+
256
+ ```ruby
257
+ SafeImage.orientation("upload.jpg") # => 1
258
+ ```
259
+
260
+ ### `SafeImage.info(path, max_pixels: nil, animated: false, orientation: false)`
261
+
262
+ Returns a `SafeImage::Info` object for a local file:
263
+
264
+ ```ruby
265
+ info = SafeImage.info("upload.jpg", animated: true, orientation: true)
266
+ info.type # => :jpeg
267
+ info.width # => 1600
268
+ info.height # => 1200
269
+ info.size # => [1600, 1200]
270
+ info.animated # => false
271
+ info.orientation # => 1
272
+ ```
273
+
274
+ `animated:` and `orientation:` default to `false` because they may require extra
275
+ ImageMagick work. When disabled, those fields are `nil`.
276
+
277
+ ## Remote metadata helpers
278
+
279
+ These helpers are intended to cover `FastImage.size(url)` / `FastImage.type(url)`
280
+ style use cases without another Ruby dependency. They use only Ruby stdlib
281
+ `Net::HTTP`, download to a tempfile with a byte cap, then run the normal Safe
282
+ Image local metadata path on that tempfile.
283
+
284
+ Remote fetching is deliberately conservative:
285
+
286
+ - only `http` and `https` URLs are accepted
287
+ - redirects are capped
288
+ - open/read timeouts and an overall `total_timeout` are capped
289
+ - response size is capped by `max_bytes`
290
+ - public remote fetches are limited to ports 80 and 443 by default
291
+ - `Net::HTTP` environment proxies are disabled; proxy environment variables cannot
292
+ route around IP validation
293
+ - DNS answers are checked against a special-use address blocklist and the
294
+ connection is pinned to a vetted IP address to avoid DNS-rebinding/TOCTOU
295
+ bypasses
296
+ - HTTPS-to-HTTP redirects are rejected
297
+ - same-origin redirects keep caller headers; cross-origin redirects use a small
298
+ header allowlist (`Accept`, `Accept-Encoding`, `User-Agent`) rather than a
299
+ blacklist
300
+ - initial caller-supplied request headers use the same small allowlist; cookies,
301
+ authorization headers, and custom auth headers are not forwarded by default
302
+ - hop-by-hop/proxy/`Host` request headers are rejected before any request
303
+ - private, loopback, link-local, multicast, documentation, benchmarking,
304
+ carrier-grade NAT, IPv4-mapped IPv6, NAT64, 6to4/Teredo, and other
305
+ special-use resolved addresses are rejected by default
306
+ - no image decoding happens directly from the socket
307
+ - the final response `Content-Type` must be an allowed image type and must agree
308
+ with an image-looking URL extension when one is present
309
+ - downloaded content is probed before `fetch_remote` yields the tempfile, so the
310
+ raw downloader cannot be used as a blind extension-based file saver
311
+ - SVG remote metadata uses the same bounded SVG metadata parser after download;
312
+ SVG is not handed to ImageMagick for probing
313
+
314
+ Set `allow_private: true` only when the caller has already made an SSRF decision
315
+ or is intentionally probing a trusted internal URL. Passing `allow_private: true`
316
+ also permits non-standard ports; for public fetches, pass `allowed_ports:` if you
317
+ really need to allow a different port.
318
+
319
+ ### `SafeImage.remote_size(url, ...)` / `SafeImage.remote_dimensions(url, ...)`
320
+
321
+ ```ruby
322
+ SafeImage.remote_size(
323
+ "https://example.com/image.jpg",
324
+ max_bytes: 10.megabytes,
325
+ total_timeout: 30,
326
+ max_pixels: 40_000_000
327
+ )
328
+ # => [1600, 1200]
329
+ ```
330
+
331
+ ### `SafeImage.remote_type(url, ...)`
332
+
333
+ ```ruby
334
+ SafeImage.remote_type("https://example.com/image.png", max_bytes: 10.megabytes)
335
+ # => :png
336
+ ```
337
+
338
+ ### `SafeImage.remote_info(url, ...)`
339
+
340
+ ```ruby
341
+ info = SafeImage.remote_info(
342
+ "https://example.com/image.gif",
343
+ max_bytes: 10.megabytes,
344
+ animated: true
345
+ )
346
+ info.type # => :gif
347
+ info.size # => [640, 480]
348
+ info.animated # => true
349
+ ```
350
+
351
+ ### `SafeImage.remote_animated?(url, ...)`
352
+
353
+ ```ruby
354
+ SafeImage.remote_animated?("https://example.com/image.webp", max_bytes: 10.megabytes)
355
+ # => true / false
356
+ ```
357
+
358
+ ### `SafeImage.fetch_remote(url, ...) { |path| ... }`
359
+
360
+ Downloads a remote image to a tempfile and yields the local path:
361
+
362
+ ```ruby
363
+ SafeImage.fetch_remote("https://example.com/image.jpg", max_bytes: 10.megabytes) do |path|
364
+ SafeImage.probe(path)
365
+ end
366
+ ```
367
+
368
+ When global Landlock is enabled, the network fetch itself is not put inside the
369
+ Landlock worker because the worker denies network access. The downloaded tempfile
370
+ is then passed through the normal Safe Image local image APIs, so decoding still
371
+ uses the same sandboxed image-processing path.
372
+
373
+ ## Compatibility API
374
+
375
+ These methods are shaped around the image operations Discourse currently
376
+ performs. They are useful outside Discourse too, but the names are deliberately
377
+ boring because they map to common upload-pipeline tasks.
378
+
379
+ ### `SafeImage.resize(from, to, width, height, quality: nil, backend: :imagemagick, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)`
380
+
381
+ Creates a resized thumbnail-style output.
382
+
383
+ ```ruby
384
+ SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400)
385
+ SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400, backend: :vips, quality: 85)
386
+ ```
387
+
388
+ Backends:
389
+
390
+ - `:imagemagick` default compatibility path
391
+ - `:vips` direct libvips path
392
+
393
+ ### `SafeImage.crop(from, to, width, height, quality: nil, backend: :imagemagick, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)`
394
+
395
+ Creates a north-cropped image. This matches the avatar/optimized-image crop
396
+ shape used by Discourse.
397
+
398
+ ```ruby
399
+ SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240)
400
+ SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240, backend: :vips)
401
+ ```
402
+
403
+ ### `SafeImage.downsize(from, to, dimensions, backend: :imagemagick, optimize: true, max_pixels: nil, quality: 85, encoder: :auto, chroma_subsampling: :auto)`
404
+
405
+ Downsizes an image using ImageMagick-style geometry strings.
406
+
407
+ ```ruby
408
+ SafeImage.downsize("large.png", "small.png", "50%")
409
+ SafeImage.downsize("large.png", "small.png", "100x100>", backend: :vips)
410
+ SafeImage.downsize("large.png", "small.png", "400000@", backend: :vips)
411
+ ```
412
+
413
+ The direct vips backend supports the geometry forms covered by the test suite:
414
+ percentage, bounding box with `>`, and pixel-area cap with `@`.
415
+
416
+ ### `SafeImage.convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)`
417
+
418
+ Converts an input image to an explicit output `format:`. Unsupported formats
419
+ raise `SafeImage::UnsupportedFormatError`.
420
+
421
+ For JPEG output, `encoder: :auto` uses `cjpegli` when it is installed and the
422
+ input can be encoded directly by Jpegli. Today that direct path is intentionally
423
+ limited to PNG input; other formats fall back to the hardened ImageMagick
424
+ compatibility backend. Use `encoder: :cjpegli` to require Jpegli and fail closed,
425
+ or `encoder: :imagemagick` to force the compatibility path.
426
+
427
+ ```ruby
428
+ SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85)
429
+ SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85, encoder: :cjpegli)
430
+ SafeImage.convert("upload.heic", "upload.jpg", format: "jpg", quality: 85)
431
+ SafeImage.convert("upload.jpg", "upload.webp", format: "webp", quality: 85)
432
+ ```
433
+
434
+ ### `SafeImage.fix_orientation(from, to = from, max_pixels: nil)`
435
+
436
+ Applies EXIF orientation through ImageMagick. If `to` is omitted, the file is
437
+ rewritten in place.
438
+
439
+ ```ruby
440
+ SafeImage.fix_orientation("upload.jpg")
441
+ SafeImage.fix_orientation("upload.jpg", "oriented.jpg")
442
+ ```
443
+
444
+ ### `SafeImage.convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)`
445
+
446
+ Extracts the largest ICO frame and writes PNG.
447
+
448
+ ```ruby
449
+ SafeImage.convert_favicon_to_png("favicon.ico", "favicon.png")
450
+ ```
451
+
452
+ ### `SafeImage.frame_count(path, max_pixels: nil)`
453
+
454
+ Returns the frame count using the hardened ImageMagick identify path.
455
+
456
+ ```ruby
457
+ frames = SafeImage.frame_count("animated.gif")
458
+ ```
459
+
460
+ ### `SafeImage.animated?(path, max_pixels: nil)`
461
+
462
+ Returns `true` when `frame_count(path) > 1`.
463
+
464
+ ```ruby
465
+ SafeImage.animated?("animated.webp")
466
+ ```
467
+
468
+ ### `SafeImage.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "NimbusSans-Regular")`
469
+
470
+ Generates a square letter avatar PNG.
471
+
472
+ ```ruby
473
+ SafeImage.letter_avatar(
474
+ output: "avatar.png",
475
+ size: 360,
476
+ background_rgb: [1, 2, 3],
477
+ letter: "S",
478
+ font: "Adwaita-Sans"
479
+ )
480
+ ```
481
+
482
+ ### `SafeImage.optimize(path, mode: :lossless, strip_metadata: true, quality: nil, strict: true)`
483
+
484
+ Optimises an existing JPEG or PNG in place.
485
+
486
+ ```ruby
487
+ SafeImage.optimize("image.jpg", quality: 85)
488
+ SafeImage.optimize("image.png")
489
+ SafeImage.optimize("image.png", mode: :lossy, quality: "65-90")
490
+ ```
491
+
492
+ JPEG path:
493
+
494
+ - uses `jpegoptim`
495
+ - `quality:` maps to `jpegoptim --max`
496
+ - metadata is stripped unless `strip_metadata: false`
497
+
498
+ PNG path:
499
+
500
+ - uses `oxipng` for lossless optimisation
501
+ - when `mode: :lossy`, uses `pngquant` first for PNGs smaller than 500 KB,
502
+ then `oxipng`
503
+
504
+ When `strict: true`, missing optimizer tools raise. When `strict: false`, missing
505
+ optimizer tools are tolerated.
506
+
507
+ ### `SafeImage.optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)`
508
+
509
+ Compatibility wrapper around `SafeImage.optimize`.
510
+
511
+ ```ruby
512
+ SafeImage.optimize_image!("image.jpg")
513
+ SafeImage.optimize_image!("image.png", allow_lossy_png: true)
514
+ ```
515
+
516
+ ### `SafeImage.sanitize_svg!(path)`
517
+
518
+ Sanitises an SVG in place using a small REXML allowlist.
519
+
520
+ ```ruby
521
+ result = SafeImage.sanitize_svg!("icon.svg")
522
+ puts result[:sanitized]
523
+ ```
524
+
525
+ The sanitizer removes unsafe elements/attributes such as scripts and event
526
+ handlers. It is intentionally conservative rather than a full browser-grade SVG
527
+ implementation.
528
+
529
+ ## Security posture
530
+
531
+ Safe Image is a hardened boundary for untrusted image processing, not a magic
532
+ wand. The goal is to centralize risky operations, make the safe path boring, and
533
+ remove common image-processing foot-guns.
534
+
535
+ Baseline hardening:
536
+
537
+ - external commands use argv arrays, never shell strings
538
+ - command environment, temp/home/cache directories, stdout/stderr size, and
539
+ process-group timeout cleanup are controlled
540
+ - libvips loaders are selected explicitly from an allowlist
541
+ - libvips' untrusted-operation block is enabled in-process
542
+ - libvips ImageMagick loader classes are blocked in the native extension
543
+ - libvips cache is disabled by default in-process
544
+ - local untrusted input/output paths reject symlinks and symlinked path components
545
+ - generated images strip metadata where applicable
546
+ - `max_pixels` checks fail before expensive work; the libvips path applies a
547
+ default 128MP ceiling (`SafeImage::DEFAULT_MAX_PIXELS`) when none is supplied
548
+ - remote fetch uses SSRF hardening: scheme/port restrictions, special-use IP
549
+ blocking, DNS pinning, redirect limits, HTTPS-to-HTTP rejection, proxy-env
550
+ bypass prevention, request-header allowlists, content-type/extension agreement,
551
+ and probe-before-yield
552
+ - SVG metadata uses a bounded parser; SVG is not handed to ImageMagick for probing
553
+ - SVG sanitising is conservative and allowlist based; it rejects `DOCTYPE` and
554
+ XML processing instructions, removes comments and disallowed elements, converts
555
+ CDATA to escaped text, and blocks event handlers, external URLs, and
556
+ `javascript:` / `data:` URL values
557
+
558
+ SVG sanitising is defense-in-depth for stored bytes. Applications that serve
559
+ user-supplied SVGs directly should still use response-level controls such as a
560
+ restrictive `Content-Security-Policy`, `X-Content-Type-Options: nosniff`, and/or
561
+ attachment/sandbox handling for direct-open routes. Browsers restrict script
562
+ execution when an SVG is embedded as `<img>`, but a top-level SVG document is a
563
+ different sink.
564
+
565
+ ### Security posture without Landlock
566
+
567
+ Without Landlock, Safe Image still hardens the ImageMagick path substantially:
568
+
569
+ - delegates are disabled
570
+ - filters are disabled
571
+ - `@file` indirection is disabled
572
+ - remote URL coders are disabled
573
+ - Ghostscript/document/vector formats are denied
574
+ - coders are deny-by-default with a small raster allowlist
575
+ - commands are executed with argv arrays, not shell strings
576
+ - command environment and ImageMagick policy path are controlled
577
+ - ImageMagick resource limits are set in the bundled policy
578
+
579
+ That does **not** make hostile files benign. Raster decoders still parse attacker
580
+ controlled bytes: libjpeg, libpng, libwebp, libheif/HEIC/AVIF, ImageMagick's
581
+ raster decoders, and libvips loaders. If one of those decoders has a memory
582
+ corruption bug or pathological resource-consumption bug, policy alone is not a
583
+ sandbox.
584
+
585
+ So the intended posture is:
586
+
587
+ - without Landlock: hardened, centralized image processing with the major
588
+ ImageMagick delegate/pseudo-protocol foot-guns removed
589
+ - with Landlock: the same hardening plus a real containment boundary around all
590
+ public operations
591
+
592
+ Do not describe non-sandboxed operation as making hostile images safe. The honest
593
+ claim is defense-in-depth, not immunity.
594
+
595
+ ## Atomic Landlock sandboxing
596
+
597
+ Landlock support is optional, but atomic once enabled.
598
+
599
+ ```ruby
600
+ SafeImage.sandbox_available? # => true/false
601
+ SafeImage.enable_sandbox! # raises if unavailable
602
+ SafeImage.sandbox_enabled? # => true
603
+ ```
604
+
605
+ After `SafeImage.enable_sandbox!`, every public operation routes through the
606
+ sandbox worker:
607
+
608
+ - `probe`
609
+ - `type`
610
+ - `size`
611
+ - `dimensions`
612
+ - `info`
613
+ - `orientation`
614
+ - `thumbnail`
615
+ - `optimize`
616
+ - `resize`
617
+ - `crop`
618
+ - `downsize`
619
+ - `convert`
620
+ - `convert_to_jpeg` compatibility alias
621
+ - `fix_orientation`
622
+ - `convert_favicon_to_png`
623
+ - `frame_count`
624
+ - `animated?`
625
+ - `letter_avatar`
626
+ - `optimize_image!`
627
+ - `sanitize_svg!`
628
+
629
+ There is no silent fallback after global sandbox enablement. If sandbox setup or
630
+ a sandboxed command fails, the operation fails.
631
+
632
+ The sandbox grants read/write access only to the paths inferred from the
633
+ operation arguments, plus runtime/library paths and temporary directories needed
634
+ by Ruby, libvips, ImageMagick, and optimizer tools. Network syscalls are denied
635
+ through the Landlock helper's seccomp layer.
636
+
637
+ ## Discourse parity
638
+
639
+ Safe Image currently covers the image-operation surface Discourse performs in:
640
+
641
+ - optimized image generation
642
+ - upload preprocessing
643
+ - thumbnail generation
644
+ - avatar cropping / letter avatars
645
+ - favicon conversion
646
+ - HEIC/PNG-to-JPEG conversion
647
+ - orientation fixing
648
+ - animated image detection
649
+ - JPEG/PNG optimisation
650
+ - SVG sanitising
651
+
652
+ The claim is operation parity, not byte-for-byte output identity across all
653
+ ImageMagick/libvips versions. The test suite includes golden compatibility
654
+ checks, ImageMagick parity checks, policy-denial checks, and a real-image atomic
655
+ sandbox sweep over the full public operation list.
656
+
657
+ ## Development
658
+
659
+ ```bash
660
+ bundle install
661
+ bundle exec rake # compile the native extension and run the tests
662
+ bundle exec rubocop # lint
663
+ ```
664
+
665
+ The minitest suite lives in `test/*_test.rb`; individual files run standalone
666
+ (`bundle exec ruby test/operations_test.rb`). Tests that depend on optional
667
+ host support — cjpegli, HEIC delegates, the Landlock sandbox — skip with an
668
+ explanation when that support is missing.
669
+
670
+ Gem packaging uses the standard Bundler tasks (`rake build`, `rake install`).
671
+ Releases follow the Discourse gem publication flow: bump
672
+ `SafeImage::VERSION`, merge to `main`, and CI publishes the gem to
673
+ RubyGems once the test matrix is green.
674
+
675
+ ## Security reporting
676
+
677
+ Please report suspected security issues privately to `sam@discourse.org`.
678
+
679
+ See [`SECURITY.md`](SECURITY.md) for the threat model, non-goals, and reporting
680
+ checklist.
681
+
682
+ ## License
683
+
684
+ Safe Image is MIT licensed.
685
+
686
+ The gem dynamically links to system `libvips`; `libvips` is
687
+ LGPL-2.1-or-later. Safe Image does not vendor `libvips`.
688
+
689
+ Optional command-line tools are discovered at runtime and executed as external
690
+ programs; they are not bundled into the gem. Typical licenses for those optional
691
+ tools are:
692
+
693
+ | Tool | Purpose | Typical license |
694
+ | --- | --- | --- |
695
+ | ImageMagick `magick` / `convert` / `identify` | compatibility operations | ImageMagick license |
696
+ | `jpegoptim` | JPEG lossless optimisation / metadata stripping | GPL-2.0-or-later |
697
+ | `oxipng` | PNG lossless optimisation | MIT |
698
+ | `pngquant` | optional lossy PNG quantisation | GPL-3.0-or-later / ISC / BSD-2-Clause components |
699
+ | `cjpegli` / `djpegli` / `cjxl` | optional JPEG/JPEG XL tooling when installed | BSD-3-Clause via libjxl |
700
+ | `heif-enc` / libheif tools | optional HEIC/AVIF tooling when installed | LGPL-3.0-or-later |
701
+
702
+ Deployment packages may vary; check your distribution's package metadata if
703
+ license compliance depends on the exact binary build.