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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -10
  3. data/README.md +176 -168
  4. data/SECURITY.md +22 -11
  5. data/docs/architecture.md +63 -0
  6. data/ext/safe_image_vips_helper/extconf.rb +43 -0
  7. data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
  8. data/lib/safe_image/api/metadata.rb +85 -0
  9. data/lib/safe_image/api/transform.rb +152 -0
  10. data/lib/safe_image/backend_label.rb +24 -0
  11. data/lib/safe_image/formats.rb +96 -0
  12. data/lib/safe_image/ico.rb +43 -41
  13. data/lib/safe_image/image_magick_backend.rb +219 -162
  14. data/lib/safe_image/jpegli_backend.rb +64 -44
  15. data/lib/safe_image/metadata_operations.rb +155 -0
  16. data/lib/safe_image/native.rb +96 -281
  17. data/lib/safe_image/native_helper.rb +281 -0
  18. data/lib/safe_image/operation_backends/base.rb +83 -0
  19. data/lib/safe_image/operation_backends/image_magick.rb +123 -0
  20. data/lib/safe_image/operation_backends/vips.rb +251 -0
  21. data/lib/safe_image/operation_backends.rb +27 -0
  22. data/lib/safe_image/operation_set.rb +22 -0
  23. data/lib/safe_image/optimizer.rb +250 -48
  24. data/lib/safe_image/path_safety.rb +11 -0
  25. data/lib/safe_image/processor.rb +122 -85
  26. data/lib/safe_image/quality_defaults.rb +23 -0
  27. data/lib/safe_image/remote.rb +367 -97
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +69 -24
  30. data/lib/safe_image/sandbox.rb +44 -191
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +186 -55
  33. data/lib/safe_image/transform_operations.rb +139 -0
  34. data/lib/safe_image/version.rb +1 -1
  35. data/lib/safe_image/vips_backend.rb +13 -12
  36. data/lib/safe_image.rb +62 -294
  37. metadata +48 -26
  38. data/lib/safe_image/discourse_compat.rb +0 -452
  39. data/lib/safe_image/svg_sanitizer.rb +0 -102
  40. data/lib/safe_image/vips_glue.rb +0 -361
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 runtime libvips binding (Fiddle) that exposes only the specific
20
- operations the gem invokes — there is no generic operation access
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
- - risky container formats parsed in memory-safe Ruby rather than C: SVG
28
- (bounded REXML metadata and allowlist sanitising) and ICO (bounds-checked
29
- directory/DIB parsing); extracted pixels are re-encoded through libvips and
30
- embedded payload bytes are never copied through verbatim
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 Landlock sandbox, but libjxl does parse attacker-controlled
42
- bytes in-process like the other raster decoders below.
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 in-process is memory-safe. Raster decoders such as libjpeg, libpng, libwebp, libheif, libjxl, libnsgif, libvips loaders, and ImageMagick coders still parse attacker-controlled bytes. A decoder memory-corruption bug or pathological resource-consumption bug is still possible.
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-based public operations
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, configure `landlock: true` and run image processing away from your main web worker process.
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
+ )