
Hot Cell¶
Securely run untrusted code on untrusted inputs. Hot Cell moves that work out of your application and into an unprivileged sibling container: no network, no credentials, and nothing on its filesystem worth stealing.
Inputs and outputs travel as file descriptors over a UNIX socket on a shared volume. Each call is a remote procedure call (RPC): your application calls an ordinary Ruby method. Hot Cell forwards the arguments to the container, runs the work in a forked worker process under strict limits, and returns the result or writes it to the output file.
Status¶
This is pre-release software. It may break in interesting ways. Use it with caution until the v1.0 release.
The Active Storage gems need Rails 8.2, which is unreleased. Track rails/rails main until it ships.
See Active Storage operations.
The reference manual is online at https://basecamp.github.io/hotcell/.
Why would I use this?¶
Your Rails application accepts uploads, so somewhere in it there's a line like this:
blob.variant(resize_to_limit: [ 800, 600 ]).processed
An attacker just handed you a crafted file, and Rails is about to hand it to libvips: a few hundred thousand lines of C whose whole job is to guess at file formats and delegate them to other format-specific libraries that you may not know about. libvips, ImageMagick, ffmpeg, and LibreOffice all have long histories of memory-safety bugs, and by default each one runs in the container that holds your database credentials, your session secret, and a route to every network service that the app uses.
Hot Cell gives that work its own container and its own forked process, holding nothing an attacker wants. Remove libvips and ffmpeg from your application image and keep them in the cell. Code execution there gets an attacker a read-only input descriptor, a write-only output descriptor, and the scratch of whatever else the cell is converting. The blast radius is much smaller than if that attack succeeded in your application code.
For Rails, Hot Cell ships drop-in replacements for the Active Storage analyzers, transformers, and previewers, so adopting it takes configuration changes, not code changes. In our environment, Hot Cell adds about 8 milliseconds per call and one more container on each host. We think that's a very good trade for the improved security posture.
Hot Cell isn't limited to media conversion:
- You can configure several cells on each host.
- A call can pass several input and output files.
- Custom operations have a simple
#performAPI, like Active Job's. - Cell limits are configurable: memory, wall-clock time, disk usage, and more.
- You can bring your own container image, checked by the included conformance test.
- You can set how often workers are forked again, to trade isolation for performance.
So you could use Hot Cell for ZIP files, or for compute that might hog the CPU. If something puts your trusted application at risk, move it into a cell.
The gems¶
| Gem | Runs in | Contains |
|---|---|---|
hotcell-core |
both sides | The wire protocol, descriptor passing, payload validation, and the error taxonomy. |
hotcell-client |
the application | HotCell::Client, cell registration, routing, classification, and instrumentation. |
hotcell-server |
the cell | The supervisor, the worker, HotCell::Operation, and the container image. |
activestorage-hotcell-client |
the application | The transformers, analyzers, and previewers that Rails is configured with. |
activestorage-hotcell-server |
the cell | The Active Storage operations. |
yabeda-hotcell |
the application | Yabeda metrics for every call and for each local cell. |
The gems are in one repository because they're developed together. The Active Storage gems may move to another repository later.
Using the Active Storage operations¶
This section runs Active Storage's variants, analysis, and previews in a cell. Your application code doesn't change, but you deploy a second container beside the application: a Kamal accessory, or your infrastructure's equivalent sidecar.
The examples use libvips, mutool, and ffmpeg. If your application uses variant_processor = :magick,
use the Magick classes instead of the Vips classes. See
Active Storage operations.
1. Install¶
Add the client gem to the application:
# Gemfile -- the application
gem "activestorage-hotcell-client"
Run bin/rails hotcell:install. It creates a hotcell/ directory in the application root that holds
everything about the cell, separate from the client configuration and the application code:
hotcell/Gemfile: the gems that the operations need.hotcell/Dockerfile: the recipe for the cell's image.hotcell/config.rb: the cell's own settings.hotcell/operations/: the Ruby files that the cell loads at boot.
Add the server gem to the cell's Gemfile, and require the operations that match the classes that the
application uses. Requiring an operation's file is what makes the cell serve it.
# hotcell/Gemfile -- the cell
gem "activestorage-hotcell-server"
# hotcell/operations/active_storage.rb
require "active_storage/hot_cell/server/transformers/image/vips"
require "active_storage/hot_cell/server/analyzers/image/vips"
require "active_storage/hot_cell/server/analyzers/media/ffprobe"
require "active_storage/hot_cell/server/previewers/pdf/mutool"
require "active_storage/hot_cell/server/previewers/video/ffmpeg"
2. Configure the application¶
Register the cell in an initializer, with one of your exception classes for permanent failures and one for transient failures:
# config/initializers/hotcell.rb
HotCell.root = ENV["HOTCELL_ROOT"] # unset means every cell is off
HotCell.group = ENV["HOTCELL_GROUP"] # the gid shared between app and cell
HotCell.register "active_storage",
permanent: MyApp::UnprocessableUpload,
transient: MyApp::ConversionTemporarilyUnavailable
# Warns at boot about a cell that is unreachable, slower than this client waits, in the wrong group, or on another hotcell version.
Rails.application.config.after_initialize { HotCell.describe_cells }
The cell's sockets are in $HOTCELL_ROOT/active_storage. If HOTCELL_ROOT is unset, every variant,
analysis, and preview raises HotCell::CellNotConfigured rather than falling back to the application. See
Client API and Response codes.
Then tell Rails which classes to use:
# config/application.rb
config.active_storage.variant_processor = ActiveStorage::HotCell::Client::Transformers::Image::Vips
config.active_storage.analyzers = [ ActiveStorage::HotCell::Client::Analyzers::Image::Vips,
ActiveStorage::HotCell::Client::Analyzers::Video::FFprobe,
ActiveStorage::HotCell::Client::Analyzers::Audio::FFprobe ]
config.active_storage.previewers = [ ActiveStorage::HotCell::Client::Previewers::Pdf::Mutool,
ActiveStorage::HotCell::Client::Previewers::Video::FFmpeg ]
For every class that you name, the cell must load the matching operation, and the cell's image must install the library or tool. You can mix these classes with Rails' own. See Active Storage operations.
3. Run it in development¶
Run the cell as a plain process, managed by foreman beside the Rails server. A containerized cell works
only on Linux, because descriptor passing doesn't cross the VM that runs containers on macOS. The resource
limits and the deadline apply either way. The cell keeps its own bundle, from the same hotcell/Gemfile
that the image build copies, so the two sides stay separate in development as they are in production.
Add the cell to Procfile.dev:
web: HOTCELL_ROOT=$PWD/tmp/hotcell-sockets bin/rails server
cell: BUNDLE_GEMFILE=$PWD/hotcell/Gemfile HOTCELL_CONFIG=$PWD/hotcell/config.rb HOTCELL_OPERATIONS=$PWD/hotcell/operations HOTCELL_DIR=$PWD/tmp/hotcell-sockets/active_storage bundle exec hotcell --development
bin/dev then boots both, and the app finds the sockets under tmp/hotcell-sockets. Keep
--development: without it, the cell empties the system temporary directory at boot. See
Development mode.
4. Set the cell's limits¶
Declare the cell's limits in hotcell/config.rb:
# hotcell/config.rb
HotCell.limits concurrency: 4, queue_size: 8, deadline: 60, memory: 1536 * 1024**2
Each operation declares its own limits, and the cell clamps them to its own. See Cell settings for every setting and Tuning for how to choose the numbers.
5. Build and deploy the cell¶
Hot Cell publishes no base image. Customize the installed Dockerfile for your application, starting with
the system packages that your operations need:
# hotcell/Dockerfile (excerpt)
RUN apt-get update && \
apt-get install -y --no-install-recommends libvips42 mupdf-tools ffmpeg && \
rm -rf /var/lib/apt/lists/*
Match the OMP_NUM_THREADS that the Dockerfile sets to the container's cpus. Without that bound, a
cell on a large host dies. See Bound the OpenMP thread pools.
Build the image from the hotcell/ directory, and deploy it as a second container on the same host as the
application, sharing one volume for the sockets. With Kamal, that's one accessory for each cell:
# config/deploy.yml -- the cell
accessories:
active_storage: # the cell's name; the app registers it under this
image: your.registry.com/your-image:latest
roles: [ web, jobs ] # a cell always lives on its caller's host
network: none # an accessory key, never an option
volumes:
- hotcell-sockets:/run/hotcell/cell # directory containing the IPC sockets
options:
# Performance. Docker applies no limit if unspecified.
cpus: 2
memory: 2g
memory-swap: 2g # equal to memory, or swap defeats the limit
# Security. Be cautious changing these, as that may impact security posture.
read-only: true
cap-drop: ALL
security-opt: no-new-privileges:true
user: 10001:10001
pids-limit: 512
# Both: size=512m is performance, the three flags before it are security.
tmpfs: /tmp:rw,nosuid,nodev,noexec,size=512m
env:
clear:
HOTCELL_DIR: /run/hotcell/cell # where this cell writes its two sockets
The application mounts the same volume:
# config/deploy.yml -- the app
servers:
web:
hosts: [ ... ]
options:
group-add: 10001 # the cell's gid, and what admits the app to its sockets
jobs:
hosts: [ ... ]
options:
group-add: 10001
volumes:
- hotcell-sockets:/run/hotcell/active_storage # $HOTCELL_ROOT/<registered cell name>
env:
clear:
HOTCELL_ROOT: /run/hotcell
HOTCELL_GROUP: 10001 # must match group-add above
The two mount paths differ, and only the volume name must match: a cell writes its sockets to
HOTCELL_DIR, and the app finds a cell by name under HOTCELL_ROOT. Give each cell its own volume.
Container explains every flag and how to check a deployed accessory. Scratch covers moving scratch off the tmpfs. If your image installs ImageMagick, set its limits too. See ImageMagick.
6. Remove the packages from the application image¶
After the cell handles a file type, remove that type's packages, such as libvips, from the application image. That removal is the security improvement.
Don't remove a package before the cell handles its file type. Rails' own previewers and analyzers look for
their tool in accept?, so a package removed too early turns that processing off without an error.
Using custom operations¶
The hotcell/ directory, the limits, the container, and the deployment all stay the same. You write both
sides of the call.
In the application, a client names its cell and the operation's routing name:
class TransformImage < HotCell::Client
hotcell "images"
operation "images.transform"
end
result = TransformImage.perform_in_hotcell source, destination, format: "png"
In the cell, an operation answers to the same name, and perform receives the descriptors and the
payload as keyword arguments:
class TransformImageOperation < HotCell::Operation
operation "images.transform"
limits deadline: 30, memory: 1280 * 1024**2
before_fork { require "my_image_processor" }
def perform(inputs, outputs, format:)
MyImageProcessor.convert inputs.first.fd_path, outputs.first.fd_path, format: format
{ format: format }
end
end
Register the images cell in the application's initializer, as in
Configure the application. The cell's Gemfile names hotcell-server
directly, plus whatever gems the operation uses. Put the operation's file in hotcell/operations/, and
install the tools that it runs in the Dockerfile. config.rb doesn't change: the operation's own
limits come with its class, clamped to the cell's.
Operation API and Client API cover the rest.
Running Hot Cell in production¶
Set these alerts. Observability explains each signal.
- Cell availability: the
upgauge is 0 or absent for any cell on any host. - Failed calls: the
requestscounter shifts away fromok, especially towardunavailable. - Queue headroom:
queuednearsqueue_size,queue_high_waterrises, orcapacityappears in steady state. - Scratch space: free space on each host's scratch runs low.
- Cell errors: any
ERRORevent in the cell log, or a rise in thekilledgauge.
Documentation¶
The docs have these sections:
- Reference manual: every part of Hot Cell, one topic per page, for agents and for readers who want the details.
- Design: the intent behind Hot Cell's design, including the threat model and the invariants that the design exists to hold.
- Contributing to Hot Cell: working on the gems, keeping the docs current, the experiments that the design rests on, and the decision records.