Project

safe_image

0.0
The project is in a healthy, maintained state
Safe Image is a small Ruby image-processing boundary for untrusted uploads: direct libvips thumbnails/probing, hardened ImageMagick compatibility operations, optimisation, SVG sanitising, and optional atomic Landlock sandbox execution.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

Runtime

~> 3.4
 Project Readme

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 no max_pixels is 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 @file indirection
  • 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:

  • libvips headers and library, discoverable through pkg-config vips

Optional command dependencies:

  • magick / convert and identify for ImageMagick compatibility operations
  • jpegoptim for JPEG optimisation
  • oxipng for PNG lossless optimisation
  • pngquant for optional lossy PNG optimisation
  • cjpegli for optional Jpegli JPEG encoding of generated JPEGs

Ruby runtime dependency:

  • rexml for SVG sanitising

Optional Ruby dependency:

  • landlock for 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.gem
require "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
  • png
  • webp
  • 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
  • png
  • webp
  • avif

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")   # => :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 for a local file, defaulting to 1 when no orientation is present or when ImageMagick cannot report it.

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.

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 http and https URLs are accepted
  • redirects are capped
  • open/read timeouts and an overall total_timeout are capped
  • response size is capped by max_bytes
  • public remote fetches are limited to ports 80 and 443 by default
  • Net::HTTP environment 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/Host request 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-Type must be an allowed image type and must agree with an image-looking URL extension when one is present
  • downloaded content is probed before fetch_remote yields 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.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

When 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:

  • :imagemagick default compatibility path
  • :vips direct 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 to jpegoptim --max
  • metadata is stripped unless strip_metadata: false

PNG path:

  • uses oxipng for lossless optimisation
  • when mode: :lossy, uses pngquant first for PNGs smaller than 500 KB, then oxipng

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_pixels checks 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 DOCTYPE and XML processing instructions, removes comments and disallowed elements, converts CDATA to escaped text, and blocks event handlers, external URLs, and javascript: / 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
  • @file indirection 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?   # => true

After SafeImage.enable_sandbox!, every public operation routes through the sandbox worker:

  • probe
  • type
  • size
  • dimensions
  • info
  • orientation
  • thumbnail
  • optimize
  • resize
  • crop
  • downsize
  • convert
  • convert_to_jpeg compatibility alias
  • fix_orientation
  • convert_favicon_to_png
  • frame_count
  • animated?
  • letter_avatar
  • optimize_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       # 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.

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.