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, favicon
conversion, and letter-avatar generation. The default fast path uses a tiny
native extension that calls libvips directly. Compatibility paths use
ImageMagick, but with shell-free command execution and a restrictive bundled
policy.
Safe Image started as a Discourse extraction: the public surface intentionally covers the image operations Discourse performs today. The useful part is more general: hostile image bytes are a lousy thing to spread across model callbacks, upload helpers, optimizer wrappers, and hand-built command strings.
Security model
Safe Image is not magic pixie dust. It is a deliberately small choke point.
What it does:
- 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
- blocks libvips ImageMagick loader classes in the native extension
- 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,PCL -
MSL,MVG -
HTTP,HTTPS,URL - delegates, filters, and
@fileindirection
-
- supports optional Landlock subprocess sandboxing on Linux
The ImageMagick backend is explicit. Safe Image will not silently fall from the libvips path into generic ImageMagick decoding.
Install
System dependency:
-
libvipsheaders and library, discoverable throughpkg-config vips
Optional command dependencies:
-
magick/convertandidentifyfor ImageMagick compatibility operations -
jpegoptimfor JPEG optimisation -
oxipngfor PNG lossless optimisation -
pngquantfor optional lossy PNG optimisation -
cjpeglifor optional Jpegli JPEG encoding of generated JPEGs
Ruby runtime dependency:
-
rexmlfor SVG sanitising
Optional Ruby dependency:
-
landlockfor Linux subprocess sandboxing
landlock is intentionally not a gem dependency. Install it in the host
application if you want sandboxing.
cjpegli is also intentionally optional. On Arch it is provided by libjxl;
on macOS it is commonly installed via brew install jpeg-xl; Debian/Ubuntu
package names vary by release (libjpegli-tools where available). Safe Image
detects it at runtime and falls back unless the caller explicitly requests
encoder: :cjpegli.
gem build safe_image.gemspec
gem install ./safe_image-0.1.0.gemrequire "safe_image"
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
)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", or "sandboxed-..."
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"]
}Core API
SafeImage.probe(path, max_pixels: nil)
Reads image metadata through the direct libvips backend.
Supported inputs:
-
jpg/jpeg pngwebp-
heic/heif avif
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.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,
backend: :vips, # :vips or :imagemagick
optimize: true,
optimize_mode: :lossless, # :lossless or :lossy for PNG optimisation
execution: :inline, # :inline, :sandbox, :sandbox_if_available
encoder: :auto, # :auto, :cjpegli, :vips, :imagemagick for JPEG output
chroma_subsampling: :auto # :auto, "420", "422", "444"
)Supported outputs for the direct libvips backend:
-
jpg/jpeg pngwebpavif
execution: :sandbox is fail-closed: it raises if Landlock is unavailable.
execution: :sandbox_if_available uses the sandbox only when available.
JPEG encoder selection
Safe Image separates encoding generated JPEGs from optimising existing
JPEGs. This avoids hiding a lossy re-encode behind a method named optimize.
| Operation |
encoder: :auto behavior |
|---|---|
thumbnail / resize / crop / downsize to JPEG with backend: :vips
|
use cjpegli when installed; otherwise use normal libvips JPEG output |
convert("input.png", "output.jpg", format: "jpg") |
use cjpegli when installed; otherwise use ImageMagick compatibility path |
convert from HEIC/WebP/AVIF/GIF/JPEG to JPEG |
fall back to ImageMagick compatibility path; cjpegli is not treated as a universal decoder |
optimize("existing.jpg") |
use jpegoptim; never use cjpegli by default |
Encoder controls:
| Option | Meaning |
|---|---|
encoder: :auto |
best available default with safe fallback |
encoder: :cjpegli |
require Jpegli and fail closed if unavailable/unsupported |
encoder: :vips |
force normal libvips JPEG output where available |
encoder: :imagemagick |
force ImageMagick compatibility output |
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.
Local metadata helpers
These helpers are intended to cover the local-file parts of APIs like
FastImage.type, FastImage.size, and FastImage#orientation without adding a
Ruby dependency. They do not fetch remote URLs.
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") # => :svgJPEG 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 for a local file, defaulting to 1 when no
orientation is present or when ImageMagick cannot report it.
SafeImage.orientation("upload.jpg") # => 1SafeImage.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 # => 1animated: and orientation: default to false because they may require extra
ImageMagick work. When disabled, those fields are nil.
Remote metadata helpers
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)
# => :pngSafeImage.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 # => trueSafeImage.remote_animated?(url, ...)
SafeImage.remote_animated?("https://example.com/image.webp", max_bytes: 10.megabytes)
# => true / falseSafeImage.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)
endWhen global Landlock is enabled, 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.
Compatibility API
These methods are shaped around the image operations Discourse currently performs. They are useful outside Discourse too, but the names are deliberately boring because they map to common upload-pipeline tasks.
SafeImage.resize(from, to, width, height, quality: nil, backend: :imagemagick, optimize: true, max_pixels: nil, encoder: :auto, 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, backend: :vips, quality: 85)Backends:
-
:imagemagickdefault compatibility path -
:vipsdirect libvips path
SafeImage.crop(from, to, width, height, quality: nil, backend: :imagemagick, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)
Creates a north-cropped image. This matches the avatar/optimized-image crop shape used by Discourse.
SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240)
SafeImage.crop("upload.jpg", "avatar.jpg", 240, 240, backend: :vips)SafeImage.downsize(from, to, dimensions, backend: :imagemagick, optimize: true, max_pixels: nil, quality: 85, encoder: :auto, 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>", backend: :vips)
SafeImage.downsize("large.png", "small.png", "400000@", backend: :vips)The direct 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, encoder: :auto, chroma_subsampling: :auto)
Converts an input image to an explicit output format:. Unsupported formats
raise SafeImage::UnsupportedFormatError.
For JPEG output, encoder: :auto uses cjpegli when it is installed and the
input can be encoded directly by Jpegli. Today that direct path is intentionally
limited to PNG input; other formats fall back to the hardened ImageMagick
compatibility backend. Use encoder: :cjpegli to require Jpegli and fail closed,
or encoder: :imagemagick to force the compatibility path.
SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85)
SafeImage.convert("upload.png", "upload.jpg", format: "jpg", quality: 85, encoder: :cjpegli)
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)
Applies EXIF orientation through ImageMagick. 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 frame and writes PNG.
SafeImage.convert_favicon_to_png("favicon.ico", "favicon.png")SafeImage.frame_count(path, max_pixels: nil)
Returns the frame count using the hardened ImageMagick identify path.
frames = SafeImage.frame_count("animated.gif")SafeImage.animated?(path, max_pixels: nil)
Returns true when frame_count(path) > 1.
SafeImage.animated?("animated.webp")SafeImage.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "NimbusSans-Regular")
Generates a square letter avatar PNG.
SafeImage.letter_avatar(
output: "avatar.png",
size: 360,
background_rgb: [1, 2, 3],
letter: "S",
font: "Adwaita-Sans"
)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.
SafeImage.optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)
Compatibility wrapper around SafeImage.optimize.
SafeImage.optimize_image!("image.jpg")
SafeImage.optimize_image!("image.png", allow_lossy_png: true)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.
Security posture
Safe Image is a hardened boundary for untrusted image processing, not a magic wand. The goal is to centralize risky operations, make the safe path boring, and remove common image-processing foot-guns.
Baseline hardening:
- external commands use argv arrays, never shell strings
- command environment, temp/home/cache directories, stdout/stderr size, and process-group timeout cleanup are controlled
- libvips loaders are selected explicitly from an allowlist
- libvips' untrusted-operation block is enabled in-process
- libvips ImageMagick loader classes are blocked in the native extension
- libvips cache is disabled by default in-process
- local untrusted input/output paths reject symlinks and symlinked path components
- generated images strip metadata where applicable
-
max_pixelschecks fail before expensive work; the libvips path applies a default 128MP ceiling (SafeImage::DEFAULT_MAX_PIXELS) when none is supplied - remote fetch uses SSRF hardening: 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
- SVG metadata uses a bounded parser; SVG is not handed to ImageMagick for probing
- SVG sanitising is conservative and allowlist based; it 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
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.
Security posture without Landlock
Without Landlock, Safe Image still hardens the ImageMagick path substantially:
- delegates are disabled
- filters are disabled
-
@fileindirection is disabled - remote URL coders are disabled
- Ghostscript/document/vector formats are denied
- coders are deny-by-default with a small raster allowlist
- commands are executed with argv arrays, not shell strings
- command environment and ImageMagick policy path are controlled
- ImageMagick resource limits are 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 enabled.
SafeImage.sandbox_available? # => true/false
SafeImage.enable_sandbox! # raises if unavailable
SafeImage.sandbox_enabled? # => trueAfter SafeImage.enable_sandbox!, every public operation routes through the
sandbox worker:
probetypesizedimensionsinfoorientationthumbnailoptimizeresizecropdownsizeconvert-
convert_to_jpegcompatibility alias fix_orientationconvert_favicon_to_pngframe_countanimated?letter_avataroptimize_image!sanitize_svg!
There is no silent fallback after global sandbox enablement. 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.
Discourse parity
Safe Image currently covers the image-operation surface Discourse performs in:
- optimized image generation
- upload preprocessing
- thumbnail generation
- avatar cropping / letter avatars
- favicon conversion
- HEIC/PNG-to-JPEG conversion
- orientation fixing
- animated image detection
- JPEG/PNG optimisation
- SVG sanitising
The claim is operation parity, not byte-for-byte output identity across all ImageMagick/libvips versions. The test suite includes golden compatibility checks, ImageMagick parity checks, policy-denial checks, and a real-image atomic sandbox sweep over the full public operation list.
Development
bundle install
bundle exec rake # compile the native extension and run the tests
bundle exec rubocop # lintThe 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.
Gem packaging uses the standard Bundler tasks (rake build, rake install).
Releases follow the Discourse gem publication flow: 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.
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
|
compatibility 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.