safe_image 0.1.0 → 0.2.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 +158 -0
- data/README.md +475 -281
- data/SECURITY.md +27 -7
- data/lib/safe_image/discourse_compat.rb +267 -98
- data/lib/safe_image/fonts/DEJAVU-LICENSE +187 -0
- data/lib/safe_image/fonts/DejaVuSans.ttf +0 -0
- data/lib/safe_image/ico.rb +286 -0
- data/lib/safe_image/image_magick_backend.rb +39 -3
- data/lib/safe_image/imagemagick_policy/policy.xml +8 -1
- data/lib/safe_image/jpegli_backend.rb +3 -1
- data/lib/safe_image/native.rb +371 -1
- data/lib/safe_image/processor.rb +23 -69
- data/lib/safe_image/remote.rb +15 -3
- data/lib/safe_image/runner.rb +1 -1
- data/lib/safe_image/sandbox.rb +42 -16
- data/lib/safe_image/svg_metadata.rb +59 -29
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +57 -0
- data/lib/safe_image/vips_glue.rb +361 -0
- data/lib/safe_image.rb +143 -36
- metadata +30 -14
- data/ext/safe_image_native/extconf.rb +0 -8
- data/ext/safe_image_native/safe_image_native.c +0 -392
data/README.md
CHANGED
|
@@ -3,77 +3,36 @@
|
|
|
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 sanitising, animation checks,
|
|
7
|
-
conversion, and letter-avatar generation.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
policy.
|
|
6
|
+
cropping, converting, optimising, SVG sanitising, animation checks, dominant
|
|
7
|
+
colour extraction, favicon conversion, and letter-avatar generation.
|
|
8
|
+
Everything that varies by host is decided once, at boot, with a single
|
|
9
|
+
mandatory call:
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
upload helpers, optimizer wrappers, and hand-built command strings.
|
|
16
|
-
|
|
17
|
-
## Security model
|
|
11
|
+
```ruby
|
|
12
|
+
SafeImage.configure!(backend: :vips, landlock: true)
|
|
13
|
+
```
|
|
18
14
|
|
|
19
|
-
|
|
15
|
+
The `:vips` backend uses a tiny Fiddle binding that drives `libvips` directly
|
|
16
|
+
— pure Ruby, nothing compiles at install time. The `:imagemagick` backend
|
|
17
|
+
runs ImageMagick with shell-free command execution and a restrictive bundled
|
|
18
|
+
policy. There are no per-call backend choices and no silent fallback from one
|
|
19
|
+
backend to the other: you pick the decoder for untrusted bytes in one place
|
|
20
|
+
and every operation uses it.
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
22
|
+
The premise is that hostile image bytes are a lousy thing to spread across
|
|
23
|
+
model callbacks, upload helpers, optimizer wrappers, and hand-built command
|
|
24
|
+
strings. Safe Image puts the risky operations behind one small, hardened
|
|
25
|
+
choke point instead.
|
|
46
26
|
|
|
47
27
|
## Install
|
|
48
28
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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`.
|
|
29
|
+
Nothing compiles at install time: libvips is bound at runtime through Fiddle
|
|
30
|
+
(`libvips.so.42` is dlopened when `configure!(backend: :vips)` runs;
|
|
31
|
+
`SAFE_IMAGE_LIBVIPS` overrides the library name authoritatively). libvips'
|
|
32
|
+
GLib warnings about rejected input (e.g. "Not a PNG file") are silenced —
|
|
33
|
+
failures surface as exceptions instead; set `SAFE_IMAGE_VIPS_WARNINGS=1` to
|
|
34
|
+
restore them for debugging. Install the runtime
|
|
35
|
+
[dependencies](#dependencies) below.
|
|
77
36
|
|
|
78
37
|
```bash
|
|
79
38
|
gem build safe_image.gemspec
|
|
@@ -83,6 +42,8 @@ gem install ./safe_image-0.1.0.gem
|
|
|
83
42
|
```ruby
|
|
84
43
|
require "safe_image"
|
|
85
44
|
|
|
45
|
+
SafeImage.configure!(backend: :vips, landlock: false)
|
|
46
|
+
|
|
86
47
|
result = SafeImage.thumbnail(
|
|
87
48
|
input: "upload.jpg",
|
|
88
49
|
output: "thumb.jpg",
|
|
@@ -102,7 +63,144 @@ SafeImage.convert(
|
|
|
102
63
|
)
|
|
103
64
|
```
|
|
104
65
|
|
|
105
|
-
##
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
`SafeImage.configure!` must be called before any operation — typically from a
|
|
69
|
+
boot-time initializer. Any operation before it (including the pure-Ruby SVG
|
|
70
|
+
and remote helpers) raises `SafeImage::NotConfiguredError`.
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
SafeImage.configure!(
|
|
74
|
+
backend: :vips, # required: :vips or :imagemagick — decodes all untrusted bytes
|
|
75
|
+
landlock: true, # required: route every operation through the Landlock sandbox
|
|
76
|
+
max_pixels: SafeImage::DEFAULT_MAX_PIXELS # optional: default decompression-bomb ceiling (128MP)
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Validation is eager, so a misconfigured host fails at boot rather than on the
|
|
81
|
+
first request:
|
|
82
|
+
|
|
83
|
+
- `backend: :vips` dlopens libvips and raises if it is unavailable
|
|
84
|
+
- `backend: :imagemagick` raises if no `magick`/`convert` executable is found
|
|
85
|
+
- `landlock: true` raises if the Landlock sandbox is unavailable
|
|
86
|
+
(`SafeImage.sandbox_available?` works before `configure!`, so a host can
|
|
87
|
+
probe first)
|
|
88
|
+
- unknown values raise `ArgumentError`
|
|
89
|
+
|
|
90
|
+
Calling `configure!` again replaces the configuration atomically (last call
|
|
91
|
+
wins), so reloading initializers in development is safe. `SafeImage.config`
|
|
92
|
+
returns the current frozen configuration and `SafeImage.configured?` reports
|
|
93
|
+
whether one is set.
|
|
94
|
+
|
|
95
|
+
Per-call `max_pixels:` still overrides the configured ceiling for an
|
|
96
|
+
individual operation; everything else about backend selection and sandboxing
|
|
97
|
+
is decided here and only here.
|
|
98
|
+
|
|
99
|
+
Choosing a backend:
|
|
100
|
+
|
|
101
|
+
- `:vips` — the recommended fast path: explicit native loaders, in-process
|
|
102
|
+
hardening, no subprocess per operation
|
|
103
|
+
- `:imagemagick` — matches classic ImageMagick `convert` pipelines; also the
|
|
104
|
+
option for hosts without libvips. Formats are decoded by ImageMagick under
|
|
105
|
+
the bundled restrictive policy.
|
|
106
|
+
|
|
107
|
+
A format the configured backend cannot decode fails closed — e.g. ICO
|
|
108
|
+
transform inputs need `:imagemagick` (ICO *metadata* is parsed in pure Ruby on
|
|
109
|
+
either backend), and HEIC needs a libvips build with libheif on `:vips`.
|
|
110
|
+
|
|
111
|
+
## Dependencies
|
|
112
|
+
|
|
113
|
+
### Quick install
|
|
114
|
+
|
|
115
|
+
Debian / Ubuntu:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
sudo apt-get install --no-install-recommends \
|
|
119
|
+
libvips42 imagemagick jpegoptim pngquant oxipng libjpeg-turbo-progs
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`oxipng` is packaged from Debian 13 / Ubuntu 24.04; on older releases install
|
|
123
|
+
it with `cargo install oxipng`. For `cjpegli`, Debian/Ubuntu package names
|
|
124
|
+
vary by release (`libjpegli-tools` where available); it is optional and
|
|
125
|
+
detected at runtime.
|
|
126
|
+
|
|
127
|
+
Arch:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
sudo pacman -S --needed libvips \
|
|
131
|
+
imagemagick jpegoptim pngquant oxipng libjpeg-turbo libjxl
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
(`libjpeg-turbo` provides `jpegtran`, `libjxl` provides `cjpegli`.)
|
|
135
|
+
|
|
136
|
+
### What each dependency is for
|
|
137
|
+
|
|
138
|
+
| Dependency | Kind | Needed for | Without it |
|
|
139
|
+
| --- | --- | --- | --- |
|
|
140
|
+
| `libvips` runtime library (`libvips.so.42`; Debian: `libvips42` ≥ 8.13) | required for `backend: :vips` | the fast path for every operation, bound via Fiddle | `configure!(backend: :vips)` raises at boot; configure `backend: :imagemagick` instead |
|
|
141
|
+
| ImageMagick (`magick`/`convert`, `identify`) | required for `backend: :imagemagick` | every operation on the `:imagemagick` backend | `configure!(backend: :imagemagick)` raises at boot |
|
|
142
|
+
| `jpegoptim` | required for JPEG `optimize` | lossless JPEG optimisation and metadata stripping | JPEG `optimize` raises in strict mode |
|
|
143
|
+
| `oxipng` | required for PNG `optimize` | lossless PNG optimisation | PNG `optimize` raises in strict mode |
|
|
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` for MCU-aligned JPEGs | falls back to the libvips re-encode tier |
|
|
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` | the atomic sandbox around every operation | `configure!(landlock: true)` raises at boot; `sandbox_available?` is false |
|
|
148
|
+
| `rexml` gem | automatic | SVG sanitising and SVG metadata | installed as a gem dependency |
|
|
149
|
+
|
|
150
|
+
The `landlock` gem is intentionally **not** a gem dependency; add it to the
|
|
151
|
+
host application's Gemfile if you want sandboxing.
|
|
152
|
+
|
|
153
|
+
### libvips build capabilities
|
|
154
|
+
|
|
155
|
+
Some features depend on how the host's libvips was built (all present in
|
|
156
|
+
stock Debian, Ubuntu and Arch packages):
|
|
157
|
+
|
|
158
|
+
- **libheif** — HEIC/AVIF decode (`probe`, `convert`, thumbnails)
|
|
159
|
+
- **Pango text** (`VipsText`) — `letter_avatar`; without it the operation
|
|
160
|
+
raises `UnsupportedFormatError` (configure `backend: :imagemagick` if you
|
|
161
|
+
need letter avatars on such a build)
|
|
162
|
+
- **cgif** (`gifsave`) — GIF *output* from the vips backend; GIF *decode*
|
|
163
|
+
(libnsgif) is always built in
|
|
164
|
+
- **libjxl** (`jxlload`/`jxlsave`) — JPEG XL decode and encode
|
|
165
|
+
|
|
166
|
+
Check a host with:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
vips -l | grep -cE "VipsForeignSaveCgif|VipsText|VipsForeignLoadHeif|VipsForeignLoadJxl" # expect 4+
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Fonts
|
|
173
|
+
|
|
174
|
+
Letter avatars need **no font packages** with the default `DejaVu-Sans`
|
|
175
|
+
token: the gem bundles DejaVu Sans and pins it via fontconfig's app-font API.
|
|
176
|
+
The other allowlisted tokens (`NimbusSans-Regular`, `Liberation-Sans`,
|
|
177
|
+
`Arial`, `Helvetica`, `Adwaita-Sans`) resolve through system fontconfig —
|
|
178
|
+
install e.g. `fonts-liberation` (Debian/Ubuntu) or `ttf-liberation` (Arch) if
|
|
179
|
+
you select them. Glyphs outside DejaVu's coverage (CJK, Hangul, ...) fall
|
|
180
|
+
back per-glyph to whatever system fonts exist.
|
|
181
|
+
|
|
182
|
+
### Checking a host
|
|
183
|
+
|
|
184
|
+
These probes work before `configure!`, so an application can inspect the host
|
|
185
|
+
and then make its configuration decision:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
require "safe_image"
|
|
189
|
+
|
|
190
|
+
%w[magick identify jpegoptim oxipng pngquant jpegtran cjpegli].each do |tool|
|
|
191
|
+
puts format("%-10s %s", tool, SafeImage::Runner.available?(tool) ? "ok" : "missing")
|
|
192
|
+
end
|
|
193
|
+
puts format("%-10s %s", "libvips", SafeImage::VipsGlue.available? ? "ok" : "unavailable")
|
|
194
|
+
puts format("%-10s %s", "sandbox", SafeImage.sandbox_available? ? "ok" : "unavailable")
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## API
|
|
198
|
+
|
|
199
|
+
All operations are module functions on `SafeImage` and run on the configured
|
|
200
|
+
backend. Operations that decode an image accept `max_pixels:` to override the
|
|
201
|
+
configured decompression-bomb ceiling for that one call.
|
|
202
|
+
|
|
203
|
+
### Return values
|
|
106
204
|
|
|
107
205
|
Image-producing operations return `SafeImage::Result`:
|
|
108
206
|
|
|
@@ -116,7 +214,7 @@ SafeImage::Result[
|
|
|
116
214
|
height:,
|
|
117
215
|
filesize:,
|
|
118
216
|
backend:, # e.g. "libvips-direct", "imagemagick", "cjpegli",
|
|
119
|
-
# "libvips-direct+cjpegli"
|
|
217
|
+
# "libvips-direct+cjpegli"
|
|
120
218
|
duration_ms:,
|
|
121
219
|
optimizer: # optimizer tool list for thumbnail path, otherwise nil
|
|
122
220
|
]
|
|
@@ -134,19 +232,29 @@ Optimizer operations return a hash:
|
|
|
134
232
|
}
|
|
135
233
|
```
|
|
136
234
|
|
|
137
|
-
|
|
235
|
+
### Probing and metadata
|
|
236
|
+
|
|
237
|
+
Metadata operations for local files. `type`, `size`/`dimensions`,
|
|
238
|
+
`orientation`, and `info` are intended to cover the local-file parts of APIs
|
|
239
|
+
like `FastImage.type`, `FastImage.size`, and `FastImage#orientation` without
|
|
240
|
+
adding a Ruby dependency. None of these fetch remote URLs — see
|
|
241
|
+
[Remote URLs](#remote-urls) for that.
|
|
138
242
|
|
|
139
|
-
|
|
243
|
+
#### `SafeImage.probe(path, max_pixels: nil)`
|
|
140
244
|
|
|
141
|
-
Reads image metadata through the
|
|
245
|
+
Reads image metadata through the configured backend.
|
|
142
246
|
|
|
143
|
-
Supported inputs:
|
|
247
|
+
Supported inputs on the `:vips` backend:
|
|
144
248
|
|
|
145
249
|
- `jpg` / `jpeg`
|
|
146
250
|
- `png`
|
|
251
|
+
- `gif` (first frame, via libvips' bundled libnsgif loader)
|
|
147
252
|
- `webp`
|
|
148
253
|
- `heic` / `heif`
|
|
149
254
|
- `avif`
|
|
255
|
+
- `jxl` (requires a libvips build with libjxl support)
|
|
256
|
+
- `ico` (pure-Ruby directory parse on either backend; reports the largest
|
|
257
|
+
entry's dimensions)
|
|
150
258
|
|
|
151
259
|
```ruby
|
|
152
260
|
info = SafeImage.probe("upload.jpg", max_pixels: 40_000_000)
|
|
@@ -155,72 +263,7 @@ puts "#{info.width}x#{info.height} #{info.input_format}"
|
|
|
155
263
|
|
|
156
264
|
Raises `SafeImage::LimitError` if `width * height > max_pixels`.
|
|
157
265
|
|
|
158
|
-
|
|
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)`
|
|
266
|
+
#### `SafeImage.type(path, max_pixels: nil)`
|
|
224
267
|
|
|
225
268
|
Returns a FastImage-style symbol for a local file:
|
|
226
269
|
|
|
@@ -233,7 +276,7 @@ SafeImage.type("icon.svg") # => :svg
|
|
|
233
276
|
JPEG is returned as `:jpeg`, not `:jpg`, to match common Ruby image-probing
|
|
234
277
|
conventions.
|
|
235
278
|
|
|
236
|
-
|
|
279
|
+
#### `SafeImage.size(path, max_pixels: nil)` / `SafeImage.dimensions(path, max_pixels: nil)`
|
|
237
280
|
|
|
238
281
|
Returns `[width, height]` for a local file:
|
|
239
282
|
|
|
@@ -248,16 +291,24 @@ limited to local `.svg` files, caps input size/tree depth/element/attribute
|
|
|
248
291
|
counts, rejects `DOCTYPE` and non-XML processing instructions, requires an `<svg>`
|
|
249
292
|
root, and derives dimensions from numeric `width`/`height` or `viewBox`.
|
|
250
293
|
|
|
251
|
-
|
|
294
|
+
#### `SafeImage.orientation(path, max_pixels: nil)`
|
|
295
|
+
|
|
296
|
+
Returns the EXIF orientation integer (1-8) for a local file. On the `:vips`
|
|
297
|
+
backend it is read from the orientation header field the native loaders
|
|
298
|
+
populate during the header scan — no pixel data is decoded — and garbage tag
|
|
299
|
+
values clamp to `1`. On the `:imagemagick` backend it comes from `identify`.
|
|
300
|
+
SVG and ICO report `1` by definition.
|
|
252
301
|
|
|
253
|
-
|
|
254
|
-
|
|
302
|
+
Note one deliberate HEIC difference: libheif applies the container's
|
|
303
|
+
`irot`/`imir` transforms during decode, so the native path reports the
|
|
304
|
+
orientation of the pixels as actually decoded, rather than echoing a raw
|
|
305
|
+
EXIF tag that may already be baked in.
|
|
255
306
|
|
|
256
307
|
```ruby
|
|
257
308
|
SafeImage.orientation("upload.jpg") # => 1
|
|
258
309
|
```
|
|
259
310
|
|
|
260
|
-
|
|
311
|
+
#### `SafeImage.info(path, max_pixels: nil, animated: false, orientation: false)`
|
|
261
312
|
|
|
262
313
|
Returns a `SafeImage::Info` object for a local file:
|
|
263
314
|
|
|
@@ -274,7 +325,45 @@ info.orientation # => 1
|
|
|
274
325
|
`animated:` and `orientation:` default to `false` because they may require extra
|
|
275
326
|
ImageMagick work. When disabled, those fields are `nil`.
|
|
276
327
|
|
|
277
|
-
|
|
328
|
+
#### `SafeImage.frame_count(path, max_pixels: nil)`
|
|
329
|
+
|
|
330
|
+
Returns the frame count. On the `:vips` backend it comes from the n-pages
|
|
331
|
+
header field via the native loaders — no pixel data is decoded; on the
|
|
332
|
+
`:imagemagick` backend from `identify`. ICO directories are counted by the
|
|
333
|
+
pure-Ruby parser on either backend.
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
frames = SafeImage.frame_count("animated.gif")
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
#### `SafeImage.animated?(path, max_pixels: nil)`
|
|
340
|
+
|
|
341
|
+
Returns `true` when `frame_count(path) > 1`.
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
SafeImage.animated?("animated.webp")
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
#### `SafeImage.dominant_color(path, max_pixels: nil)`
|
|
348
|
+
|
|
349
|
+
Computes the image's alpha-weighted average colour (first frame for animated
|
|
350
|
+
formats) and returns it as an uppercase `RRGGBB` hex string.
|
|
351
|
+
|
|
352
|
+
The `:vips` backend computes the exact per-channel mean natively, with ICO
|
|
353
|
+
decoded by the pure-Ruby parser. The `:imagemagick` backend uses a histogram
|
|
354
|
+
command. The two backends agree to within a few least-significant bits per
|
|
355
|
+
channel (ImageMagick averages through its resize filter rather than computing
|
|
356
|
+
the exact mean).
|
|
357
|
+
|
|
358
|
+
The pixel cap is enforced before the full decode on either backend,
|
|
359
|
+
undecodable input raises `InvalidImageError`, and SVG input raises
|
|
360
|
+
`UnsupportedFormatError`.
|
|
361
|
+
|
|
362
|
+
```ruby
|
|
363
|
+
SafeImage.dominant_color("upload.png") # => "6F745E"
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Remote URLs
|
|
278
367
|
|
|
279
368
|
These helpers are intended to cover `FastImage.size(url)` / `FastImage.type(url)`
|
|
280
369
|
style use cases without another Ruby dependency. They use only Ruby stdlib
|
|
@@ -316,7 +405,7 @@ or is intentionally probing a trusted internal URL. Passing `allow_private: true
|
|
|
316
405
|
also permits non-standard ports; for public fetches, pass `allowed_ports:` if you
|
|
317
406
|
really need to allow a different port.
|
|
318
407
|
|
|
319
|
-
|
|
408
|
+
#### `SafeImage.remote_size(url, ...)` / `SafeImage.remote_dimensions(url, ...)`
|
|
320
409
|
|
|
321
410
|
```ruby
|
|
322
411
|
SafeImage.remote_size(
|
|
@@ -328,14 +417,14 @@ SafeImage.remote_size(
|
|
|
328
417
|
# => [1600, 1200]
|
|
329
418
|
```
|
|
330
419
|
|
|
331
|
-
|
|
420
|
+
#### `SafeImage.remote_type(url, ...)`
|
|
332
421
|
|
|
333
422
|
```ruby
|
|
334
423
|
SafeImage.remote_type("https://example.com/image.png", max_bytes: 10.megabytes)
|
|
335
424
|
# => :png
|
|
336
425
|
```
|
|
337
426
|
|
|
338
|
-
|
|
427
|
+
#### `SafeImage.remote_info(url, ...)`
|
|
339
428
|
|
|
340
429
|
```ruby
|
|
341
430
|
info = SafeImage.remote_info(
|
|
@@ -348,14 +437,21 @@ info.size # => [640, 480]
|
|
|
348
437
|
info.animated # => true
|
|
349
438
|
```
|
|
350
439
|
|
|
351
|
-
|
|
440
|
+
#### `SafeImage.remote_animated?(url, ...)`
|
|
352
441
|
|
|
353
442
|
```ruby
|
|
354
443
|
SafeImage.remote_animated?("https://example.com/image.webp", max_bytes: 10.megabytes)
|
|
355
444
|
# => true / false
|
|
356
445
|
```
|
|
357
446
|
|
|
358
|
-
|
|
447
|
+
#### `SafeImage.remote_dominant_color(url, ...)`
|
|
448
|
+
|
|
449
|
+
```ruby
|
|
450
|
+
SafeImage.remote_dominant_color("https://example.com/image.png", max_bytes: 10.megabytes)
|
|
451
|
+
# => "6F745E"
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
#### `SafeImage.fetch_remote(url, ...) { |path| ... }`
|
|
359
455
|
|
|
360
456
|
Downloads a remote image to a tempfile and yields the local path:
|
|
361
457
|
|
|
@@ -365,121 +461,199 @@ SafeImage.fetch_remote("https://example.com/image.jpg", max_bytes: 10.megabytes)
|
|
|
365
461
|
end
|
|
366
462
|
```
|
|
367
463
|
|
|
368
|
-
|
|
369
|
-
Landlock worker because the worker denies network access. The downloaded
|
|
370
|
-
is then passed through the normal Safe Image local image APIs, so
|
|
371
|
-
uses the same sandboxed image-processing path.
|
|
464
|
+
With `landlock: true` configured, the network fetch itself is not put inside
|
|
465
|
+
the Landlock worker because the worker denies network access. The downloaded
|
|
466
|
+
tempfile is then passed through the normal Safe Image local image APIs, so
|
|
467
|
+
decoding still uses the same sandboxed image-processing path.
|
|
468
|
+
|
|
469
|
+
### Generating images
|
|
470
|
+
|
|
471
|
+
Operations that write a new image. All of them run on the configured backend
|
|
472
|
+
and return [`SafeImage::Result`](#return-values).
|
|
473
|
+
|
|
474
|
+
#### `SafeImage.thumbnail(...)`
|
|
475
|
+
|
|
476
|
+
Creates a center-cropped thumbnail.
|
|
477
|
+
|
|
478
|
+
```ruby
|
|
479
|
+
result = SafeImage.thumbnail(
|
|
480
|
+
input: "upload.jpg",
|
|
481
|
+
output: "thumb.jpg",
|
|
482
|
+
width: 600,
|
|
483
|
+
height: 400,
|
|
484
|
+
format: nil, # inferred from output extension when nil
|
|
485
|
+
quality: 85,
|
|
486
|
+
max_pixels: 40_000_000, # overrides the configured ceiling for this call
|
|
487
|
+
optimize: true,
|
|
488
|
+
optimize_mode: :lossless, # :lossless or :lossy for PNG optimisation
|
|
489
|
+
chroma_subsampling: :auto # :auto, "420", "422", "444" for JPEG output
|
|
490
|
+
)
|
|
491
|
+
```
|
|
372
492
|
|
|
373
|
-
|
|
493
|
+
Supported outputs on the `:vips` backend:
|
|
374
494
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
495
|
+
- `jpg` / `jpeg`
|
|
496
|
+
- `png`
|
|
497
|
+
- `gif` (requires a libvips build with cgif support; raises `UnsupportedFormatError` otherwise)
|
|
498
|
+
- `webp`
|
|
499
|
+
- `avif`
|
|
500
|
+
- `jxl` (requires a libvips build with libjxl support)
|
|
378
501
|
|
|
379
|
-
|
|
502
|
+
#### `SafeImage.resize(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
|
|
380
503
|
|
|
381
504
|
Creates a resized thumbnail-style output.
|
|
382
505
|
|
|
383
506
|
```ruby
|
|
384
507
|
SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400)
|
|
385
|
-
SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400,
|
|
508
|
+
SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400, quality: 85)
|
|
386
509
|
```
|
|
387
510
|
|
|
388
|
-
|
|
511
|
+
`resize`, `crop` and `downsize` run on the configured backend:
|
|
389
512
|
|
|
390
|
-
- `:
|
|
391
|
-
|
|
513
|
+
- `:vips` — the direct libvips path; formats outside the native loader
|
|
514
|
+
allowlist (ICO input) fail closed
|
|
515
|
+
- `:imagemagick` — matches classic `convert` thumbnail pipelines
|
|
516
|
+
(`-thumbnail`, catrom interpolation, unsharp, sRGB profile); configure this
|
|
517
|
+
if byte-similar output with previously generated thumbnails matters
|
|
392
518
|
|
|
393
|
-
|
|
519
|
+
#### `SafeImage.crop(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
|
|
394
520
|
|
|
395
|
-
Creates a north-cropped image
|
|
396
|
-
|
|
521
|
+
Creates a north-cropped image — the shape typically used for square avatar
|
|
522
|
+
crops.
|
|
397
523
|
|
|
398
524
|
```ruby
|
|
399
525
|
SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240)
|
|
400
|
-
SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240, backend: :vips)
|
|
401
526
|
```
|
|
402
527
|
|
|
403
|
-
|
|
528
|
+
#### `SafeImage.downsize(from, to, dimensions, optimize: true, max_pixels: nil, quality: 85, chroma_subsampling: :auto)`
|
|
404
529
|
|
|
405
530
|
Downsizes an image using ImageMagick-style geometry strings.
|
|
406
531
|
|
|
407
532
|
```ruby
|
|
408
533
|
SafeImage.downsize("large.png", "small.png", "50%")
|
|
409
|
-
SafeImage.downsize("large.png", "small.png", "100x100>"
|
|
410
|
-
SafeImage.downsize("large.png", "small.png", "400000@"
|
|
534
|
+
SafeImage.downsize("large.png", "small.png", "100x100>")
|
|
535
|
+
SafeImage.downsize("large.png", "small.png", "400000@")
|
|
411
536
|
```
|
|
412
537
|
|
|
413
|
-
The
|
|
538
|
+
The vips backend supports the geometry forms covered by the test suite:
|
|
414
539
|
percentage, bounding box with `>`, and pixel-area cap with `@`.
|
|
415
540
|
|
|
416
|
-
|
|
541
|
+
#### `SafeImage.convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)`
|
|
417
542
|
|
|
418
543
|
Converts an input image to an explicit output `format:`. Unsupported formats
|
|
419
544
|
raise `SafeImage::UnsupportedFormatError`.
|
|
420
545
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
546
|
+
On the `:vips` backend this decodes through the native libvips loaders,
|
|
547
|
+
auto-orients, flattens transparency onto white for JPEG targets (matching the
|
|
548
|
+
ImageMagick path's `-background white -flatten`), and re-encodes. When no
|
|
549
|
+
`quality:` is given, native JPEG output uses quality 92 — what ImageMagick
|
|
550
|
+
uses for sources without quality tables — rather than libvips' default 75.
|
|
551
|
+
ICO input/output is outside the native loaders and fails closed (use
|
|
552
|
+
`convert_favicon_to_png` for favicons, or the `:imagemagick` backend).
|
|
553
|
+
|
|
554
|
+
For PNG-to-JPEG on the `:vips` backend, `cjpegli` is used automatically when
|
|
555
|
+
installed (see [JPEG encoding of generated images](#jpeg-encoding-of-generated-images)).
|
|
426
556
|
|
|
427
557
|
```ruby
|
|
428
558
|
SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85)
|
|
429
|
-
SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85, encoder: :cjpegli)
|
|
430
559
|
SafeImage.convert("upload.heic", "upload.jpg", format: "jpg", quality: 85)
|
|
431
560
|
SafeImage.convert("upload.jpg", "upload.webp", format: "webp", quality: 85)
|
|
432
561
|
```
|
|
433
562
|
|
|
434
|
-
|
|
563
|
+
#### `SafeImage.fix_orientation(from, to = from, max_pixels: nil, quality: nil)`
|
|
435
564
|
|
|
436
|
-
|
|
437
|
-
|
|
565
|
+
Bakes the EXIF orientation into the pixels and clears the tag. The `:vips`
|
|
566
|
+
backend tries tiers in order:
|
|
438
567
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
568
|
+
1. **jpegtran (lossless)** — for JPEGs with `jpegtran` installed, the
|
|
569
|
+
transform happens on the DCT coefficients with zero generation loss.
|
|
570
|
+
`-perfect` refuses non-MCU-aligned dimensions, in which case:
|
|
571
|
+
2. **libvips re-encode** — autorotate and re-encode, stripping metadata.
|
|
572
|
+
JPEG output uses `quality:` (default 95).
|
|
443
573
|
|
|
444
|
-
|
|
574
|
+
The `:imagemagick` backend uses the previous `-auto-orient` behaviour and
|
|
575
|
+
re-encodes at the input's estimated quality.
|
|
445
576
|
|
|
446
|
-
|
|
577
|
+
If `to` is omitted, the file is rewritten in place.
|
|
447
578
|
|
|
448
579
|
```ruby
|
|
449
|
-
SafeImage.
|
|
580
|
+
SafeImage.fix_orientation("upload.jpg")
|
|
581
|
+
SafeImage.fix_orientation("upload.jpg", "oriented.jpg")
|
|
450
582
|
```
|
|
451
583
|
|
|
452
|
-
|
|
584
|
+
#### `SafeImage.convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)`
|
|
453
585
|
|
|
454
|
-
|
|
586
|
+
Extracts the largest ICO entry and writes PNG. On the `:vips` backend no
|
|
587
|
+
ImageMagick is involved: the container and legacy DIB payloads (1/4/8/24/32bpp
|
|
588
|
+
BI_RGB plus the AND mask) are parsed in pure Ruby with explicit bounds checks,
|
|
589
|
+
and pixels are encoded through the hardened native libvips path. Embedded PNG
|
|
590
|
+
payloads are re-encoded — never copied through verbatim — and their pixel cap
|
|
591
|
+
is enforced from the IHDR before any decoder runs. On the `:imagemagick`
|
|
592
|
+
backend the conversion runs through ImageMagick's ico decoder under the
|
|
593
|
+
bundled policy.
|
|
455
594
|
|
|
456
595
|
```ruby
|
|
457
|
-
|
|
596
|
+
SafeImage.convert_favicon_to_png("favicon.ico", "favicon.png")
|
|
458
597
|
```
|
|
459
598
|
|
|
460
|
-
|
|
599
|
+
#### `SafeImage.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")`
|
|
461
600
|
|
|
462
|
-
|
|
601
|
+
Generates a square letter avatar PNG: one grapheme blended in white at 80%
|
|
602
|
+
opacity over a solid background.
|
|
463
603
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
604
|
+
The `:vips` backend renders natively through libvips' Pango text support (the
|
|
605
|
+
glyph is markup-escaped before rendering) and fails closed on builds without
|
|
606
|
+
a text renderer; the `:imagemagick` backend uses ImageMagick's annotation
|
|
607
|
+
path.
|
|
467
608
|
|
|
468
|
-
|
|
609
|
+
The default `DejaVu-Sans` font uses the DejaVu Sans file bundled with the gem
|
|
610
|
+
(see `lib/safe_image/fonts/DEJAVU-LICENSE`), so rendering does not depend on
|
|
611
|
+
which fonts the host has installed. The other allowlisted tokens
|
|
612
|
+
(`NimbusSans-Regular`, `Liberation-Sans`, `Arial`, `Helvetica`,
|
|
613
|
+
`Adwaita-Sans`) resolve through fontconfig.
|
|
469
614
|
|
|
470
|
-
|
|
615
|
+
The native path centres the glyph's ink box optically, which differs from the
|
|
616
|
+
ImageMagick path's baseline placement (where descenders could clip at the
|
|
617
|
+
canvas edge). Treat switching backends as a visual change: regenerate cached
|
|
618
|
+
avatars.
|
|
471
619
|
|
|
472
620
|
```ruby
|
|
473
621
|
SafeImage.letter_avatar(
|
|
474
622
|
output: "avatar.png",
|
|
475
623
|
size: 360,
|
|
476
624
|
background_rgb: [1, 2, 3],
|
|
477
|
-
letter: "S"
|
|
478
|
-
font: "Adwaita-Sans"
|
|
625
|
+
letter: "S"
|
|
479
626
|
)
|
|
480
627
|
```
|
|
481
628
|
|
|
482
|
-
|
|
629
|
+
#### JPEG encoding of generated images
|
|
630
|
+
|
|
631
|
+
Safe Image separates **encoding generated JPEGs** from **optimising existing
|
|
632
|
+
JPEGs**. This avoids hiding a lossy re-encode behind a method named `optimize`.
|
|
633
|
+
|
|
634
|
+
Like the optimizer tools, the optional `cjpegli` encoder is availability
|
|
635
|
+
driven: installed means used, absent means the configured backend encodes.
|
|
636
|
+
There is no encoder knob — cjpegli only ever encodes pixels Safe Image has
|
|
637
|
+
already decoded, so it is not part of the untrusted-input surface the backend
|
|
638
|
+
choice controls.
|
|
639
|
+
|
|
640
|
+
| Operation | Behavior |
|
|
641
|
+
| --- | --- |
|
|
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 | use `cjpegli` when installed (PNG is the one input Jpegli encodes directly); otherwise libvips |
|
|
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 |
|
|
645
|
+
| any operation on the `:imagemagick` backend | ImageMagick encodes; `cjpegli` is never used |
|
|
646
|
+
| `optimize("existing.jpg")` | use `jpegoptim`; never `cjpegli` |
|
|
647
|
+
|
|
648
|
+
`cjpegli` output is ordinary browser-compatible JPEG. It is optional because it
|
|
649
|
+
is a system binary, not a Ruby dependency. Safe Image detects it at runtime.
|
|
650
|
+
|
|
651
|
+
`chroma_subsampling: :auto` uses `4:4:4` for PNG-sourced JPEG conversion and
|
|
652
|
+
`4:2:0` otherwise. Pass `"420"`, `"422"`, or `"444"` to force a value.
|
|
653
|
+
|
|
654
|
+
### Optimising in place
|
|
655
|
+
|
|
656
|
+
#### `SafeImage.optimize(path, mode: :lossless, strip_metadata: true, quality: nil, strict: true)`
|
|
483
657
|
|
|
484
658
|
Optimises an existing JPEG or PNG in place.
|
|
485
659
|
|
|
@@ -504,16 +678,9 @@ PNG path:
|
|
|
504
678
|
When `strict: true`, missing optimizer tools raise. When `strict: false`, missing
|
|
505
679
|
optimizer tools are tolerated.
|
|
506
680
|
|
|
507
|
-
###
|
|
508
|
-
|
|
509
|
-
Compatibility wrapper around `SafeImage.optimize`.
|
|
681
|
+
### SVG sanitising
|
|
510
682
|
|
|
511
|
-
|
|
512
|
-
SafeImage.optimize_image!("image.jpg")
|
|
513
|
-
SafeImage.optimize_image!("image.png", allow_lossy_png: true)
|
|
514
|
-
```
|
|
515
|
-
|
|
516
|
-
### `SafeImage.sanitize_svg!(path)`
|
|
683
|
+
#### `SafeImage.sanitize_svg!(path)`
|
|
517
684
|
|
|
518
685
|
Sanitises an SVG in place using a small REXML allowlist.
|
|
519
686
|
|
|
@@ -526,55 +693,90 @@ The sanitizer removes unsafe elements/attributes such as scripts and event
|
|
|
526
693
|
handlers. It is intentionally conservative rather than a full browser-grade SVG
|
|
527
694
|
implementation.
|
|
528
695
|
|
|
529
|
-
|
|
696
|
+
SVG sanitising is defense-in-depth for stored bytes. Applications that serve
|
|
697
|
+
user-supplied SVGs directly should still use response-level controls such as a
|
|
698
|
+
restrictive `Content-Security-Policy`, `X-Content-Type-Options: nosniff`, and/or
|
|
699
|
+
attachment/sandbox handling for direct-open routes. Browsers restrict script
|
|
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.
|
|
530
717
|
|
|
531
|
-
|
|
532
|
-
|
|
718
|
+
## Security
|
|
719
|
+
|
|
720
|
+
Safe Image is not magic pixie dust. It is a deliberately small choke point:
|
|
721
|
+
the goal is to centralize risky operations, make the safe path boring, and
|
|
533
722
|
remove common image-processing foot-guns.
|
|
534
723
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
-
|
|
541
|
-
-
|
|
542
|
-
|
|
543
|
-
- libvips
|
|
544
|
-
-
|
|
545
|
-
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
724
|
+
What it does:
|
|
725
|
+
|
|
726
|
+
- forces one explicit, eagerly validated `configure!` decision — which backend
|
|
727
|
+
decodes untrusted bytes, whether the Landlock sandbox is on — before any
|
|
728
|
+
operation runs; everything else raises `NotConfiguredError`
|
|
729
|
+
- uses explicit argv arrays for external commands, never shell strings
|
|
730
|
+
- starts external commands with an allowlisted environment, private temp/home/cache
|
|
731
|
+
directories, bounded stdout/stderr, and process-group timeout cleanup
|
|
732
|
+
- uses explicit libvips loaders selected from allowlisted extensions
|
|
733
|
+
- enables libvips' untrusted-operation block in-process (deliberately
|
|
734
|
+
re-enabling only the libjxl loader/saver, which libvips tags untrusted,
|
|
735
|
+
because JPEG XL is part of the supported input surface)
|
|
736
|
+
- blocks libvips ImageMagick loader classes in the libvips binding, which
|
|
737
|
+
itself exposes only the operations the gem invokes
|
|
738
|
+
- disables libvips cache by default in-process
|
|
739
|
+
- strips metadata on generated images where applicable
|
|
740
|
+
- rejects symlinked local input/output paths and symlinked path components for
|
|
741
|
+
untrusted file-processing paths
|
|
742
|
+
- caps decoded pixels before expensive work: the libvips path enforces a
|
|
743
|
+
default `SafeImage::DEFAULT_MAX_PIXELS` (128MP) ceiling even when no
|
|
744
|
+
`max_pixels` is given, and callers can raise or lower it per call
|
|
745
|
+
- ships a restrictive ImageMagick `policy.xml`
|
|
746
|
+
- denies Ghostscript-backed formats and dangerous ImageMagick features:
|
|
747
|
+
- `PS`, `PS2`, `PS3`, `EPS`, `EPSF`, `PDF`, `XPS`, `PCL`
|
|
748
|
+
- `MSL`, `MVG`
|
|
749
|
+
- `HTTP`, `HTTPS`, `URL`
|
|
750
|
+
- delegates, filters, and `@file` indirection
|
|
751
|
+
- hardens remote fetch against SSRF: scheme/port restrictions, special-use IP
|
|
549
752
|
blocking, DNS pinning, redirect limits, HTTPS-to-HTTP rejection, proxy-env
|
|
550
|
-
bypass prevention, request-header allowlists, content-type/extension
|
|
551
|
-
and probe-before-yield
|
|
552
|
-
- SVG metadata
|
|
553
|
-
|
|
554
|
-
|
|
753
|
+
bypass prevention, request-header allowlists, content-type/extension
|
|
754
|
+
agreement, and probe-before-yield (details under [Remote URLs](#remote-urls))
|
|
755
|
+
- parses SVG metadata with a bounded pure-Ruby parser; SVG is never handed to
|
|
756
|
+
ImageMagick for probing
|
|
757
|
+
- sanitises SVG conservatively, allowlist based: rejects `DOCTYPE` and XML
|
|
758
|
+
processing instructions, removes comments and disallowed elements, converts
|
|
555
759
|
CDATA to escaped text, and blocks event handlers, external URLs, and
|
|
556
760
|
`javascript:` / `data:` URL values
|
|
761
|
+
- supports optional Landlock subprocess sandboxing on Linux
|
|
557
762
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
execution when an SVG is embedded as `<img>`, but a top-level SVG document is a
|
|
563
|
-
different sink.
|
|
763
|
+
The backend is a configuration decision, not a per-call option. Safe Image
|
|
764
|
+
will never silently fall from the libvips path into generic ImageMagick
|
|
765
|
+
decoding — a format the configured backend cannot decode fails closed with
|
|
766
|
+
`SafeImage::UnsupportedFormatError`.
|
|
564
767
|
|
|
565
768
|
### Security posture without Landlock
|
|
566
769
|
|
|
567
|
-
Without Landlock,
|
|
770
|
+
Without Landlock, everything above still applies; in particular the
|
|
771
|
+
ImageMagick path runs with:
|
|
568
772
|
|
|
569
|
-
- delegates
|
|
570
|
-
- filters
|
|
571
|
-
- `@file` indirection
|
|
572
|
-
- remote URL coders
|
|
573
|
-
- Ghostscript/document/vector formats
|
|
574
|
-
- coders
|
|
575
|
-
-
|
|
576
|
-
- command environment and ImageMagick policy path are controlled
|
|
577
|
-
- ImageMagick resource limits are set in the bundled policy
|
|
773
|
+
- delegates disabled
|
|
774
|
+
- filters disabled
|
|
775
|
+
- `@file` indirection disabled
|
|
776
|
+
- remote URL coders disabled
|
|
777
|
+
- Ghostscript/document/vector formats denied
|
|
778
|
+
- coders deny-by-default with a small raster allowlist
|
|
779
|
+
- ImageMagick resource limits set in the bundled policy
|
|
578
780
|
|
|
579
781
|
That does **not** make hostile files benign. Raster decoders still parse attacker
|
|
580
782
|
controlled bytes: libjpeg, libpng, libwebp, libheif/HEIC/AVIF, ImageMagick's
|
|
@@ -592,18 +794,18 @@ So the intended posture is:
|
|
|
592
794
|
Do not describe non-sandboxed operation as making hostile images safe. The honest
|
|
593
795
|
claim is defense-in-depth, not immunity.
|
|
594
796
|
|
|
595
|
-
|
|
797
|
+
### Atomic Landlock sandboxing
|
|
596
798
|
|
|
597
|
-
Landlock support is optional, but atomic once
|
|
799
|
+
Landlock support is optional, but atomic once configured.
|
|
598
800
|
|
|
599
801
|
```ruby
|
|
600
|
-
SafeImage.sandbox_available?
|
|
601
|
-
SafeImage.
|
|
602
|
-
SafeImage.
|
|
802
|
+
SafeImage.sandbox_available? # => true/false, works before configure!
|
|
803
|
+
SafeImage.configure!(backend: :vips, landlock: true) # raises if unavailable
|
|
804
|
+
SafeImage.config.landlock # => true
|
|
603
805
|
```
|
|
604
806
|
|
|
605
|
-
|
|
606
|
-
|
|
807
|
+
With `landlock: true`, every public operation routes through the sandbox
|
|
808
|
+
worker:
|
|
607
809
|
|
|
608
810
|
- `probe`
|
|
609
811
|
- `type`
|
|
@@ -611,6 +813,7 @@ sandbox worker:
|
|
|
611
813
|
- `dimensions`
|
|
612
814
|
- `info`
|
|
613
815
|
- `orientation`
|
|
816
|
+
- `dominant_color`
|
|
614
817
|
- `thumbnail`
|
|
615
818
|
- `optimize`
|
|
616
819
|
- `resize`
|
|
@@ -626,39 +829,22 @@ sandbox worker:
|
|
|
626
829
|
- `optimize_image!`
|
|
627
830
|
- `sanitize_svg!`
|
|
628
831
|
|
|
629
|
-
There is no silent fallback
|
|
832
|
+
There is no silent fallback once landlock is configured. If sandbox setup or
|
|
630
833
|
a sandboxed command fails, the operation fails.
|
|
631
834
|
|
|
632
835
|
The sandbox grants read/write access only to the paths inferred from the
|
|
633
836
|
operation arguments, plus runtime/library paths and temporary directories needed
|
|
634
837
|
by Ruby, libvips, ImageMagick, and optimizer tools. Network syscalls are denied
|
|
635
|
-
through the Landlock helper's seccomp layer.
|
|
636
|
-
|
|
637
|
-
|
|
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.
|
|
838
|
+
through the Landlock helper's seccomp layer. Worker processes inherit the
|
|
839
|
+
parent's backend and pixel-ceiling configuration; landlock is forced off
|
|
840
|
+
inside the worker so sandboxed operations never nest.
|
|
656
841
|
|
|
657
842
|
## Development
|
|
658
843
|
|
|
659
844
|
```bash
|
|
660
845
|
bundle install
|
|
661
|
-
bundle exec rake #
|
|
846
|
+
bundle exec rake # run the tests (nothing compiles)
|
|
847
|
+
docker/run.sh # run the suite on Debian bookworm's packaged libvips 8.14
|
|
662
848
|
bundle exec rubocop # lint
|
|
663
849
|
```
|
|
664
850
|
|
|
@@ -667,10 +853,14 @@ The minitest suite lives in `test/*_test.rb`; individual files run standalone
|
|
|
667
853
|
host support — cjpegli, HEIC delegates, the Landlock sandbox — skip with an
|
|
668
854
|
explanation when that support is missing.
|
|
669
855
|
|
|
856
|
+
The suite includes golden-output checks, cross-backend parity checks (the
|
|
857
|
+
claim is operation parity, not byte-for-byte output identity across
|
|
858
|
+
ImageMagick/libvips versions), policy-denial checks, and a real-image atomic
|
|
859
|
+
sandbox sweep over the full public operation list.
|
|
860
|
+
|
|
670
861
|
Gem packaging uses the standard Bundler tasks (`rake build`, `rake install`).
|
|
671
|
-
Releases
|
|
672
|
-
|
|
673
|
-
RubyGems once the test matrix is green.
|
|
862
|
+
Releases: bump `SafeImage::VERSION`, merge to `main`, and CI publishes the
|
|
863
|
+
gem to RubyGems once the test matrix is green.
|
|
674
864
|
|
|
675
865
|
## Security reporting
|
|
676
866
|
|
|
@@ -686,13 +876,17 @@ Safe Image is MIT licensed.
|
|
|
686
876
|
The gem dynamically links to system `libvips`; `libvips` is
|
|
687
877
|
LGPL-2.1-or-later. Safe Image does not vendor `libvips`.
|
|
688
878
|
|
|
879
|
+
The gem bundles the DejaVu Sans font for deterministic letter-avatar
|
|
880
|
+
rendering; its license (Bitstream Vera derivative, freely redistributable)
|
|
881
|
+
ships alongside the font at `lib/safe_image/fonts/DEJAVU-LICENSE`.
|
|
882
|
+
|
|
689
883
|
Optional command-line tools are discovered at runtime and executed as external
|
|
690
884
|
programs; they are not bundled into the gem. Typical licenses for those optional
|
|
691
885
|
tools are:
|
|
692
886
|
|
|
693
887
|
| Tool | Purpose | Typical license |
|
|
694
888
|
| --- | --- | --- |
|
|
695
|
-
| ImageMagick `magick` / `convert` / `identify` |
|
|
889
|
+
| ImageMagick `magick` / `convert` / `identify` | `:imagemagick` backend operations | ImageMagick license |
|
|
696
890
|
| `jpegoptim` | JPEG lossless optimisation / metadata stripping | GPL-2.0-or-later |
|
|
697
891
|
| `oxipng` | PNG lossless optimisation | MIT |
|
|
698
892
|
| `pngquant` | optional lossy PNG quantisation | GPL-3.0-or-later / ISC / BSD-2-Clause components |
|
|
@@ -700,4 +894,4 @@ tools are:
|
|
|
700
894
|
| `heif-enc` / libheif tools | optional HEIC/AVIF tooling when installed | LGPL-3.0-or-later |
|
|
701
895
|
|
|
702
896
|
Deployment packages may vary; check your distribution's package metadata if
|
|
703
|
-
license compliance depends on the exact binary build.
|
|
897
|
+
license compliance depends on the exact binary build.
|