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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1f73bfd1e95c6fbb93ad43254cbff0b2d14cf1d667b5af58daf3e7f2311e3240
|
|
4
|
+
data.tar.gz: 598bc5111672e82b922bfc134c168488ad9348b2b85cf5d6f3ac6d12c959ddfc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3dd059f65b1f5d5ad43290aaf3e654817eca8d5127f90717cbf91739c31b92c9674c5fcc73c5b61a5b2be724c5b8053e298a35f6063cd880959a3ccfb9916c8
|
|
7
|
+
data.tar.gz: 5eeb7da08494d4e76f6ba5e9aa6479b083c8e1cd8f40fa7779537c00884a0e5cb068714b6fbf6ece00fc82fcdc39919c458c2fadcc4740b1348786a88871fb7a
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,211 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.0 - 2026-06-18]
|
|
9
|
+
|
|
10
|
+
### Removed (breaking)
|
|
11
|
+
|
|
12
|
+
- Removed `SafeImage.sanitize_svg!` and the internal SVG/CSS sanitizer
|
|
13
|
+
implementation. Safe Image still supports bounded SVG metadata probing for
|
|
14
|
+
local and remote `.svg` files, but applications that need to clean or inline
|
|
15
|
+
user-supplied SVG content must use a dedicated sanitizer outside this gem.
|
|
16
|
+
- Removed the compatibility/in-place file-writing APIs. `resize`, `crop`,
|
|
17
|
+
`downsize`, `convert`, `fix_orientation`, `convert_favicon_to_png`, and
|
|
18
|
+
`optimize` now require explicit, distinct `input:` and `output:` paths;
|
|
19
|
+
`optimize_image!` and `convert_to_jpeg` are gone.
|
|
20
|
+
|
|
21
|
+
## [0.3.0 - 2026-06-12]
|
|
22
|
+
|
|
23
|
+
### Changed (breaking)
|
|
24
|
+
|
|
25
|
+
- **`sanitize_svg!` now requires `id_namespace:`.** The argument forces a
|
|
26
|
+
deliberate choice of where the output may be used, removing the footgun of a
|
|
27
|
+
silently-wrong default. Pass `:standalone` for output served only as an
|
|
28
|
+
external `<img>`/CSS-url/file, or a stable per-document String to make it safe
|
|
29
|
+
to inline (see below). Omitting it (or passing `nil`/`""`) raises
|
|
30
|
+
`ArgumentError`. Callers must update: `sanitize_svg!(path)` →
|
|
31
|
+
`sanitize_svg!(path, id_namespace: :standalone)`.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **`sanitize_svg!` can produce output safe to inline into an HTML DOM.** Pass
|
|
36
|
+
`id_namespace:` a stable per-document value (e.g. the upload sha) and the
|
|
37
|
+
sanitizer prefixes every `id` and every reference to it (`href`/`xlink:href`
|
|
38
|
+
fragments, `url(#…)` in attributes and CSS, ARIA IDREF attributes like
|
|
39
|
+
`aria-labelledby`/`aria-controls`, and every `class` token plus the matching
|
|
40
|
+
`.class` selectors) with the namespace, and scopes every `<style>` selector
|
|
41
|
+
under a `<ns>-scope` class added to the root `<svg>`. Namespacing classes stops
|
|
42
|
+
an inlined SVG from invoking the host page's framework CSS (a bare
|
|
43
|
+
`class="modal fixed"` overlay vector). `var()`/`env()`/`attr()` in presentation
|
|
44
|
+
attributes are rejected outright — they resolve against the host page.
|
|
45
|
+
Inlined into a page, the preserved `<style>` can no longer reach the host
|
|
46
|
+
cascade (`*{visibility:hidden}`, `#header{display:none}`) and ids cannot
|
|
47
|
+
clobber host ids — including references written `URL(#x)`, `url('#x')`, or
|
|
48
|
+
`url("#x")`, which are namespaced like the unquoted form. In this mode the root
|
|
49
|
+
`<svg>`'s `overflow` is also dropped so it clips to its declared viewport (a
|
|
50
|
+
tiny viewport with `overflow:visible` and oversized content would otherwise
|
|
51
|
+
paint a full-page overlay). With `id_namespace: :standalone` the output is the
|
|
52
|
+
document-safe form (no namespacing). The namespace must be a valid ident (a
|
|
53
|
+
letter followed by letters/digits/`_`/`-`); malformed tokens are rejected
|
|
54
|
+
rather than coerced, so two distinct values can never collapse to one. The
|
|
55
|
+
transform is idempotent per namespace. `style=""` attributes are
|
|
56
|
+
element-scoped and inline-safe either way.
|
|
57
|
+
- **`<style>` elements now fail closed on any at-rule.** Previously an at-rule
|
|
58
|
+
block followed by a valid rule (`@font-face{…}.ok{…}`) could keep the trailing
|
|
59
|
+
rule; a stylesheet containing `@` anywhere is now rejected whole, matching the
|
|
60
|
+
documented guarantee.
|
|
61
|
+
- **The SVG sanitizer keeps a safe CSS subset instead of stripping all CSS.**
|
|
62
|
+
`style` attributes (as written by Inkscape) and `<style>` elements (as
|
|
63
|
+
written by Illustrator) now survive sanitisation when they parse against a
|
|
64
|
+
constructed allowlist grammar: properties mirroring the allowed
|
|
65
|
+
presentation attributes, type/class/id selectors, numeric/keyword/color
|
|
66
|
+
values, and `url(#fragment)` references only. Output is reassembled from
|
|
67
|
+
validated tokens — CSS escapes, quotes/strings, at-rules, comments, and
|
|
68
|
+
unknown properties, functions, or selectors drop the declaration, rule, or
|
|
69
|
+
whole stylesheet rather than being interpreted. A single at-rule or nested
|
|
70
|
+
block fails the whole `<style>` element closed. `!important` and modern
|
|
71
|
+
`rgb()/hsl()` slash-alpha (`rgb(R G B / A)`) are preserved; both are parsed
|
|
72
|
+
structurally and re-emitted, and admitting `/` for the alpha keeps CSS
|
|
73
|
+
comments impossible because `*` remains excluded from the value charset.
|
|
74
|
+
- **The presentation-attribute allowlist covers common editor output.** Added
|
|
75
|
+
the safe, widely-emitted SVG presentation properties (and their CSS twins):
|
|
76
|
+
`stroke-dasharray`/`stroke-dashoffset`, `vector-effect`, `marker`/`marker-*`
|
|
77
|
+
(with the `<marker>` element and its geometry attributes), `color`,
|
|
78
|
+
`display`/`visibility`/`overflow`, `paint-order`/`mix-blend-mode`/`isolation`,
|
|
79
|
+
the `*-rendering` hints, and the longhand text properties (`font-style`,
|
|
80
|
+
`font-variant`, `font-stretch`, `text-decoration`, `letter-spacing`,
|
|
81
|
+
`word-spacing`, `dominant-baseline`, `baseline-shift`, `writing-mode`,
|
|
82
|
+
`direction`). The only additions carrying a URL — `marker*` — are constrained
|
|
83
|
+
to `url(#fragment)` like the existing paint and clip/mask references. Filters
|
|
84
|
+
remain out of scope.
|
|
85
|
+
|
|
86
|
+
### Changed
|
|
87
|
+
|
|
88
|
+
- **Sandboxed operations are served by a pool of resident zygote workers —
|
|
89
|
+
warm Landlock overhead drops from ~100ms to ~3–8ms per operation, with
|
|
90
|
+
near-linear concurrency.** Previously every sandboxed call exec'd a fresh
|
|
91
|
+
Ruby that re-paid interpreter boot, rubygems, the gem's requires, and
|
|
92
|
+
libvips init. A zygote boots that once, then forks a child per operation;
|
|
93
|
+
the child applies rlimits, its per-operation Landlock policy (filesystem
|
|
94
|
+
allowlist, all TCP denied on ABI ≥ 4, abstract-unix-socket/signal scopes on
|
|
95
|
+
ABI ≥ 6), and — when the installed `landlock` gem exposes
|
|
96
|
+
`seccomp_deny_network!` — the helper's deny-all-network seccomp filter
|
|
97
|
+
(blocking sockets of every family), *before* touching untrusted input, and
|
|
98
|
+
exits after the operation. Workers are pooled so N threads run N sandboxed
|
|
99
|
+
operations at once (the single zygote would serialise them): the pool grows
|
|
100
|
+
on demand to `SAFE_IMAGE_ZYGOTE_WORKERS` (default 8) and offered concurrency
|
|
101
|
+
past the cap blocks until a worker frees, bounding concurrent libvips
|
|
102
|
+
memory. An idle worker exits after `Zygote::IDLE_SECONDS` (300, overridable
|
|
103
|
+
via `SAFE_IMAGE_ZYGOTE_IDLE_SECONDS`; idling holds ~16MB private memory and
|
|
104
|
+
no CPU), exits immediately when its parent process does, and `configure!`
|
|
105
|
+
always retires the pool, so no stale process outlives its parent or a
|
|
106
|
+
reconfigure. Forking is sound because the zygote never runs operations
|
|
107
|
+
itself: libvips is initialised but quiescent (zero native threads) at every
|
|
108
|
+
fork — verified empirically. Outputs are byte-identical to both the exec
|
|
109
|
+
worker and unsandboxed operation. `SAFE_IMAGE_ZYGOTE=0` falls back to the
|
|
110
|
+
exec-per-operation worker. Pool correctness under concurrent reconfigure,
|
|
111
|
+
worker death, and fork is enforced by a per-worker generation token (a
|
|
112
|
+
worker checked out when `configure!` lands is retired on return, never
|
|
113
|
+
reused under stale config), channel-health (not process-liveness) reuse
|
|
114
|
+
decisions (a worker whose pipe broke is discarded, never re-pooled), a
|
|
115
|
+
`PR_SET_PDEATHSIG` so an operation child dies with its zygote, and
|
|
116
|
+
parent-side tmp-root cleanup that survives a SIGKILLed worker — all
|
|
117
|
+
exercised by a multithreaded chaos stress test (reconfigure + worker kills)
|
|
118
|
+
asserting no wrong output, no process/fd/tmpdir leak, and no deadlock.
|
|
119
|
+
|
|
120
|
+
- **External tools run with `MALLOC_ARENA_MAX=2`.** Multithreaded optimizer
|
|
121
|
+
tools (oxipng's rayon pool, ImageMagick's OpenMP) otherwise have glibc
|
|
122
|
+
reserve a 64MB malloc arena per thread; combined with the sandbox's
|
|
123
|
+
`RLIMIT_AS` memory cap, that *address-space* reservation spuriously fails
|
|
124
|
+
the tool under concurrency even though real memory use is tiny. Bounding the
|
|
125
|
+
arena count is the standard mitigation and is free for these compute-bound
|
|
126
|
+
tools. Found by stress-testing concurrent sandboxed `convert`.
|
|
127
|
+
|
|
128
|
+
- **rexml loads on first SVG use instead of at `require "safe_image"`.**
|
|
129
|
+
rexml costs ~27ms to parse and only the SVG paths need it, so every boot of
|
|
130
|
+
the gem — and in particular every Landlock sandbox worker, which is a fresh
|
|
131
|
+
Ruby process per operation — was paying it on operations that never touch
|
|
132
|
+
SVG. Measured per-op sandbox cost drops from ~102ms to ~85ms on the vips
|
|
133
|
+
backend and ~75ms to ~58ms on the imagemagick backend.
|
|
134
|
+
|
|
135
|
+
- **Remote fetches reject bad responses from the headers alone.** The
|
|
136
|
+
`Content-Type` allowlist and content-type/extension agreement checks now run
|
|
137
|
+
before any body bytes are read (previously the body was downloaded first and
|
|
138
|
+
rejected afterwards), and the first bytes of the body must be compatible
|
|
139
|
+
with the claimed format's magic bytes — an obviously mislabeled body is
|
|
140
|
+
dropped after the first chunk instead of being downloaded to `max_bytes`.
|
|
141
|
+
- **Remote metadata helpers download only what the answer needs.**
|
|
142
|
+
`remote_size`, `remote_type`, `remote_info` and `remote_animated?` now probe
|
|
143
|
+
the partially-downloaded file at growing thresholds (64KB, 256KB, ...) and
|
|
144
|
+
abort the transfer once the answer is final, instead of always downloading
|
|
145
|
+
up to `max_bytes`. Early answers are only trusted when more data cannot
|
|
146
|
+
change them: "not animated" still requires the complete file (truncated
|
|
147
|
+
animations undercount frames), SVG metadata still downloads the whole
|
|
148
|
+
document so the SVG size cap keeps its meaning, and any prefix probe
|
|
149
|
+
failure falls back to the full download with unchanged validation and
|
|
150
|
+
error behaviour. `fetch_remote` and `remote_dominant_color` still download
|
|
151
|
+
the complete body.
|
|
152
|
+
|
|
153
|
+
### Fixed
|
|
154
|
+
|
|
155
|
+
- **Lossy PNG optimisation no longer fails when pngquant declines to
|
|
156
|
+
quantise.** pngquant signals "quantised result not used" through exit
|
|
157
|
+
status 98 (`--skip-if-larger`) and 99 (`--quality` not met) — for example
|
|
158
|
+
on low-bit-depth grayscale PNGs its RGBA-palette output cannot beat —
|
|
159
|
+
and `optimize(mode: :lossy)` raised `CommandError` instead of keeping the
|
|
160
|
+
original and continuing to oxipng. Found by running 10 random Wikimedia
|
|
161
|
+
Commons images through the optimizer.
|
|
162
|
+
|
|
163
|
+
- **`optimize` no longer ships sideways JPEGs.** With `strip_metadata: true`
|
|
164
|
+
(the default), stripping deleted the EXIF orientation tag without applying
|
|
165
|
+
the rotation, so an oriented camera photo came out rendered 90/180° wrong.
|
|
166
|
+
`optimize` now bakes the rotation into the pixels first via jpegtran's
|
|
167
|
+
lossless transforms: `-perfect` when the dimensions are MCU-aligned, else
|
|
168
|
+
`-trim`, which drops the partial edge blocks (under one MCU, at most 15px)
|
|
169
|
+
instead of hiding a lossy re-encode. The result hash gains `rotated_from:`
|
|
170
|
+
and `trimmed:` so the trim is reported, never silent — image_optim's jhead
|
|
171
|
+
worker (Discourse's `FileHelper.optimize_image!`) does the same transform
|
|
172
|
+
but trims silently. Without jpegtran an oriented JPEG raises in strict mode
|
|
173
|
+
and is left untouched otherwise; reading the tag goes through the configured
|
|
174
|
+
backend, so `optimize` now also enforces the pixel cap before touching an
|
|
175
|
+
oriented JPEG. Internal callers optimising output the gem just encoded skip
|
|
176
|
+
the check (`assume_upright:`).
|
|
177
|
+
|
|
178
|
+
- **JPEGs with an EXIF orientation no longer fail on the libvips path once
|
|
179
|
+
they outgrow the sequential readahead window (~512px — every real camera
|
|
180
|
+
photo).** `resize`, `crop`, `convert`/`convert_to_jpeg` and the
|
|
181
|
+
`fix_orientation` re-encode tier loaded input with `access: sequential` and
|
|
182
|
+
then autorotated, and the rotation's out-of-order row reads raised
|
|
183
|
+
`VipsJpeg: out of order read`. Oriented images are now reloaded with random
|
|
184
|
+
access before autorotation; upright images keep the streaming sequential
|
|
185
|
+
load, and the pixel cap still runs before any decode.
|
|
186
|
+
|
|
187
|
+
### Security
|
|
188
|
+
|
|
189
|
+
- **Presentation-attribute `url()` references fail closed unless they are a
|
|
190
|
+
canonical same-document fragment.** A single validation/rewrite grammar now
|
|
191
|
+
governs both the keep decision and the namespace rewrite, so external URLs,
|
|
192
|
+
mismatched quotes, and unterminated forms (`url(#id`, `url(http://evil`) are
|
|
193
|
+
dropped rather than kept on browser parse-error leniency — and no bare,
|
|
194
|
+
un-namespaced reference can survive in inline (`id_namespace:` String) output.
|
|
195
|
+
- **Attribute values containing CSS escapes are rejected outright.**
|
|
196
|
+
Browsers feed SVG presentation attributes through their CSS value parsers,
|
|
197
|
+
where an escape can re-form a token after the sanitizer's pattern checks
|
|
198
|
+
(`ur\6c(...)` is `url(...)`). No allowlisted attribute legitimately
|
|
199
|
+
contains a backslash, so any attribute value with one is now dropped.
|
|
200
|
+
- **SVG parsing rejects encodings the byte-level guards cannot see through.**
|
|
201
|
+
The DOCTYPE/processing-instruction guards are ASCII byte scans; a UTF-16
|
|
202
|
+
document interleaves NUL bytes between the ASCII characters, so a
|
|
203
|
+
`<!DOCTYPE` (and an entity payload behind it) could slip past them while
|
|
204
|
+
REXML still decoded and honoured it. SVG documents must now be UTF-8 (BOM
|
|
205
|
+
allowed) or declare a single-byte ASCII-transparent charset (US-ASCII,
|
|
206
|
+
ISO-8859-*, Windows-125x): UTF-16/32 BOMs, embedded NUL bytes, and declared
|
|
207
|
+
multi-byte or transforming encodings (Shift_JIS, GBK, EUC-*, ISO-2022-*,
|
|
208
|
+
UTF-7) raise `InvalidImageError`. Declared names that fit the allowed shape
|
|
209
|
+
but resolve to no real encoding (e.g. `utf8`, `windows-1259`) also fail
|
|
210
|
+
closed as `InvalidImageError` instead of surfacing REXML's bare
|
|
211
|
+
`ArgumentError`.
|
|
212
|
+
|
|
8
213
|
## [0.2.0] - 2026-06-10
|
|
9
214
|
|
|
10
215
|
The host's whole image-processing posture is now decided in one place, once,
|
|
@@ -91,18 +296,15 @@ between backends.
|
|
|
91
296
|
- Graceful operation without libvips: the new `SafeImage::VipsUnavailableError`
|
|
92
297
|
(a subclass of `UnsupportedFormatError`) makes `backend: :auto` route through
|
|
93
298
|
ImageMagick on hosts without the library, while explicit `backend: :vips`
|
|
94
|
-
calls fail closed. `SafeImage::
|
|
299
|
+
calls fail closed. `SafeImage::Native.available?` reports the state.
|
|
95
300
|
- `docker/run.sh`: containerised validation against Debian bookworm's packaged
|
|
96
|
-
libvips 8.14
|
|
301
|
+
libvips 8.14 and native-helper build/install.
|
|
97
302
|
|
|
98
303
|
### Changed
|
|
99
304
|
|
|
100
|
-
- **The compiled C
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
only gem dependencies are `fiddle` and `rexml`. Minimum libvips is 8.13
|
|
104
|
-
(Debian bookworm's 8.14 package is tested); `SAFE_IMAGE_LIBVIPS` overrides
|
|
105
|
-
the library name.
|
|
305
|
+
- **The compiled C helper owns libvips execution.** libvips is initialized in
|
|
306
|
+
the bundled helper process, which exposes only the operations the gem invokes.
|
|
307
|
+
Minimum libvips is 8.13 (Debian bookworm's 8.14 package is tested).
|
|
106
308
|
- All transform defaults are now native-first: `resize`, `crop`, `downsize`,
|
|
107
309
|
`convert` and `thumbnail` default to `backend: :auto` (previously
|
|
108
310
|
ImageMagick for the first three and `:vips` fail-closed for `thumbnail`).
|
|
@@ -144,8 +346,8 @@ between backends.
|
|
|
144
346
|
untrusted-operation block: JPEG XL is part of the supported input surface,
|
|
145
347
|
and inputs still pass extension routing, pixel caps and (optionally) the
|
|
146
348
|
Landlock sandbox.
|
|
147
|
-
- The
|
|
148
|
-
guards
|
|
349
|
+
- The native helper doubles as an operation allowlist, and a leak-loop test
|
|
350
|
+
guards helper request handling.
|
|
149
351
|
|
|
150
352
|
## [0.1.0] - 2026-06-09
|
|
151
353
|
|