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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +703 -0
- data/SECURITY.md +48 -0
- data/ext/safe_image_native/extconf.rb +8 -0
- data/ext/safe_image_native/safe_image_native.c +392 -0
- data/lib/safe_image/RT_sRGB.icm +0 -0
- data/lib/safe_image/discourse_compat.rb +283 -0
- data/lib/safe_image/image_magick_backend.rb +263 -0
- data/lib/safe_image/imagemagick_policy/policy.xml +22 -0
- data/lib/safe_image/jpegli_backend.rb +109 -0
- data/lib/safe_image/native.rb +3 -0
- data/lib/safe_image/optimizer.rb +78 -0
- data/lib/safe_image/path_safety.rb +63 -0
- data/lib/safe_image/processor.rb +196 -0
- data/lib/safe_image/remote.rb +309 -0
- data/lib/safe_image/result.rb +28 -0
- data/lib/safe_image/runner.rb +174 -0
- data/lib/safe_image/sandbox.rb +236 -0
- data/lib/safe_image/svg_metadata.rb +132 -0
- data/lib/safe_image/svg_sanitizer.rb +102 -0
- data/lib/safe_image/version.rb +5 -0
- data/lib/safe_image/vips_backend.rb +50 -0
- data/lib/safe_image.rb +272 -0
- metadata +140 -0
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.
|