safe_image 0.2.0 → 0.4.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 +212 -10
- data/README.md +176 -168
- data/SECURITY.md +22 -11
- data/docs/architecture.md +63 -0
- data/ext/safe_image_vips_helper/extconf.rb +43 -0
- data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
- data/lib/safe_image/api/metadata.rb +85 -0
- data/lib/safe_image/api/transform.rb +152 -0
- data/lib/safe_image/backend_label.rb +24 -0
- data/lib/safe_image/formats.rb +96 -0
- data/lib/safe_image/ico.rb +43 -41
- data/lib/safe_image/image_magick_backend.rb +219 -162
- data/lib/safe_image/jpegli_backend.rb +64 -44
- data/lib/safe_image/metadata_operations.rb +155 -0
- data/lib/safe_image/native.rb +96 -281
- data/lib/safe_image/native_helper.rb +281 -0
- data/lib/safe_image/operation_backends/base.rb +83 -0
- data/lib/safe_image/operation_backends/image_magick.rb +123 -0
- data/lib/safe_image/operation_backends/vips.rb +251 -0
- data/lib/safe_image/operation_backends.rb +27 -0
- data/lib/safe_image/operation_set.rb +22 -0
- data/lib/safe_image/optimizer.rb +250 -48
- data/lib/safe_image/path_safety.rb +11 -0
- data/lib/safe_image/processor.rb +122 -85
- data/lib/safe_image/quality_defaults.rb +23 -0
- data/lib/safe_image/remote.rb +367 -97
- data/lib/safe_image/result.rb +71 -23
- data/lib/safe_image/runner.rb +69 -24
- data/lib/safe_image/sandbox.rb +44 -191
- data/lib/safe_image/staged_output.rb +44 -0
- data/lib/safe_image/svg_metadata.rb +186 -55
- data/lib/safe_image/transform_operations.rb +139 -0
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +13 -12
- data/lib/safe_image.rb +62 -294
- metadata +48 -26
- data/lib/safe_image/discourse_compat.rb +0 -452
- data/lib/safe_image/svg_sanitizer.rb +0 -102
- data/lib/safe_image/vips_glue.rb +0 -361
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Safe Image is a small Ruby image-processing boundary for untrusted uploads.
|
|
4
4
|
|
|
5
5
|
It gives an application one narrow API for probing, thumbnailing, resizing,
|
|
6
|
-
cropping, converting, optimising, SVG
|
|
6
|
+
cropping, converting, optimising, SVG metadata probing, animation checks, dominant
|
|
7
7
|
colour extraction, favicon conversion, and letter-avatar generation.
|
|
8
8
|
Everything that varies by host is decided once, at boot, with a single
|
|
9
9
|
mandatory call:
|
|
@@ -12,27 +12,27 @@ mandatory call:
|
|
|
12
12
|
SafeImage.configure!(backend: :vips, landlock: true)
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
The `:vips` backend uses a tiny
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
The `:vips` backend uses a tiny compiled helper for all libvips work; the Ruby
|
|
16
|
+
process never loads libvips. The `:imagemagick` backend runs ImageMagick with
|
|
17
|
+
shell-free command execution and a restrictive bundled policy. There are no
|
|
18
|
+
per-call backend choices and no silent fallback from one backend to the other:
|
|
19
|
+
you pick the decoder for untrusted bytes in one place and every operation uses
|
|
20
|
+
it.
|
|
21
21
|
|
|
22
22
|
The premise is that hostile image bytes are a lousy thing to spread across
|
|
23
23
|
model callbacks, upload helpers, optimizer wrappers, and hand-built command
|
|
24
24
|
strings. Safe Image puts the risky operations behind one small, hardened
|
|
25
|
-
choke point instead.
|
|
25
|
+
choke point instead. See [`docs/architecture.md`](docs/architecture.md) for the
|
|
26
|
+
security model and the invariants future changes must preserve.
|
|
26
27
|
|
|
27
28
|
## Install
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
[dependencies](#dependencies) below.
|
|
30
|
+
A small native helper is compiled at gem install time and linked against
|
|
31
|
+
libvips; install the libvips development package (`pkg-config vips` must work)
|
|
32
|
+
in the build environment. At runtime, `configure!(backend: :vips)` verifies that
|
|
33
|
+
this helper can start and initialize libvips; all subsequent libvips operations
|
|
34
|
+
run in helper subprocesses whose stderr is captured and reported only on
|
|
35
|
+
structured failures. Install the runtime [dependencies](#dependencies) below.
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
gem build safe_image.gemspec
|
|
@@ -55,8 +55,8 @@ result = SafeImage.thumbnail(
|
|
|
55
55
|
puts "#{result.backend}: #{result.width}x#{result.height} #{result.filesize} bytes"
|
|
56
56
|
|
|
57
57
|
SafeImage.convert(
|
|
58
|
-
"upload.png",
|
|
59
|
-
"upload.jpg",
|
|
58
|
+
input: "upload.png",
|
|
59
|
+
output: "upload.jpg",
|
|
60
60
|
format: "jpg",
|
|
61
61
|
quality: 85,
|
|
62
62
|
max_pixels: 40_000_000
|
|
@@ -66,13 +66,13 @@ SafeImage.convert(
|
|
|
66
66
|
## Configuration
|
|
67
67
|
|
|
68
68
|
`SafeImage.configure!` must be called before any operation — typically from a
|
|
69
|
-
boot-time initializer. Any operation before it (including
|
|
70
|
-
|
|
69
|
+
boot-time initializer. Any operation before it (including SVG metadata and remote
|
|
70
|
+
helpers) raises `SafeImage::NotConfiguredError`.
|
|
71
71
|
|
|
72
72
|
```ruby
|
|
73
73
|
SafeImage.configure!(
|
|
74
74
|
backend: :vips, # required: :vips or :imagemagick — decodes all untrusted bytes
|
|
75
|
-
landlock: true, # required:
|
|
75
|
+
landlock: true, # required: Landlock child helpers/tools when they run
|
|
76
76
|
max_pixels: SafeImage::DEFAULT_MAX_PIXELS # optional: default decompression-bomb ceiling (128MP)
|
|
77
77
|
)
|
|
78
78
|
```
|
|
@@ -80,7 +80,7 @@ SafeImage.configure!(
|
|
|
80
80
|
Validation is eager, so a misconfigured host fails at boot rather than on the
|
|
81
81
|
first request:
|
|
82
82
|
|
|
83
|
-
- `backend: :vips`
|
|
83
|
+
- `backend: :vips` verifies that the compiled helper can initialize libvips and raises if it is unavailable
|
|
84
84
|
- `backend: :imagemagick` raises if no `magick`/`convert` executable is found
|
|
85
85
|
- `landlock: true` raises if the Landlock sandbox is unavailable
|
|
86
86
|
(`SafeImage.sandbox_available?` works before `configure!`, so a host can
|
|
@@ -98,8 +98,8 @@ is decided here and only here.
|
|
|
98
98
|
|
|
99
99
|
Choosing a backend:
|
|
100
100
|
|
|
101
|
-
- `:vips` — the recommended fast path: explicit native loaders
|
|
102
|
-
|
|
101
|
+
- `:vips` — the recommended fast path: explicit native loaders in the compiled
|
|
102
|
+
helper process, with no libvips loaded into Ruby
|
|
103
103
|
- `:imagemagick` — matches classic ImageMagick `convert` pipelines; also the
|
|
104
104
|
option for hosts without libvips. Formats are decoded by ImageMagick under
|
|
105
105
|
the bundled restrictive policy.
|
|
@@ -116,7 +116,7 @@ Debian / Ubuntu:
|
|
|
116
116
|
|
|
117
117
|
```bash
|
|
118
118
|
sudo apt-get install --no-install-recommends \
|
|
119
|
-
|
|
119
|
+
build-essential pkg-config libvips-dev imagemagick jpegoptim pngquant oxipng libjpeg-turbo-progs
|
|
120
120
|
```
|
|
121
121
|
|
|
122
122
|
`oxipng` is packaged from Debian 13 / Ubuntu 24.04; on older releases install
|
|
@@ -127,7 +127,7 @@ detected at runtime.
|
|
|
127
127
|
Arch:
|
|
128
128
|
|
|
129
129
|
```bash
|
|
130
|
-
sudo pacman -S --needed libvips \
|
|
130
|
+
sudo pacman -S --needed base-devel pkgconf libvips \
|
|
131
131
|
imagemagick jpegoptim pngquant oxipng libjpeg-turbo libjxl
|
|
132
132
|
```
|
|
133
133
|
|
|
@@ -137,18 +137,19 @@ sudo pacman -S --needed libvips \
|
|
|
137
137
|
|
|
138
138
|
| Dependency | Kind | Needed for | Without it |
|
|
139
139
|
| --- | --- | --- | --- |
|
|
140
|
-
| `libvips` runtime library (`libvips.so.42`; Debian: `libvips42` ≥ 8.13) | required for `backend: :vips` | the fast path for every operation
|
|
140
|
+
| `libvips` runtime library (`libvips.so.42`; Debian: `libvips42` ≥ 8.13) plus development headers/pkg-config at gem build time | required for `backend: :vips` | the fast path for every operation and the compiled helper | gem install fails without headers; `configure!(backend: :vips)` raises at boot if the runtime library is unavailable |
|
|
141
141
|
| ImageMagick (`magick`/`convert`, `identify`) | required for `backend: :imagemagick` | every operation on the `:imagemagick` backend | `configure!(backend: :imagemagick)` raises at boot |
|
|
142
142
|
| `jpegoptim` | required for JPEG `optimize` | lossless JPEG optimisation and metadata stripping | JPEG `optimize` raises in strict mode |
|
|
143
143
|
| `oxipng` | required for PNG `optimize` | lossless PNG optimisation | PNG `optimize` raises in strict mode |
|
|
144
144
|
| `pngquant` | optional | lossy PNG quantisation (`optimize_mode: :lossy`, files < 500KB) | lossy mode silently skips the quantisation pass |
|
|
145
|
-
| `jpegtran` (libjpeg-turbo) | optional | lossless tier of `fix_orientation
|
|
145
|
+
| `jpegtran` (libjpeg-turbo) | optional | lossless tier of `fix_orientation`; uprighting EXIF-oriented JPEGs in `optimize` | `fix_orientation` falls back to the libvips re-encode tier; `optimize` of an oriented JPEG raises in strict mode (or copies source to output with `strict: false`) |
|
|
146
146
|
| `cjpegli` (libjxl) | optional | higher-quality encoding of generated JPEGs on the `:vips` backend — used automatically when installed | generated JPEGs use the backend's own encoder |
|
|
147
|
-
| `landlock` gem (Linux kernel ≥ 5.13) | required for `landlock: true` |
|
|
148
|
-
| `
|
|
147
|
+
| `landlock` gem ≥ 0.3 (Linux kernel ≥ 5.13) | required for `landlock: true` | optional sandboxing for child helpers/tools (`safe_image_vips_helper`, ImageMagick, optimizers, jpegtran/cjpegli) | `configure!(landlock: true)` raises at boot; `sandbox_available?` is false |
|
|
148
|
+
| `nokogiri` gem | automatic | bounded SVG metadata parser (not Landlock-contained) | installed as a gem dependency |
|
|
149
149
|
|
|
150
|
-
The `landlock` gem is intentionally **not** a gem dependency; add
|
|
151
|
-
host application's Gemfile if you want
|
|
150
|
+
The `landlock` gem is intentionally **not** a runtime gem dependency; add
|
|
151
|
+
`gem "landlock", ">= 0.3"` to the host application's Gemfile if you want
|
|
152
|
+
sandboxing.
|
|
152
153
|
|
|
153
154
|
### libvips build capabilities
|
|
154
155
|
|
|
@@ -190,7 +191,7 @@ require "safe_image"
|
|
|
190
191
|
%w[magick identify jpegoptim oxipng pngquant jpegtran cjpegli].each do |tool|
|
|
191
192
|
puts format("%-10s %s", tool, SafeImage::Runner.available?(tool) ? "ok" : "missing")
|
|
192
193
|
end
|
|
193
|
-
puts format("%-10s %s", "
|
|
194
|
+
puts format("%-10s %s", "vips-helper", SafeImage::Native.available? ? "ok" : "unavailable")
|
|
194
195
|
puts format("%-10s %s", "sandbox", SafeImage.sandbox_available? ? "ok" : "unavailable")
|
|
195
196
|
```
|
|
196
197
|
|
|
@@ -213,8 +214,8 @@ SafeImage::Result[
|
|
|
213
214
|
width:,
|
|
214
215
|
height:,
|
|
215
216
|
filesize:,
|
|
216
|
-
backend:, # e.g. "libvips-
|
|
217
|
-
# "libvips-
|
|
217
|
+
backend:, # e.g. "libvips-helper", "imagemagick", "cjpegli",
|
|
218
|
+
# "libvips-helper+cjpegli"
|
|
218
219
|
duration_ms:,
|
|
219
220
|
optimizer: # optimizer tool list for thumbnail path, otherwise nil
|
|
220
221
|
]
|
|
@@ -228,7 +229,9 @@ Optimizer operations return a hash:
|
|
|
228
229
|
before_bytes: 123_456,
|
|
229
230
|
after_bytes: 120_000,
|
|
230
231
|
saved_bytes: 3_456,
|
|
231
|
-
tools: ["jpegoptim"]
|
|
232
|
+
tools: ["jpegoptim"],
|
|
233
|
+
rotated_from: nil, # the EXIF orientation baked into the pixels, when one was set
|
|
234
|
+
trimmed: false # true when uprighting dropped partial edge blocks (see optimize)
|
|
232
235
|
}
|
|
233
236
|
```
|
|
234
237
|
|
|
@@ -289,7 +292,9 @@ SafeImage.size("icon.svg") # => [120, 80]
|
|
|
289
292
|
SVG metadata is handled by a dedicated parser, not ImageMagick or libvips. It is
|
|
290
293
|
limited to local `.svg` files, caps input size/tree depth/element/attribute
|
|
291
294
|
counts, rejects `DOCTYPE` and non-XML processing instructions, requires an `<svg>`
|
|
292
|
-
root, and derives dimensions from numeric `width`/`height` or `viewBox`.
|
|
295
|
+
root, and derives dimensions from numeric `width`/`height` or `viewBox`. This
|
|
296
|
+
bounded Nokogiri/libxml2 parse happens in the Ruby process; `landlock: true`
|
|
297
|
+
contains child image helpers/tools, not SVG metadata parsing itself.
|
|
293
298
|
|
|
294
299
|
#### `SafeImage.orientation(path, max_pixels: nil)`
|
|
295
300
|
|
|
@@ -367,8 +372,26 @@ SafeImage.dominant_color("upload.png") # => "6F745E"
|
|
|
367
372
|
|
|
368
373
|
These helpers are intended to cover `FastImage.size(url)` / `FastImage.type(url)`
|
|
369
374
|
style use cases without another Ruby dependency. They use only Ruby stdlib
|
|
370
|
-
`Net::HTTP
|
|
371
|
-
|
|
375
|
+
`Net::HTTP` and stream to a tempfile with a byte cap.
|
|
376
|
+
|
|
377
|
+
Like FastImage, the metadata helpers (`remote_size`, `remote_type`,
|
|
378
|
+
`remote_info`, `remote_animated?`) download as little as possible: the normal
|
|
379
|
+
Safe Image local metadata path probes the partially-downloaded tempfile as
|
|
380
|
+
bytes arrive (first at 64KB, then at growing thresholds) and the transfer is
|
|
381
|
+
aborted as soon as the answer is final — typically after the first 64KB.
|
|
382
|
+
Early answers are only trusted when more data cannot change them:
|
|
383
|
+
|
|
384
|
+
- "not animated" is reported only from the complete file, because a truncated
|
|
385
|
+
animation undercounts frames; "animated" is final as soon as a second frame
|
|
386
|
+
is seen
|
|
387
|
+
- SVG metadata always downloads the whole document, so the SVG parser's total
|
|
388
|
+
size cap keeps its meaning
|
|
389
|
+
- a probe failure on a prefix just means the download continues; a file that
|
|
390
|
+
never yields an early answer is downloaded and validated exactly like a
|
|
391
|
+
`fetch_remote` download
|
|
392
|
+
|
|
393
|
+
`fetch_remote` and `remote_dominant_color` need the complete body and always
|
|
394
|
+
download it (up to `max_bytes`).
|
|
372
395
|
|
|
373
396
|
Remote fetching is deliberately conservative:
|
|
374
397
|
|
|
@@ -392,9 +415,14 @@ Remote fetching is deliberately conservative:
|
|
|
392
415
|
- private, loopback, link-local, multicast, documentation, benchmarking,
|
|
393
416
|
carrier-grade NAT, IPv4-mapped IPv6, NAT64, 6to4/Teredo, and other
|
|
394
417
|
special-use resolved addresses are rejected by default
|
|
395
|
-
- no image decoding happens directly from the socket
|
|
418
|
+
- no image decoding happens directly from the socket; probes only ever see the
|
|
419
|
+
on-disk tempfile
|
|
396
420
|
- the final response `Content-Type` must be an allowed image type and must agree
|
|
397
|
-
with an image-looking URL extension when one is present
|
|
421
|
+
with an image-looking URL extension when one is present — both are enforced
|
|
422
|
+
from the response headers, before any body bytes are downloaded
|
|
423
|
+
- the first bytes of the body must be compatible with the claimed format's
|
|
424
|
+
magic bytes (SVG, which has no signature, is exempt); an obviously mislabeled
|
|
425
|
+
body is dropped after the first chunk instead of being downloaded to the cap
|
|
398
426
|
- downloaded content is probed before `fetch_remote` yields the tempfile, so the
|
|
399
427
|
raw downloader cannot be used as a blind extension-based file saver
|
|
400
428
|
- SVG remote metadata uses the same bounded SVG metadata parser after download;
|
|
@@ -461,15 +489,20 @@ SafeImage.fetch_remote("https://example.com/image.jpg", max_bytes: 10.megabytes)
|
|
|
461
489
|
end
|
|
462
490
|
```
|
|
463
491
|
|
|
464
|
-
With `landlock: true` configured, the network fetch itself is not
|
|
465
|
-
|
|
466
|
-
tempfile is then passed through the normal Safe
|
|
467
|
-
decoding
|
|
492
|
+
With `landlock: true` configured, the network fetch itself is not sandboxed: it
|
|
493
|
+
needs network access, while the image-processing child helpers/tools deliberately
|
|
494
|
+
do not get it. The downloaded tempfile is then passed through the normal Safe
|
|
495
|
+
Image local APIs, so raster decoding and transform subprocesses still use the
|
|
496
|
+
configured Landlock containment.
|
|
468
497
|
|
|
469
498
|
### Generating images
|
|
470
499
|
|
|
471
500
|
Operations that write a new image. All of them run on the configured backend
|
|
472
|
-
and return [`SafeImage::Result`](#return-values).
|
|
501
|
+
and return [`SafeImage::Result`](#return-values). Every operation that reads an
|
|
502
|
+
existing image and writes a file requires explicit `input:` and `output:` paths;
|
|
503
|
+
they must be distinct. Safe Image does not expose in-place mutating APIs — if a
|
|
504
|
+
caller wants to replace a file, write to a separate destination and perform the
|
|
505
|
+
rename in application code.
|
|
473
506
|
|
|
474
507
|
#### `SafeImage.thumbnail(...)`
|
|
475
508
|
|
|
@@ -499,46 +532,46 @@ Supported outputs on the `:vips` backend:
|
|
|
499
532
|
- `avif`
|
|
500
533
|
- `jxl` (requires a libvips build with libjxl support)
|
|
501
534
|
|
|
502
|
-
#### `SafeImage.resize(
|
|
535
|
+
#### `SafeImage.resize(input:, output:, width:, height:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
|
|
503
536
|
|
|
504
537
|
Creates a resized thumbnail-style output.
|
|
505
538
|
|
|
506
539
|
```ruby
|
|
507
|
-
SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400)
|
|
508
|
-
SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400, quality: 85)
|
|
540
|
+
SafeImage.resize(input: "upload.jpg", output: "thumb.jpg", width: 600, height: 400)
|
|
541
|
+
SafeImage.resize(input: "upload.jpg", output: "thumb.jpg", width: 600, height: 400, quality: 85)
|
|
509
542
|
```
|
|
510
543
|
|
|
511
544
|
`resize`, `crop` and `downsize` run on the configured backend:
|
|
512
545
|
|
|
513
|
-
- `:vips` — the
|
|
546
|
+
- `:vips` — the helper-backed libvips path; formats outside the native loader
|
|
514
547
|
allowlist (ICO input) fail closed
|
|
515
548
|
- `:imagemagick` — matches classic `convert` thumbnail pipelines
|
|
516
549
|
(`-thumbnail`, catrom interpolation, unsharp, sRGB profile); configure this
|
|
517
550
|
if byte-similar output with previously generated thumbnails matters
|
|
518
551
|
|
|
519
|
-
#### `SafeImage.crop(
|
|
552
|
+
#### `SafeImage.crop(input:, output:, width:, height:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
|
|
520
553
|
|
|
521
554
|
Creates a north-cropped image — the shape typically used for square avatar
|
|
522
555
|
crops.
|
|
523
556
|
|
|
524
557
|
```ruby
|
|
525
|
-
SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240)
|
|
558
|
+
SafeImage.crop(input: "upload.jpg", output: "avatar.jpg", width: 240, height: 240)
|
|
526
559
|
```
|
|
527
560
|
|
|
528
|
-
#### `SafeImage.downsize(
|
|
561
|
+
#### `SafeImage.downsize(input:, output:, dimensions:, optimize: true, max_pixels: nil, quality: 85, chroma_subsampling: :auto)`
|
|
529
562
|
|
|
530
563
|
Downsizes an image using ImageMagick-style geometry strings.
|
|
531
564
|
|
|
532
565
|
```ruby
|
|
533
|
-
SafeImage.downsize("large.png", "small.png", "50%")
|
|
534
|
-
SafeImage.downsize("large.png", "small.png", "100x100>")
|
|
535
|
-
SafeImage.downsize("large.png", "small.png", "400000@")
|
|
566
|
+
SafeImage.downsize(input: "large.png", output: "small.png", dimensions: "50%")
|
|
567
|
+
SafeImage.downsize(input: "large.png", output: "small.png", dimensions: "100x100>")
|
|
568
|
+
SafeImage.downsize(input: "large.png", output: "small.png", dimensions: "400000@")
|
|
536
569
|
```
|
|
537
570
|
|
|
538
571
|
The vips backend supports the geometry forms covered by the test suite:
|
|
539
572
|
percentage, bounding box with `>`, and pixel-area cap with `@`.
|
|
540
573
|
|
|
541
|
-
#### `SafeImage.convert(
|
|
574
|
+
#### `SafeImage.convert(input:, output:, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
|
|
542
575
|
|
|
543
576
|
Converts an input image to an explicit output `format:`. Unsupported formats
|
|
544
577
|
raise `SafeImage::UnsupportedFormatError`.
|
|
@@ -555,12 +588,12 @@ For PNG-to-JPEG on the `:vips` backend, `cjpegli` is used automatically when
|
|
|
555
588
|
installed (see [JPEG encoding of generated images](#jpeg-encoding-of-generated-images)).
|
|
556
589
|
|
|
557
590
|
```ruby
|
|
558
|
-
SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85)
|
|
559
|
-
SafeImage.convert("upload.heic", "upload.jpg", format: "jpg", quality: 85)
|
|
560
|
-
SafeImage.convert("upload.jpg", "upload.webp", format: "webp", quality: 85)
|
|
591
|
+
SafeImage.convert(input: "upload.png", output: "upload.jpg", format: "jpg", quality: 85)
|
|
592
|
+
SafeImage.convert(input: "upload.heic", output: "upload.jpg", format: "jpg", quality: 85)
|
|
593
|
+
SafeImage.convert(input: "upload.jpg", output: "upload.webp", format: "webp", quality: 85)
|
|
561
594
|
```
|
|
562
595
|
|
|
563
|
-
#### `SafeImage.fix_orientation(
|
|
596
|
+
#### `SafeImage.fix_orientation(input:, output:, max_pixels: nil, quality: nil)`
|
|
564
597
|
|
|
565
598
|
Bakes the EXIF orientation into the pixels and clears the tag. The `:vips`
|
|
566
599
|
backend tries tiers in order:
|
|
@@ -574,26 +607,23 @@ backend tries tiers in order:
|
|
|
574
607
|
The `:imagemagick` backend uses the previous `-auto-orient` behaviour and
|
|
575
608
|
re-encodes at the input's estimated quality.
|
|
576
609
|
|
|
577
|
-
If `to` is omitted, the file is rewritten in place.
|
|
578
|
-
|
|
579
610
|
```ruby
|
|
580
|
-
SafeImage.fix_orientation("upload.jpg")
|
|
581
|
-
SafeImage.fix_orientation("upload.jpg", "oriented.jpg")
|
|
611
|
+
SafeImage.fix_orientation(input: "upload.jpg", output: "oriented.jpg")
|
|
582
612
|
```
|
|
583
613
|
|
|
584
|
-
#### `SafeImage.convert_favicon_to_png(
|
|
614
|
+
#### `SafeImage.convert_favicon_to_png(input:, output:, optimize: true, max_pixels: nil)`
|
|
585
615
|
|
|
586
616
|
Extracts the largest ICO entry and writes PNG. On the `:vips` backend no
|
|
587
617
|
ImageMagick is involved: the container and legacy DIB payloads (1/4/8/24/32bpp
|
|
588
618
|
BI_RGB plus the AND mask) are parsed in pure Ruby with explicit bounds checks,
|
|
589
|
-
and pixels are encoded through the hardened
|
|
619
|
+
and pixels are encoded through the hardened libvips helper. Embedded PNG
|
|
590
620
|
payloads are re-encoded — never copied through verbatim — and their pixel cap
|
|
591
621
|
is enforced from the IHDR before any decoder runs. On the `:imagemagick`
|
|
592
622
|
backend the conversion runs through ImageMagick's ico decoder under the
|
|
593
623
|
bundled policy.
|
|
594
624
|
|
|
595
625
|
```ruby
|
|
596
|
-
SafeImage.convert_favicon_to_png("favicon.ico", "favicon.png")
|
|
626
|
+
SafeImage.convert_favicon_to_png(input: "favicon.ico", output: "favicon.png")
|
|
597
627
|
```
|
|
598
628
|
|
|
599
629
|
#### `SafeImage.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")`
|
|
@@ -633,17 +663,18 @@ JPEGs**. This avoids hiding a lossy re-encode behind a method named `optimize`.
|
|
|
633
663
|
|
|
634
664
|
Like the optimizer tools, the optional `cjpegli` encoder is availability
|
|
635
665
|
driven: installed means used, absent means the configured backend encodes.
|
|
636
|
-
There is no encoder knob
|
|
637
|
-
|
|
638
|
-
|
|
666
|
+
There is no encoder knob. Even when cjpegli is used, untrusted inputs are first
|
|
667
|
+
decoded by the configured Safe Image backend with the normal pixel cap; cjpegli
|
|
668
|
+
only receives a PNG generated by Safe Image, so it is not an additional
|
|
669
|
+
untrusted-input decoder.
|
|
639
670
|
|
|
640
671
|
| Operation | Behavior |
|
|
641
672
|
| --- | --- |
|
|
642
|
-
| `thumbnail` / `resize` / `crop` / `downsize` to JPEG on the `:vips` backend | use `cjpegli` when installed; otherwise normal libvips JPEG output |
|
|
643
|
-
| `convert("input.png", "output.jpg", format: "jpg")` on the `:vips` backend |
|
|
644
|
-
| `convert` from HEIC/WebP/AVIF/GIF/JPEG to JPEG | decode through the native libvips loaders and encode with libvips; `cjpegli` is not treated as a universal decoder |
|
|
673
|
+
| `thumbnail` / `resize` / `crop` / `downsize` to JPEG on the `:vips` backend | decode through libvips first, then use `cjpegli` when installed; otherwise normal libvips JPEG output |
|
|
674
|
+
| `convert(input: "input.png", output: "output.jpg", format: "jpg")` on the `:vips` backend | decode the PNG through libvips into a generated PNG first, then use `cjpegli` when installed; otherwise libvips |
|
|
675
|
+
| `convert` from HEIC/WebP/AVIF/GIF/JPEG/JXL to JPEG | decode through the native libvips loaders and encode with libvips; `cjpegli` is not treated as a universal decoder |
|
|
645
676
|
| any operation on the `:imagemagick` backend | ImageMagick encodes; `cjpegli` is never used |
|
|
646
|
-
| `optimize("existing.jpg")` | use `jpegoptim`; never `cjpegli` |
|
|
677
|
+
| `optimize(input: "existing.jpg", output: "optimized.jpg")` | use `jpegoptim`; never `cjpegli` |
|
|
647
678
|
|
|
648
679
|
`cjpegli` output is ordinary browser-compatible JPEG. It is optional because it
|
|
649
680
|
is a system binary, not a Ruby dependency. Safe Image detects it at runtime.
|
|
@@ -651,16 +682,17 @@ is a system binary, not a Ruby dependency. Safe Image detects it at runtime.
|
|
|
651
682
|
`chroma_subsampling: :auto` uses `4:4:4` for PNG-sourced JPEG conversion and
|
|
652
683
|
`4:2:0` otherwise. Pass `"420"`, `"422"`, or `"444"` to force a value.
|
|
653
684
|
|
|
654
|
-
### Optimising
|
|
685
|
+
### Optimising to a destination
|
|
655
686
|
|
|
656
|
-
#### `SafeImage.optimize(
|
|
687
|
+
#### `SafeImage.optimize(input:, output:, mode: :lossless, strip_metadata: true, quality: nil, strict: true)`
|
|
657
688
|
|
|
658
|
-
Optimises an existing JPEG or PNG
|
|
689
|
+
Optimises an existing JPEG or PNG into a separate output path. The source file is
|
|
690
|
+
never modified, and `input:`/`output:` must not refer to the same file.
|
|
659
691
|
|
|
660
692
|
```ruby
|
|
661
|
-
SafeImage.optimize("image.jpg", quality: 85)
|
|
662
|
-
SafeImage.optimize("image.png")
|
|
663
|
-
SafeImage.optimize("image.png", mode: :lossy, quality: "65-90")
|
|
693
|
+
SafeImage.optimize(input: "image.jpg", output: "image.optimized.jpg", quality: 85)
|
|
694
|
+
SafeImage.optimize(input: "image.png", output: "image.optimized.png")
|
|
695
|
+
SafeImage.optimize(input: "image.png", output: "image.lossy.png", mode: :lossy, quality: "65-90")
|
|
664
696
|
```
|
|
665
697
|
|
|
666
698
|
JPEG path:
|
|
@@ -668,6 +700,14 @@ JPEG path:
|
|
|
668
700
|
- uses `jpegoptim`
|
|
669
701
|
- `quality:` maps to `jpegoptim --max`
|
|
670
702
|
- metadata is stripped unless `strip_metadata: false`
|
|
703
|
+
- an EXIF-oriented JPEG is uprighted first with jpegtran's lossless
|
|
704
|
+
transforms, because stripping would otherwise delete the orientation tag
|
|
705
|
+
without applying the rotation and ship the image sideways. MCU-aligned
|
|
706
|
+
images rotate exactly (`-perfect`); others drop the partial edge blocks
|
|
707
|
+
(`-trim`, under one MCU — at most 15px), reported as `trimmed: true` in
|
|
708
|
+
the result rather than re-encoding behind a method named `optimize`.
|
|
709
|
+
Without `jpegtran`, an oriented JPEG raises in strict mode and the output is
|
|
710
|
+
not written; with `strict: false`, the original bytes are copied to `output:`.
|
|
671
711
|
|
|
672
712
|
PNG path:
|
|
673
713
|
|
|
@@ -676,44 +716,18 @@ PNG path:
|
|
|
676
716
|
then `oxipng`
|
|
677
717
|
|
|
678
718
|
When `strict: true`, missing optimizer tools raise. When `strict: false`, missing
|
|
679
|
-
optimizer tools are tolerated
|
|
680
|
-
|
|
681
|
-
### SVG sanitising
|
|
682
|
-
|
|
683
|
-
#### `SafeImage.sanitize_svg!(path)`
|
|
684
|
-
|
|
685
|
-
Sanitises an SVG in place using a small REXML allowlist.
|
|
686
|
-
|
|
687
|
-
```ruby
|
|
688
|
-
result = SafeImage.sanitize_svg!("icon.svg")
|
|
689
|
-
puts result[:sanitized]
|
|
690
|
-
```
|
|
719
|
+
optimizer tools are tolerated and the output is still written from the source
|
|
720
|
+
copy when possible.
|
|
691
721
|
|
|
692
|
-
|
|
693
|
-
handlers. It is intentionally conservative rather than a full browser-grade SVG
|
|
694
|
-
implementation.
|
|
722
|
+
### SVG handling
|
|
695
723
|
|
|
696
|
-
|
|
697
|
-
|
|
724
|
+
Safe Image no longer sanitises or rewrites SVG files. It only supports bounded
|
|
725
|
+
metadata probing for local and remote `.svg` inputs (`type`, `size`, `info`, and
|
|
726
|
+
the corresponding remote helpers). Applications that accept user-supplied SVG
|
|
727
|
+
content for display should run a dedicated SVG sanitizer in their own upload or
|
|
728
|
+
rendering pipeline, and should still use response-level controls such as a
|
|
698
729
|
restrictive `Content-Security-Policy`, `X-Content-Type-Options: nosniff`, and/or
|
|
699
|
-
attachment/sandbox handling for direct-open routes.
|
|
700
|
-
execution when an SVG is embedded as `<img>`, but a top-level SVG document is a
|
|
701
|
-
different sink.
|
|
702
|
-
|
|
703
|
-
### Compatibility aliases
|
|
704
|
-
|
|
705
|
-
Two thin wrappers kept for callers migrating from existing upload pipelines:
|
|
706
|
-
|
|
707
|
-
```ruby
|
|
708
|
-
SafeImage.optimize_image!("image.jpg")
|
|
709
|
-
SafeImage.optimize_image!("image.png", allow_lossy_png: true)
|
|
710
|
-
SafeImage.convert_to_jpeg("upload.heic", "upload.jpg", quality: 85)
|
|
711
|
-
```
|
|
712
|
-
|
|
713
|
-
`optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)`
|
|
714
|
-
forwards to `optimize`, with `allow_lossy_png:` mapping to `mode:`.
|
|
715
|
-
`convert_to_jpeg(from, to, ...)` forwards to `convert` with `format: "jpg"`
|
|
716
|
-
and accepts the same keywords.
|
|
730
|
+
attachment/sandbox handling for direct-open routes.
|
|
717
731
|
|
|
718
732
|
## Security
|
|
719
733
|
|
|
@@ -730,12 +744,12 @@ What it does:
|
|
|
730
744
|
- starts external commands with an allowlisted environment, private temp/home/cache
|
|
731
745
|
directories, bounded stdout/stderr, and process-group timeout cleanup
|
|
732
746
|
- uses explicit libvips loaders selected from allowlisted extensions
|
|
733
|
-
- enables libvips' untrusted-operation block
|
|
747
|
+
- enables libvips' untrusted-operation block inside the helper (deliberately
|
|
734
748
|
re-enabling only the libjxl loader/saver, which libvips tags untrusted,
|
|
735
749
|
because JPEG XL is part of the supported input surface)
|
|
736
|
-
- blocks libvips ImageMagick loader classes in the
|
|
737
|
-
|
|
738
|
-
- disables libvips cache
|
|
750
|
+
- blocks libvips ImageMagick loader classes in the helper, which exposes only
|
|
751
|
+
the operations the gem invokes
|
|
752
|
+
- disables libvips cache inside the helper
|
|
739
753
|
- strips metadata on generated images where applicable
|
|
740
754
|
- rejects symlinked local input/output paths and symlinked path components for
|
|
741
755
|
untrusted file-processing paths
|
|
@@ -752,12 +766,8 @@ What it does:
|
|
|
752
766
|
blocking, DNS pinning, redirect limits, HTTPS-to-HTTP rejection, proxy-env
|
|
753
767
|
bypass prevention, request-header allowlists, content-type/extension
|
|
754
768
|
agreement, and probe-before-yield (details under [Remote URLs](#remote-urls))
|
|
755
|
-
- parses SVG metadata with a bounded
|
|
769
|
+
- parses SVG metadata with a bounded Nokogiri SAX parser; SVG is never handed to
|
|
756
770
|
ImageMagick for probing
|
|
757
|
-
- sanitises SVG conservatively, allowlist based: rejects `DOCTYPE` and XML
|
|
758
|
-
processing instructions, removes comments and disallowed elements, converts
|
|
759
|
-
CDATA to escaped text, and blocks event handlers, external URLs, and
|
|
760
|
-
`javascript:` / `data:` URL values
|
|
761
771
|
- supports optional Landlock subprocess sandboxing on Linux
|
|
762
772
|
|
|
763
773
|
The backend is a configuration decision, not a per-call option. Safe Image
|
|
@@ -780,23 +790,23 @@ ImageMagick path runs with:
|
|
|
780
790
|
|
|
781
791
|
That does **not** make hostile files benign. Raster decoders still parse attacker
|
|
782
792
|
controlled bytes: libjpeg, libpng, libwebp, libheif/HEIC/AVIF, ImageMagick's
|
|
783
|
-
raster decoders, and libvips loaders
|
|
784
|
-
|
|
785
|
-
sandbox.
|
|
793
|
+
raster decoders, and libvips loaders; Nokogiri/libxml2 also parses SVG metadata.
|
|
794
|
+
If one of those parsers or decoders has a memory corruption bug or pathological
|
|
795
|
+
resource-consumption bug, policy alone is not a sandbox.
|
|
786
796
|
|
|
787
797
|
So the intended posture is:
|
|
788
798
|
|
|
789
799
|
- without Landlock: hardened, centralized image processing with the major
|
|
790
800
|
ImageMagick delegate/pseudo-protocol foot-guns removed
|
|
791
|
-
- with Landlock: the same hardening plus a
|
|
792
|
-
|
|
801
|
+
- with Landlock: the same hardening plus a containment boundary around child
|
|
802
|
+
helpers/tools that parse or transform raster bytes
|
|
793
803
|
|
|
794
804
|
Do not describe non-sandboxed operation as making hostile images safe. The honest
|
|
795
805
|
claim is defense-in-depth, not immunity.
|
|
796
806
|
|
|
797
|
-
###
|
|
807
|
+
### Optional Landlock containment
|
|
798
808
|
|
|
799
|
-
Landlock support is optional, but
|
|
809
|
+
Landlock support is optional, but fail-closed once configured.
|
|
800
810
|
|
|
801
811
|
```ruby
|
|
802
812
|
SafeImage.sandbox_available? # => true/false, works before configure!
|
|
@@ -804,46 +814,44 @@ SafeImage.configure!(backend: :vips, landlock: true) # raises if unavailable
|
|
|
804
814
|
SafeImage.config.landlock # => true
|
|
805
815
|
```
|
|
806
816
|
|
|
807
|
-
With `landlock: true`,
|
|
808
|
-
worker:
|
|
809
|
-
|
|
810
|
-
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
- `
|
|
816
|
-
- `
|
|
817
|
-
- `
|
|
818
|
-
- `
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
parent's backend and pixel-ceiling configuration; landlock is forced off
|
|
840
|
-
inside the worker so sandboxed operations never nest.
|
|
817
|
+
With `landlock: true`, Safe Image does **not** proxy public operations through a
|
|
818
|
+
sandboxed Ruby worker. Ruby remains the orchestrator: it validates paths, stages
|
|
819
|
+
outputs, applies format/pixel policy, parses bounded SVG/ICO metadata where
|
|
820
|
+
needed, and launches the actual image-processing helpers/tools.
|
|
821
|
+
|
|
822
|
+
The Landlock boundary is applied to child processes that do risky native image
|
|
823
|
+
work:
|
|
824
|
+
|
|
825
|
+
- `safe_image_vips_helper` for every libvips operation
|
|
826
|
+
- ImageMagick `magick`/`convert`/`identify` commands on the `:imagemagick` backend
|
|
827
|
+
- optimizer tools such as `jpegoptim`, `oxipng`, and `pngquant`
|
|
828
|
+
- `jpegtran` and `cjpegli` when those optional paths are used
|
|
829
|
+
|
|
830
|
+
There is no silent fallback once Landlock is configured. If sandbox setup or a
|
|
831
|
+
sandboxed helper/tool fails, the operation fails.
|
|
832
|
+
|
|
833
|
+
Each child process receives only the explicit read/write grants for that call,
|
|
834
|
+
plus runtime/library/font/temp paths needed by the helper or tool. The network is
|
|
835
|
+
denied for those children; remote fetching happens in Ruby before the downloaded
|
|
836
|
+
tempfile is handed back to the local image APIs.
|
|
837
|
+
|
|
838
|
+
For the `:vips` backend, raster operations are executed by the compiled
|
|
839
|
+
`safe_image_vips_helper` executable. The Ruby process performs path validation and
|
|
840
|
+
orchestration, then the helper initializes libvips in its own process, applies the
|
|
841
|
+
loader/operation allowlist and pixel cap, writes a structured JSON response, and
|
|
842
|
+
exits. With Landlock enabled, the helper process itself is launched inside the
|
|
843
|
+
per-call filesystem policy.
|
|
844
|
+
|
|
845
|
+
SVG metadata is the deliberate exception: it is bounded and non-rendering, but it
|
|
846
|
+
is parsed by Nokogiri/libxml2 in the Ruby process and is not Landlock-contained by
|
|
847
|
+
Safe Image. If your deployment needs a hard kernel boundary around SVG parsing as
|
|
848
|
+
well, run Safe Image in a separate application process/container.
|
|
841
849
|
|
|
842
850
|
## Development
|
|
843
851
|
|
|
844
852
|
```bash
|
|
845
853
|
bundle install
|
|
846
|
-
bundle exec rake # run the tests (
|
|
854
|
+
bundle exec rake # run the tests (builds/uses the native vips helper)
|
|
847
855
|
docker/run.sh # run the suite on Debian bookworm's packaged libvips 8.14
|
|
848
856
|
bundle exec rubocop # lint
|
|
849
857
|
```
|
|
@@ -855,8 +863,8 @@ explanation when that support is missing.
|
|
|
855
863
|
|
|
856
864
|
The suite includes golden-output checks, cross-backend parity checks (the
|
|
857
865
|
claim is operation parity, not byte-for-byte output identity across
|
|
858
|
-
ImageMagick/libvips versions), policy-denial checks, and
|
|
859
|
-
|
|
866
|
+
ImageMagick/libvips versions), policy-denial checks, and Landlock sweeps over
|
|
867
|
+
helper/tool execution paths.
|
|
860
868
|
|
|
861
869
|
Gem packaging uses the standard Bundler tasks (`rake build`, `rake install`).
|
|
862
870
|
Releases: bump `SafeImage::VERSION`, merge to `main`, and CI publishes the
|