safe_image 0.3.0 → 0.5.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 +61 -12
- data/README.md +140 -287
- 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 +42 -40
- 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 -290
- 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 +225 -98
- 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 +248 -144
- data/lib/safe_image/result.rb +71 -23
- data/lib/safe_image/runner.rb +60 -23
- data/lib/safe_image/sandbox.rb +44 -218
- data/lib/safe_image/staged_output.rb +44 -0
- data/lib/safe_image/svg_metadata.rb +74 -69
- 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 -306
- metadata +43 -37
- data/lib/safe_image/discourse_compat.rb +0 -441
- data/lib/safe_image/svg_css.rb +0 -314
- data/lib/safe_image/svg_sanitizer.rb +0 -583
- data/lib/safe_image/vips_glue.rb +0 -361
- data/lib/safe_image/zygote.rb +0 -619
data/SECURITY.md
CHANGED
|
@@ -16,18 +16,19 @@ Safe Image assumes image input may be attacker-controlled. The library is design
|
|
|
16
16
|
- explicit libvips loader selection for supported raster formats, with
|
|
17
17
|
libvips' untrusted-operation block enabled and the ImageMagick loader
|
|
18
18
|
classes blocked by name
|
|
19
|
-
- a
|
|
20
|
-
|
|
19
|
+
- a compiled libvips helper process that exposes only the specific operations
|
|
20
|
+
the gem invokes — there is no generic operation access and the Ruby process
|
|
21
|
+
never loads libvips
|
|
21
22
|
- no silent fallback from libvips to generic ImageMagick decoding; the
|
|
22
23
|
backend is a single explicit `SafeImage.configure!` decision and formats it
|
|
23
24
|
cannot decode fail closed
|
|
24
25
|
- decompression-bomb ceilings enforced from container/header metadata before
|
|
25
26
|
any pixel decode (128MP default, plus dedicated SVG and ICO caps)
|
|
26
27
|
- restrictive ImageMagick policy disabling delegates, filters, `@file`, remote URL coders, Ghostscript-backed formats, and dangerous pseudo-formats
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
- bounded SVG metadata probing (Nokogiri SAX with explicit byte/structure caps,
|
|
29
|
+
not Landlock-contained by Safe Image) and pure-Ruby ICO directory/DIB parsing;
|
|
30
|
+
extracted ICO pixels are re-encoded through libvips and embedded payload bytes
|
|
31
|
+
are never copied through verbatim
|
|
31
32
|
- letter avatar text rendering escapes the user-derived glyph before Pango
|
|
32
33
|
markup parsing, and fonts come from an allowlist (the default font is
|
|
33
34
|
bundled with the gem)
|
|
@@ -38,19 +39,29 @@ Safe Image assumes image input may be attacker-controlled. The library is design
|
|
|
38
39
|
One deliberate exception to libvips' untrusted-operation block: the libjxl
|
|
39
40
|
loader and saver are re-enabled because JPEG XL is part of the supported
|
|
40
41
|
input surface. JXL inputs still pass extension routing, the pixel cap, and
|
|
41
|
-
(optionally) the
|
|
42
|
-
|
|
42
|
+
(optionally) Landlock containment around the helper process, but libjxl does
|
|
43
|
+
parse attacker-controlled bytes inside that helper like the other raster
|
|
44
|
+
decoders below.
|
|
43
45
|
|
|
44
46
|
## Non-goals
|
|
45
47
|
|
|
46
|
-
Safe Image does not claim that parsing hostile images
|
|
48
|
+
Safe Image does not claim that parsing hostile images is memory-safe. Raster
|
|
49
|
+
decoders such as libjpeg, libpng, libwebp, libheif, libjxl, libnsgif, libvips
|
|
50
|
+
loaders, and ImageMagick coders still parse attacker-controlled bytes; so does
|
|
51
|
+
Nokogiri/libxml2 for bounded SVG metadata. A parser or decoder
|
|
52
|
+
memory-corruption bug or pathological resource-consumption bug is still possible.
|
|
53
|
+
When `landlock: true` is configured, Safe Image contains child helpers/tools; it
|
|
54
|
+
does not put Ruby orchestration or Nokogiri SVG parsing behind a Landlock
|
|
55
|
+
boundary.
|
|
47
56
|
|
|
48
57
|
The honest claim is defense-in-depth:
|
|
49
58
|
|
|
50
59
|
- without Landlock: centralized and hardened image processing with major delegate/protocol/policy foot-guns removed
|
|
51
|
-
- with Landlock: the same hardening plus a kernel containment boundary around subprocess
|
|
60
|
+
- with Landlock: the same hardening plus a kernel containment boundary around subprocess helpers/tools
|
|
52
61
|
|
|
53
|
-
If your deployment needs a hard isolation boundary
|
|
62
|
+
If your deployment needs a hard isolation boundary around the Ruby process as well,
|
|
63
|
+
run image processing away from your main web worker process (for example in a
|
|
64
|
+
separate service/container).
|
|
54
65
|
|
|
55
66
|
## Reporting vulnerabilities
|
|
56
67
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Safe Image architecture and safety model
|
|
2
|
+
|
|
3
|
+
Safe Image is deliberately structured as a narrow boundary around untrusted image bytes. The public API lives on
|
|
4
|
+
`SafeImage`, but the implementation is split by responsibility:
|
|
5
|
+
|
|
6
|
+
- `lib/safe_image/api/metadata.rb` — public read-only method signatures (`probe`, `size`, `info`, animation checks,
|
|
7
|
+
etc.) that delegate to operation objects.
|
|
8
|
+
- `lib/safe_image/api/transform.rb` — public image-producing method signatures. These require explicit `input:` and
|
|
9
|
+
`output:` keywords and never replace a caller's source file in place.
|
|
10
|
+
- `lib/safe_image/metadata_operations.rb` and `lib/safe_image/transform_operations.rb` — inline Ruby orchestration for
|
|
11
|
+
public operations. They do not load libvips and they are not proxied through a Ruby sandbox worker.
|
|
12
|
+
- `lib/safe_image/operations.rb` — private backend orchestration shared by public writers.
|
|
13
|
+
- `lib/safe_image/processor.rb` and `lib/safe_image/native.rb` — libvips orchestration that shells out to the bundled
|
|
14
|
+
`safe_image_vips_helper`; the Ruby process never loads libvips.
|
|
15
|
+
- `lib/safe_image/image_magick_backend.rb` — ImageMagick argv construction under the bundled policy.
|
|
16
|
+
- `lib/safe_image/sandbox.rb` — optional Landlock capture for child commands/helpers, with explicit read/write grants.
|
|
17
|
+
- `lib/safe_image/formats.rb` — Ruby-side format normalization and allowlists.
|
|
18
|
+
- `lib/safe_image/staged_output.rb` — same-directory temporary output and staged replacement helpers.
|
|
19
|
+
|
|
20
|
+
## Security invariants
|
|
21
|
+
|
|
22
|
+
Changes should preserve these rules:
|
|
23
|
+
|
|
24
|
+
1. `SafeImage.configure!(backend:, landlock:)` is mandatory before any operation. Backend and sandbox posture are a
|
|
25
|
+
boot-time decision, not per-call convenience flags.
|
|
26
|
+
2. The configured backend is authoritative. There is no silent fallback from libvips to ImageMagick when a format fails.
|
|
27
|
+
3. Untrusted local paths pass `PathSafety` checks. Symlink components are rejected, output paths may not be directories,
|
|
28
|
+
and input/output paths must be distinct.
|
|
29
|
+
4. Public writers require explicit output paths. No public API mutates the input file in place or shuffles files for the
|
|
30
|
+
caller.
|
|
31
|
+
5. Output replacement goes through `StagedOutput`, which writes a sibling temporary file and renames it into place so
|
|
32
|
+
callers do not observe partial output.
|
|
33
|
+
6. External commands are always argv arrays. Never construct shell strings.
|
|
34
|
+
7. ImageMagick paths are prefixed with explicit coders (`jpeg:`, `png:`, etc.) and run only with the bundled restrictive
|
|
35
|
+
`policy.xml`.
|
|
36
|
+
8. Pixel limits are enforced before full decode: libvips probes headers first and ImageMagick uses both probe checks and
|
|
37
|
+
its `128MP` area limit.
|
|
38
|
+
9. SVG metadata probing remains bounded and non-rendering: byte/depth/element/attribute caps, unsafe encoding rejection,
|
|
39
|
+
and root-dimension pixel caps all happen before parser results are trusted. This Nokogiri/libxml2 parse runs in the
|
|
40
|
+
Ruby process; Landlock containment is for child helpers and tools, not this parser.
|
|
41
|
+
10. Remote fetching remains SSRF-hardened: DNS pinning, special-use IP blocking, redirect limits, and no direct decode
|
|
42
|
+
from network sockets.
|
|
43
|
+
|
|
44
|
+
## Debugging model
|
|
45
|
+
|
|
46
|
+
- `SafeImage::CommandError` carries `command`, `status`, `stdout`, `stderr`, `category`, and optional `operation` so CI
|
|
47
|
+
failures show whether the fault came from a timeout, output cap, exit status, sandboxed command/helper, or native
|
|
48
|
+
helper error.
|
|
49
|
+
- Backend labels are centralized in `BackendLabel` so results consistently report `libvips-helper`, `imagemagick`, or
|
|
50
|
+
`libvips-helper+cjpegli`.
|
|
51
|
+
- With `landlock: true`, child commands/helpers get explicit readable and writable path lists at each call site. Add new
|
|
52
|
+
tool/helper invocations deliberately; do not infer readable/writable paths from arbitrary argument strings.
|
|
53
|
+
|
|
54
|
+
## Adding a new image-producing operation
|
|
55
|
+
|
|
56
|
+
1. Add a public method in `api/transform.rb` with explicit keywords.
|
|
57
|
+
2. Add the inline implementation to `TransformOperations`.
|
|
58
|
+
3. Add private backend orchestration in `operations.rb` if it is shared across backends.
|
|
59
|
+
4. Add or reuse backend-specific argv/native helpers.
|
|
60
|
+
5. If the operation shells out, pass explicit Landlock `read:`/`write:` grants to the child command/helper.
|
|
61
|
+
6. Route temporary outputs through `StagedOutput`.
|
|
62
|
+
7. Normalize/validate formats with `Formats`.
|
|
63
|
+
8. Add contract and backend tests that exercise real fixtures rather than mocks.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mkmf"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
pkg_config("vips") or abort "libvips development files are required (pkg-config vips failed)"
|
|
7
|
+
|
|
8
|
+
pkg_cflags = `pkg-config --cflags vips`.strip
|
|
9
|
+
pkg_libs = `pkg-config --libs vips`.strip
|
|
10
|
+
cflags = [ENV["CFLAGS"] || RbConfig::CONFIG["CFLAGS"], RbConfig::CONFIG["CPPFLAGS"], pkg_cflags].compact.join(" ")
|
|
11
|
+
ldflags = [ENV["LDFLAGS"] || RbConfig::CONFIG["LDFLAGS"]].compact.join(" ")
|
|
12
|
+
|
|
13
|
+
helper = "safe_image_vips_helper"
|
|
14
|
+
source = "safe_image_vips_helper.c"
|
|
15
|
+
lib_dir = File.expand_path("../../lib/safe_image", __dir__)
|
|
16
|
+
|
|
17
|
+
File.write(
|
|
18
|
+
"Makefile",
|
|
19
|
+
<<~MAKEFILE
|
|
20
|
+
SHELL = /bin/sh
|
|
21
|
+
CC = #{RbConfig::CONFIG.fetch("CC")}
|
|
22
|
+
CFLAGS = #{cflags}
|
|
23
|
+
LDFLAGS = #{ldflags}
|
|
24
|
+
LIBS = #{pkg_libs} -lm
|
|
25
|
+
INSTALL = #{RbConfig::CONFIG.fetch("INSTALL", "install")}
|
|
26
|
+
|
|
27
|
+
all: #{helper}
|
|
28
|
+
|
|
29
|
+
#{helper}: #{source}
|
|
30
|
+
$(CC) $(CFLAGS) -o #{helper} #{source} $(LDFLAGS) $(LIBS)
|
|
31
|
+
|
|
32
|
+
install: #{helper}
|
|
33
|
+
mkdir -p #{lib_dir}
|
|
34
|
+
cp #{helper} #{File.join(lib_dir, helper)}
|
|
35
|
+
chmod 0755 #{File.join(lib_dir, helper)}
|
|
36
|
+
|
|
37
|
+
clean:
|
|
38
|
+
rm -f #{helper} *.o
|
|
39
|
+
|
|
40
|
+
distclean: clean
|
|
41
|
+
rm -f Makefile
|
|
42
|
+
MAKEFILE
|
|
43
|
+
)
|