Safe Image
Safe Image is a small Ruby image-processing boundary for untrusted uploads.
It gives an application one narrow API for probing, thumbnailing, resizing, cropping, converting, optimising, SVG sanitising, animation checks, dominant colour extraction, favicon conversion, and letter-avatar generation. Everything that varies by host is decided once, at boot, with a single mandatory call:
SafeImage.configure!(backend: :vips, landlock: true)
The :vips backend uses a tiny Fiddle binding that drives libvips directly
— pure Ruby, nothing compiles at install time. The :imagemagick backend
runs ImageMagick with shell-free command execution and a restrictive bundled
policy. There are no per-call backend choices and no silent fallback from one
backend to the other: you pick the decoder for untrusted bytes in one place
and every operation uses it.
The premise is that hostile image bytes are a lousy thing to spread across model callbacks, upload helpers, optimizer wrappers, and hand-built command strings. Safe Image puts the risky operations behind one small, hardened choke point instead.
Install
Nothing compiles at install time: libvips is bound at runtime through Fiddle
(libvips.so.42 is dlopened when configure!(backend: :vips) runs;
SAFE_IMAGE_LIBVIPS overrides the library name authoritatively). libvips'
GLib warnings about rejected input (e.g. "Not a PNG file") are silenced —
failures surface as exceptions instead; set SAFE_IMAGE_VIPS_WARNINGS=1 to
restore them for debugging. Install the runtime
dependencies below.
gem build safe_image.gemspec
gem install ./safe_image-0.1.0.gem
require "safe_image"
SafeImage.configure!(backend: :vips, landlock: false)
result = SafeImage.thumbnail(
input: "upload.jpg",
output: "thumb.jpg",
width: 600,
height: 400,
max_pixels: 40_000_000
)
puts "#{result.backend}: #{result.width}x#{result.height} #{result.filesize} bytes"
SafeImage.convert(
"upload.png",
"upload.jpg",
format: "jpg",
quality: 85,
max_pixels: 40_000_000
)
Configuration
SafeImage.configure! must be called before any operation — typically from a
boot-time initializer. Any operation before it (including the pure-Ruby SVG
and remote helpers) raises SafeImage::NotConfiguredError.
SafeImage.configure!(
backend: :vips, # required: :vips or :imagemagick — decodes all untrusted bytes
landlock: true, # required: route every operation through the Landlock sandbox
max_pixels: SafeImage::DEFAULT_MAX_PIXELS # optional: default decompression-bomb ceiling (128MP)
)
Validation is eager, so a misconfigured host fails at boot rather than on the first request:
backend: :vipsdlopens libvips and raises if it is unavailablebackend: :imagemagickraises if nomagick/convertexecutable is foundlandlock: trueraises if the Landlock sandbox is unavailable (SafeImage.sandbox_available?works beforeconfigure!, so a host can probe first)- unknown values raise
ArgumentError
Calling configure! again replaces the configuration atomically (last call
wins), so reloading initializers in development is safe. SafeImage.config
returns the current frozen configuration and SafeImage.configured? reports
whether one is set.
Per-call max_pixels: still overrides the configured ceiling for an
individual operation; everything else about backend selection and sandboxing
is decided here and only here.
Choosing a backend:
:vips— the recommended fast path: explicit native loaders, in-process hardening, no subprocess per operation:imagemagick— matches classic ImageMagickconvertpipelines; also the option for hosts without libvips. Formats are decoded by ImageMagick under the bundled restrictive policy.
A format the configured backend cannot decode fails closed — e.g. ICO
transform inputs need :imagemagick (ICO metadata is parsed in pure Ruby on
either backend), and HEIC needs a libvips build with libheif on :vips.
Dependencies
Quick install
Debian / Ubuntu:
sudo apt-get install --no-install-recommends \
libvips42 imagemagick jpegoptim pngquant oxipng libjpeg-turbo-progs
oxipng is packaged from Debian 13 / Ubuntu 24.04; on older releases install
it with cargo install oxipng. For cjpegli, Debian/Ubuntu package names
vary by release (libjpegli-tools where available); it is optional and
detected at runtime.
Arch:
sudo pacman -S --needed libvips \
imagemagick jpegoptim pngquant oxipng libjpeg-turbo libjxl
(libjpeg-turbo provides jpegtran, libjxl provides cjpegli.)
What each dependency is for
| Dependency | Kind | Needed for | Without it |
|---|---|---|---|
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 |
ImageMagick (magick/convert, identify) |
required for backend: :imagemagick |
every operation on the :imagemagick backend |
configure!(backend: :imagemagick) raises at boot |
jpegoptim |
required for JPEG optimize |
lossless JPEG optimisation and metadata stripping | JPEG optimize raises in strict mode |
oxipng |
required for PNG optimize |
lossless PNG optimisation | PNG optimize raises in strict mode |
pngquant |
optional | lossy PNG quantisation (optimize_mode: :lossy, files < 500KB) |
lossy mode silently skips the quantisation pass |
jpegtran (libjpeg-turbo) |
optional | lossless tier of fix_orientation for MCU-aligned JPEGs |
falls back to the libvips re-encode tier |
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 |
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 |
rexml gem |
automatic | SVG sanitising and SVG metadata | installed as a gem dependency |
The landlock gem is intentionally not a gem dependency; add it to the
host application's Gemfile if you want sandboxing.
libvips build capabilities
Some features depend on how the host's libvips was built (all present in stock Debian, Ubuntu and Arch packages):
- libheif — HEIC/AVIF decode (
probe,convert, thumbnails) - Pango text (
VipsText) —letter_avatar; without it the operation raisesUnsupportedFormatError(configurebackend: :imagemagickif you need letter avatars on such a build) - cgif (
gifsave) — GIF output from the vips backend; GIF decode (libnsgif) is always built in - libjxl (
jxlload/jxlsave) — JPEG XL decode and encode
Check a host with:
vips -l | grep -cE "VipsForeignSaveCgif|VipsText|VipsForeignLoadHeif|VipsForeignLoadJxl" # expect 4+
Fonts
Letter avatars need no font packages with the default DejaVu-Sans
token: the gem bundles DejaVu Sans and pins it via fontconfig's app-font API.
The other allowlisted tokens (NimbusSans-Regular, Liberation-Sans,
Arial, Helvetica, Adwaita-Sans) resolve through system fontconfig —
install e.g. fonts-liberation (Debian/Ubuntu) or ttf-liberation (Arch) if
you select them. Glyphs outside DejaVu's coverage (CJK, Hangul, ...) fall
back per-glyph to whatever system fonts exist.
Checking a host
These probes work before configure!, so an application can inspect the host
and then make its configuration decision:
require "safe_image"
%w[magick identify jpegoptim oxipng pngquant jpegtran cjpegli].each do |tool|
puts format("%-10s %s", tool, SafeImage::Runner.available?(tool) ? "ok" : "missing")
end
puts format("%-10s %s", "libvips", SafeImage::VipsGlue.available? ? "ok" : "unavailable")
puts format("%-10s %s", "sandbox", SafeImage.sandbox_available? ? "ok" : "unavailable")
API
All operations are module functions on SafeImage and run on the configured
backend. Operations that decode an image accept max_pixels: to override the
configured decompression-bomb ceiling for that one call.
Return values
Image-producing operations return SafeImage::Result:
SafeImage::Result[
input:, # source path or "generated"
output:, # output path, nil for probe
input_format:, # "jpg", "png", "webp", "heic", "avif", etc.
output_format:, # output format, nil for probe
width:,
height:,
filesize:,
backend:, # e.g. "libvips-direct", "imagemagick", "cjpegli",
# "libvips-direct+cjpegli"
duration_ms:,
optimizer: # optimizer tool list for thumbnail path, otherwise nil
]
Optimizer operations return a hash:
{
format: "jpg",
before_bytes: 123_456,
after_bytes: 120_000,
saved_bytes: 3_456,
tools: ["jpegoptim"]
}
Probing and metadata
Metadata operations for local files. type, size/dimensions,
orientation, and info are intended to cover the local-file parts of APIs
like FastImage.type, FastImage.size, and FastImage#orientation without
adding a Ruby dependency. None of these fetch remote URLs — see
Remote URLs for that.
SafeImage.probe(path, max_pixels: nil)
Reads image metadata through the configured backend.
Supported inputs on the :vips backend:
jpg/jpegpnggif(first frame, via libvips' bundled libnsgif loader)webpheic/heifavifjxl(requires a libvips build with libjxl support)ico(pure-Ruby directory parse on either backend; reports the largest entry's dimensions)
info = SafeImage.probe("upload.jpg", max_pixels: 40_000_000)
puts "#{info.width}x#{info.height} #{info.input_format}"
Raises SafeImage::LimitError if width * height > max_pixels.
SafeImage.type(path, max_pixels: nil)
Returns a FastImage-style symbol for a local file:
SafeImage.type("upload.jpg") # => :jpeg
SafeImage.type("upload.png") # => :png
SafeImage.type("icon.svg") # => :svg
JPEG is returned as :jpeg, not :jpg, to match common Ruby image-probing
conventions.
SafeImage.size(path, max_pixels: nil) / SafeImage.dimensions(path, max_pixels: nil)
Returns [width, height] for a local file:
SafeImage.size("upload.jpg") # => [1600, 1200]
SafeImage.dimensions("upload.png") # => [800, 600]
SafeImage.size("icon.svg") # => [120, 80]
SVG metadata is handled by a dedicated parser, not ImageMagick or libvips. It is
limited to local .svg files, caps input size/tree depth/element/attribute
counts, rejects DOCTYPE and non-XML processing instructions, requires an <svg>
root, and derives dimensions from numeric width/height or viewBox.
SafeImage.orientation(path, max_pixels: nil)
Returns the EXIF orientation integer (1-8) for a local file. On the :vips
backend it is read from the orientation header field the native loaders
populate during the header scan — no pixel data is decoded — and garbage tag
values clamp to 1. On the :imagemagick backend it comes from identify.
SVG and ICO report 1 by definition.
Note one deliberate HEIC difference: libheif applies the container's
irot/imir transforms during decode, so the native path reports the
orientation of the pixels as actually decoded, rather than echoing a raw
EXIF tag that may already be baked in.
SafeImage.orientation("upload.jpg") # => 1
SafeImage.info(path, max_pixels: nil, animated: false, orientation: false)
Returns a SafeImage::Info object for a local file:
info = SafeImage.info("upload.jpg", animated: true, orientation: true)
info.type # => :jpeg
info.width # => 1600
info.height # => 1200
info.size # => [1600, 1200]
info.animated # => false
info.orientation # => 1
animated: and orientation: default to false because they may require extra
ImageMagick work. When disabled, those fields are nil.
SafeImage.frame_count(path, max_pixels: nil)
Returns the frame count. On the :vips backend it comes from the n-pages
header field via the native loaders — no pixel data is decoded; on the
:imagemagick backend from identify. ICO directories are counted by the
pure-Ruby parser on either backend.
frames = SafeImage.frame_count("animated.gif")
SafeImage.animated?(path, max_pixels: nil)
Returns true when frame_count(path) > 1.
SafeImage.animated?("animated.webp")
SafeImage.dominant_color(path, max_pixels: nil)
Computes the image's alpha-weighted average colour (first frame for animated
formats) and returns it as an uppercase RRGGBB hex string.
The :vips backend computes the exact per-channel mean natively, with ICO
decoded by the pure-Ruby parser. The :imagemagick backend uses a histogram
command. The two backends agree to within a few least-significant bits per
channel (ImageMagick averages through its resize filter rather than computing
the exact mean).
The pixel cap is enforced before the full decode on either backend,
undecodable input raises InvalidImageError, and SVG input raises
UnsupportedFormatError.
SafeImage.dominant_color("upload.png") # => "6F745E"
Remote URLs
These helpers are intended to cover FastImage.size(url) / FastImage.type(url)
style use cases without another Ruby dependency. They use only Ruby stdlib
Net::HTTP, download to a tempfile with a byte cap, then run the normal Safe
Image local metadata path on that tempfile.
Remote fetching is deliberately conservative:
- only
httpandhttpsURLs are accepted - redirects are capped
- open/read timeouts and an overall
total_timeoutare capped - response size is capped by
max_bytes - public remote fetches are limited to ports 80 and 443 by default
Net::HTTPenvironment proxies are disabled; proxy environment variables cannot route around IP validation- DNS answers are checked against a special-use address blocklist and the connection is pinned to a vetted IP address to avoid DNS-rebinding/TOCTOU bypasses
- HTTPS-to-HTTP redirects are rejected
- same-origin redirects keep caller headers; cross-origin redirects use a small
header allowlist (
Accept,Accept-Encoding,User-Agent) rather than a blacklist - initial caller-supplied request headers use the same small allowlist; cookies, authorization headers, and custom auth headers are not forwarded by default
- hop-by-hop/proxy/
Hostrequest headers are rejected before any request - private, loopback, link-local, multicast, documentation, benchmarking, carrier-grade NAT, IPv4-mapped IPv6, NAT64, 6to4/Teredo, and other special-use resolved addresses are rejected by default
- no image decoding happens directly from the socket
- the final response
Content-Typemust be an allowed image type and must agree with an image-looking URL extension when one is present - downloaded content is probed before
fetch_remoteyields the tempfile, so the raw downloader cannot be used as a blind extension-based file saver - SVG remote metadata uses the same bounded SVG metadata parser after download; SVG is not handed to ImageMagick for probing
Set allow_private: true only when the caller has already made an SSRF decision
or is intentionally probing a trusted internal URL. Passing allow_private: true
also permits non-standard ports; for public fetches, pass allowed_ports: if you
really need to allow a different port.
SafeImage.remote_size(url, ...) / SafeImage.remote_dimensions(url, ...)
SafeImage.remote_size(
"https://example.com/image.jpg",
max_bytes: 10.megabytes,
total_timeout: 30,
max_pixels: 40_000_000
)
# => [1600, 1200]
SafeImage.remote_type(url, ...)
SafeImage.remote_type("https://example.com/image.png", max_bytes: 10.megabytes)
# => :png
SafeImage.remote_info(url, ...)
info = SafeImage.remote_info(
"https://example.com/image.gif",
max_bytes: 10.megabytes,
animated: true
)
info.type # => :gif
info.size # => [640, 480]
info.animated # => true
SafeImage.remote_animated?(url, ...)
SafeImage.remote_animated?("https://example.com/image.webp", max_bytes: 10.megabytes)
# => true / false
SafeImage.remote_dominant_color(url, ...)
SafeImage.remote_dominant_color("https://example.com/image.png", max_bytes: 10.megabytes)
# => "6F745E"
SafeImage.fetch_remote(url, ...) { |path| ... }
Downloads a remote image to a tempfile and yields the local path:
SafeImage.fetch_remote("https://example.com/image.jpg", max_bytes: 10.megabytes) do |path|
SafeImage.probe(path)
end
With landlock: true configured, the network fetch itself is not put inside
the Landlock worker because the worker denies network access. The downloaded
tempfile is then passed through the normal Safe Image local image APIs, so
decoding still uses the same sandboxed image-processing path.
Generating images
Operations that write a new image. All of them run on the configured backend
and return SafeImage::Result.
SafeImage.thumbnail(...)
Creates a center-cropped thumbnail.
result = SafeImage.thumbnail(
input: "upload.jpg",
output: "thumb.jpg",
width: 600,
height: 400,
format: nil, # inferred from output extension when nil
quality: 85,
max_pixels: 40_000_000, # overrides the configured ceiling for this call
optimize: true,
optimize_mode: :lossless, # :lossless or :lossy for PNG optimisation
chroma_subsampling: :auto # :auto, "420", "422", "444" for JPEG output
)
Supported outputs on the :vips backend:
jpg/jpegpnggif(requires a libvips build with cgif support; raisesUnsupportedFormatErrorotherwise)webpavifjxl(requires a libvips build with libjxl support)
SafeImage.resize(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
Creates a resized thumbnail-style output.
SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400)
SafeImage.resize("upload.jpg", "thumb.jpg", 600, 400, quality: 85)
resize, crop and downsize run on the configured backend:
:vips— the direct libvips path; formats outside the native loader allowlist (ICO input) fail closed:imagemagick— matches classicconvertthumbnail pipelines (-thumbnail, catrom interpolation, unsharp, sRGB profile); configure this if byte-similar output with previously generated thumbnails matters
SafeImage.crop(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
Creates a north-cropped image — the shape typically used for square avatar crops.
SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240)
SafeImage.downsize(from, to, dimensions, optimize: true, max_pixels: nil, quality: 85, chroma_subsampling: :auto)
Downsizes an image using ImageMagick-style geometry strings.
SafeImage.downsize("large.png", "small.png", "50%")
SafeImage.downsize("large.png", "small.png", "100x100>")
SafeImage.downsize("large.png", "small.png", "400000@")
The vips backend supports the geometry forms covered by the test suite:
percentage, bounding box with >, and pixel-area cap with @.
SafeImage.convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
Converts an input image to an explicit output format:. Unsupported formats
raise SafeImage::UnsupportedFormatError.
On the :vips backend this decodes through the native libvips loaders,
auto-orients, flattens transparency onto white for JPEG targets (matching the
ImageMagick path's -background white -flatten), and re-encodes. When no
quality: is given, native JPEG output uses quality 92 — what ImageMagick
uses for sources without quality tables — rather than libvips' default 75.
ICO input/output is outside the native loaders and fails closed (use
convert_favicon_to_png for favicons, or the :imagemagick backend).
For PNG-to-JPEG on the :vips backend, cjpegli is used automatically when
installed (see JPEG encoding of generated images).
SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85)
SafeImage.convert("upload.heic", "upload.jpg", format: "jpg", quality: 85)
SafeImage.convert("upload.jpg", "upload.webp", format: "webp", quality: 85)
SafeImage.fix_orientation(from, to = from, max_pixels: nil, quality: nil)
Bakes the EXIF orientation into the pixels and clears the tag. The :vips
backend tries tiers in order:
- jpegtran (lossless) — for JPEGs with
jpegtraninstalled, the transform happens on the DCT coefficients with zero generation loss.-perfectrefuses non-MCU-aligned dimensions, in which case: - libvips re-encode — autorotate and re-encode, stripping metadata.
JPEG output uses
quality:(default 95).
The :imagemagick backend uses the previous -auto-orient behaviour and
re-encodes at the input's estimated quality.
If to is omitted, the file is rewritten in place.
SafeImage.fix_orientation("upload.jpg")
SafeImage.fix_orientation("upload.jpg", "oriented.jpg")
SafeImage.convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)
Extracts the largest ICO entry and writes PNG. On the :vips backend no
ImageMagick is involved: the container and legacy DIB payloads (1/4/8/24/32bpp
BI_RGB plus the AND mask) are parsed in pure Ruby with explicit bounds checks,
and pixels are encoded through the hardened native libvips path. Embedded PNG
payloads are re-encoded — never copied through verbatim — and their pixel cap
is enforced from the IHDR before any decoder runs. On the :imagemagick
backend the conversion runs through ImageMagick's ico decoder under the
bundled policy.
SafeImage.convert_favicon_to_png("favicon.ico", "favicon.png")
SafeImage.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")
Generates a square letter avatar PNG: one grapheme blended in white at 80% opacity over a solid background.
The :vips backend renders natively through libvips' Pango text support (the
glyph is markup-escaped before rendering) and fails closed on builds without
a text renderer; the :imagemagick backend uses ImageMagick's annotation
path.
The default DejaVu-Sans font uses the DejaVu Sans file bundled with the gem
(see lib/safe_image/fonts/DEJAVU-LICENSE), so rendering does not depend on
which fonts the host has installed. The other allowlisted tokens
(NimbusSans-Regular, Liberation-Sans, Arial, Helvetica,
Adwaita-Sans) resolve through fontconfig.
The native path centres the glyph's ink box optically, which differs from the ImageMagick path's baseline placement (where descenders could clip at the canvas edge). Treat switching backends as a visual change: regenerate cached avatars.
SafeImage.letter_avatar(
output: "avatar.png",
size: 360,
background_rgb: [1, 2, 3],
letter: "S"
)
JPEG encoding of generated images
Safe Image separates encoding generated JPEGs from optimising existing
JPEGs. This avoids hiding a lossy re-encode behind a method named optimize.
Like the optimizer tools, the optional cjpegli encoder is availability
driven: installed means used, absent means the configured backend encodes.
There is no encoder knob — cjpegli only ever encodes pixels Safe Image has
already decoded, so it is not part of the untrusted-input surface the backend
choice controls.
| Operation | Behavior |
|---|---|
thumbnail / resize / crop / downsize to JPEG on the :vips backend |
use cjpegli when installed; otherwise normal libvips JPEG output |
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 |
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 |
any operation on the :imagemagick backend |
ImageMagick encodes; cjpegli is never used |
optimize("existing.jpg") |
use jpegoptim; never cjpegli |
cjpegli output is ordinary browser-compatible JPEG. It is optional because it
is a system binary, not a Ruby dependency. Safe Image detects it at runtime.
chroma_subsampling: :auto uses 4:4:4 for PNG-sourced JPEG conversion and
4:2:0 otherwise. Pass "420", "422", or "444" to force a value.
Optimising in place
SafeImage.optimize(path, mode: :lossless, strip_metadata: true, quality: nil, strict: true)
Optimises an existing JPEG or PNG in place.
SafeImage.optimize("image.jpg", quality: 85)
SafeImage.optimize("image.png")
SafeImage.optimize("image.png", mode: :lossy, quality: "65-90")
JPEG path:
- uses
jpegoptim quality:maps tojpegoptim --max- metadata is stripped unless
strip_metadata: false
PNG path:
- uses
oxipngfor lossless optimisation - when
mode: :lossy, usespngquantfirst for PNGs smaller than 500 KB, thenoxipng
When strict: true, missing optimizer tools raise. When strict: false, missing
optimizer tools are tolerated.
SVG sanitising
SafeImage.sanitize_svg!(path)
Sanitises an SVG in place using a small REXML allowlist.
result = SafeImage.sanitize_svg!("icon.svg")
puts result[:sanitized]
The sanitizer removes unsafe elements/attributes such as scripts and event handlers. It is intentionally conservative rather than a full browser-grade SVG implementation.
SVG sanitising is defense-in-depth for stored bytes. Applications that serve
user-supplied SVGs directly should still use response-level controls such as a
restrictive Content-Security-Policy, X-Content-Type-Options: nosniff, and/or
attachment/sandbox handling for direct-open routes. Browsers restrict script
execution when an SVG is embedded as <img>, but a top-level SVG document is a
different sink.
Compatibility aliases
Two thin wrappers kept for callers migrating from existing upload pipelines:
SafeImage.optimize_image!("image.jpg")
SafeImage.optimize_image!("image.png", allow_lossy_png: true)
SafeImage.convert_to_jpeg("upload.heic", "upload.jpg", quality: 85)
optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)
forwards to optimize, with allow_lossy_png: mapping to mode:.
convert_to_jpeg(from, to, ...) forwards to convert with format: "jpg"
and accepts the same keywords.
Security
Safe Image is not magic pixie dust. It is a deliberately small choke point: the goal is to centralize risky operations, make the safe path boring, and remove common image-processing foot-guns.
What it does:
- forces one explicit, eagerly validated
configure!decision — which backend decodes untrusted bytes, whether the Landlock sandbox is on — before any operation runs; everything else raisesNotConfiguredError - uses explicit argv arrays for external commands, never shell strings
- starts external commands with an allowlisted environment, private temp/home/cache directories, bounded stdout/stderr, and process-group timeout cleanup
- uses explicit libvips loaders selected from allowlisted extensions
- enables libvips' untrusted-operation block in-process (deliberately re-enabling only the libjxl loader/saver, which libvips tags untrusted, because JPEG XL is part of the supported input surface)
- blocks libvips ImageMagick loader classes in the libvips binding, which itself exposes only the operations the gem invokes
- disables libvips cache by default in-process
- strips metadata on generated images where applicable
- rejects symlinked local input/output paths and symlinked path components for untrusted file-processing paths
- caps decoded pixels before expensive work: the libvips path enforces a
default
SafeImage::DEFAULT_MAX_PIXELS(128MP) ceiling even when nomax_pixelsis given, and callers can raise or lower it per call - ships a restrictive ImageMagick
policy.xml - denies Ghostscript-backed formats and dangerous ImageMagick features:
PS,PS2,PS3,EPS,EPSF,PDF,XPS,PCLMSL,MVGHTTP,HTTPS,URL- delegates, filters, and
@fileindirection
- hardens remote fetch against SSRF: scheme/port restrictions, special-use IP blocking, DNS pinning, redirect limits, HTTPS-to-HTTP rejection, proxy-env bypass prevention, request-header allowlists, content-type/extension agreement, and probe-before-yield (details under Remote URLs)
- parses SVG metadata with a bounded pure-Ruby parser; SVG is never handed to ImageMagick for probing
- sanitises SVG conservatively, allowlist based: rejects
DOCTYPEand XML processing instructions, removes comments and disallowed elements, converts CDATA to escaped text, and blocks event handlers, external URLs, andjavascript:/data:URL values - supports optional Landlock subprocess sandboxing on Linux
The backend is a configuration decision, not a per-call option. Safe Image
will never silently fall from the libvips path into generic ImageMagick
decoding — a format the configured backend cannot decode fails closed with
SafeImage::UnsupportedFormatError.
Security posture without Landlock
Without Landlock, everything above still applies; in particular the ImageMagick path runs with:
- delegates disabled
- filters disabled
@fileindirection disabled- remote URL coders disabled
- Ghostscript/document/vector formats denied
- coders deny-by-default with a small raster allowlist
- ImageMagick resource limits set in the bundled policy
That does not make hostile files benign. Raster decoders still parse attacker controlled bytes: libjpeg, libpng, libwebp, libheif/HEIC/AVIF, ImageMagick's raster decoders, and libvips loaders. If one of those decoders has a memory corruption bug or pathological resource-consumption bug, policy alone is not a sandbox.
So the intended posture is:
- without Landlock: hardened, centralized image processing with the major ImageMagick delegate/pseudo-protocol foot-guns removed
- with Landlock: the same hardening plus a real containment boundary around all public operations
Do not describe non-sandboxed operation as making hostile images safe. The honest claim is defense-in-depth, not immunity.
Atomic Landlock sandboxing
Landlock support is optional, but atomic once configured.
SafeImage.sandbox_available? # => true/false, works before configure!
SafeImage.configure!(backend: :vips, landlock: true) # raises if unavailable
SafeImage.config.landlock # => true
With landlock: true, every public operation routes through the sandbox
worker:
probetypesizedimensionsinfoorientationdominant_colorthumbnailoptimizeresizecropdownsizeconvertconvert_to_jpegcompatibility aliasfix_orientationconvert_favicon_to_pngframe_countanimated?letter_avataroptimize_image!sanitize_svg!
There is no silent fallback once landlock is configured. If sandbox setup or a sandboxed command fails, the operation fails.
The sandbox grants read/write access only to the paths inferred from the operation arguments, plus runtime/library paths and temporary directories needed by Ruby, libvips, ImageMagick, and optimizer tools. Network syscalls are denied through the Landlock helper's seccomp layer. Worker processes inherit the parent's backend and pixel-ceiling configuration; landlock is forced off inside the worker so sandboxed operations never nest.
Development
bundle install
bundle exec rake # run the tests (nothing compiles)
docker/run.sh # run the suite on Debian bookworm's packaged libvips 8.14
bundle exec rubocop # lint
The minitest suite lives in test/*_test.rb; individual files run standalone
(bundle exec ruby test/operations_test.rb). Tests that depend on optional
host support — cjpegli, HEIC delegates, the Landlock sandbox — skip with an
explanation when that support is missing.
The suite includes golden-output checks, cross-backend parity checks (the claim is operation parity, not byte-for-byte output identity across ImageMagick/libvips versions), policy-denial checks, and a real-image atomic sandbox sweep over the full public operation list.
Gem packaging uses the standard Bundler tasks (rake build, rake install).
Releases: bump SafeImage::VERSION, merge to main, and CI publishes the
gem to RubyGems once the test matrix is green.
Security reporting
Please report suspected security issues privately to sam@discourse.org.
See SECURITY.md for the threat model, non-goals, and reporting
checklist.
License
Safe Image is MIT licensed.
The gem dynamically links to system libvips; libvips is
LGPL-2.1-or-later. Safe Image does not vendor libvips.
The gem bundles the DejaVu Sans font for deterministic letter-avatar
rendering; its license (Bitstream Vera derivative, freely redistributable)
ships alongside the font at lib/safe_image/fonts/DEJAVU-LICENSE.
Optional command-line tools are discovered at runtime and executed as external programs; they are not bundled into the gem. Typical licenses for those optional tools are:
| Tool | Purpose | Typical license |
|---|---|---|
ImageMagick magick / convert / identify |
:imagemagick backend operations |
ImageMagick license |
jpegoptim |
JPEG lossless optimisation / metadata stripping | GPL-2.0-or-later |
oxipng |
PNG lossless optimisation | MIT |
pngquant |
optional lossy PNG quantisation | GPL-3.0-or-later / ISC / BSD-2-Clause components |
cjpegli / djpegli / cjxl |
optional JPEG/JPEG XL tooling when installed | BSD-3-Clause via libjxl |
heif-enc / libheif tools |
optional HEIC/AVIF tooling when installed | LGPL-3.0-or-later |
Deployment packages may vary; check your distribution's package metadata if license compliance depends on the exact binary build.