Skip to content

Reject vips transformations that dispatch arbitrary libvips operations - #3025

Open
jeremy wants to merge 3 commits into
mainfrom
security/vips-transformation-allowlist
Open

Reject vips transformations that dispatch arbitrary libvips operations#3025
jeremy wants to merge 3 commits into
mainfrom
security/vips-transformation-allowlist

Conversation

@jeremy

@jeremy jeremy commented Aug 6, 2026

Copy link
Copy Markdown
Member

The defect

CVE-2025-24293 (rails/rails 1ca278a6a7, "Active Storage: Remove dangerous transformations") removed apply, loader, and saver from ActiveStorage.supported_image_processing_methods. But that allowlist is only enforced by the ImageMagick transformerImageMagick#validate_transformation is the sole reader of supported_image_processing_methods in the whole tree. The vips transformer (transformers/vips.rb, 15 lines) only defines processor; it never overrides validate_transformation, and the base ImageProcessingTransformer#validate_transformation rejects only combine_options.

Fizzy renders variants with vips (Rails default under load_defaults 8.1). So on our path there is no method-name enforcement at all — not merely a three-method gap. A signed variation key picks the method.

Worth naming because it is why this was easy to miss: the CVE commit names neither ImageMagick nor mini_magick. git show 1ca278a6a7 --stat touches exactly two files (lib/active_storage.rb, test/models/variant_test.rb) and no CHANGELOGactivestorage/CHANGELOG.md at our pinned Rails revision contains zero mention of the CVE. The only in-repo signal that the fix is ImageMagick-only is that both changed tests sit inside process_variants_with :mini_magick blocks; the :vips blocks in the same file were never extended.

Every transformation name reaches libvips by dispatch

ImageProcessing's builder handles apply, loader, and saver itself, and sends every other name through Chainable#method_missing into the operations list, where Processor#apply_operation hands it to Vips::Image#public_send:

# Processor#apply_operation
receiver = respond_to?(name) ? self : @accumulator   # @accumulator is a Vips::Image
receiver.public_send(name, *args, &block)

That leaves three distinct primitives:

loader: { loader: "x" }  ->  Vips::Image.public_send(:"xload", path, **options)
saver:  { saver:  "x" }  ->  image.public_send(:"xsave", path, **options)
<any other name>         ->  image.public_send(:<name>, argument)

and apply launders all three: it re-enters the builder for each of its entries, and those entries are never revalidated, because only top-level transformations reach validate_transformation.

config/initializers/vips.rb already calls Vips.block_untrusted(true), which gates untrusted loaders — so loader: { loader: "openslide" } is blocked. Savers are not flagged untrusted, and direct dispatch is not gated at all.

Threat model — defense-in-depth, not standalone RCE

A variation key is a signed serialization of the transformations hash (signed with secret_key_base); an attacker cannot forge one without the key. This is defense-in-depth on the live RAILS_MASTER_KEY-exfiltration incident (HEY #3916984): if the key leaks, arbitrary Vips::Image method dispatch is the attacker's next hop.

The impact of that next hop is worse than this PR first claimed. For the direct-dispatch case the argument is the destination path, so it is not confined to Active Storage's tempfile — it is arbitrary-path write, and on our locked image_processing 1.14.0 the accumulator's inherited Object methods are reachable too.

Reproduction

Replicating ImageProcessingTransformer#process on the vips path at Fizzy's locked image_processing 1.14.0 / ruby-vips 2.2.5 / libvips 8.18.4, with Vips.block_untrusted(true) on, and with the first version of this guard (rejecting only apply + nested loader:/saver:) in place:

== Cases that first guard rejected ==
  apply: {resize_to_limit:[100,100]}     GUARD REJECTED                    -
  loader: {loader: 'magick'}             GUARD REJECTED                    -
  saver: {saver: 'dz'}                   GUARD REJECTED                    -

== Direct operation names — NOT rejected by that guard ==
  dzsave: '<path>'                       NoMethodError (nil) AFTER write   WROTE 36 entries
  csvsave: '<path>'                      NoMethodError (nil) AFTER write   WROTE 1 entry
  matrixsave: '<path>'                   NoMethodError (nil) AFTER write   WROTE 1 entry
  write_to_file: '<path>'                NoMethodError (nil) AFTER write   WROTE 1 entry
  instance_eval: 'File.write(...)'       NoMethodError AFTER execution     WROTE 1 entry

== Non-vacuity: legitimate transformations ==
  resize_to_limit: [50,50]               PROCESSED                         -
  loader:{n:-1} + resize (VARIANTS)      PROCESSED                         -
  saver: {quality: 80}                   PROCESSED                         -

Each dangerous case does raise — on the operation's nil return, when the pipeline tries to save it. The side effect has already landed by then. The exception is not a defense, which is why the tests assert the absence of the file, not just the exception class.

The fix

Prepend a guard onto ActiveStorage::Transformers::Vips (via the app's lib/rails_ext + to_prepare idiom) that enforces ActiveStorage.supported_image_processing_methods, the way the ImageMagick transformer does.

The allowlist admits nothing dangerous on this path. Measuring that needs care, because the reachable surface is wider than Vips::Image's own methods: Vips::Image#method_missing resolves an unknown name against libvips operation nicknames, so an allowlisted name reaches those too. Counting all three routes, 30 of its 284 names resolve to anything at all:

  • 6 reach an ImageProcessing macro (resize_to_limit, resize_to_fit, resize_to_fill, resize_and_pad, rotate, composite)
  • 21 reach a real libvips operation (affine, canny, clamp, colourspace, complex, copy, crop, flatten, flip, gamma, gravity, insert, morph, mosaic, resize, scale, sharpen, thumbnail, cache, plus the two macro-shadowed ones)
  • the rest are pure accessors (clone, format, log, median, size)
  • 254 resolve to nothing

None of the 30 writes to disk or takes a filename, and every *save / *load nickname (dzsave, csvsave, matrixsave, rawsave, magickload, openslideload, pdfload, svgload, …) is absent from the list. The only Object method admitted is clone.

loader and saver stay permitted as namesCVE-2025-24293 removed them, but Attachments::VARIANTS signs loader: { n: -1 } into variant URLs that never expire, so dropping the names would break every existing animated-GIF URL. Only their nested same-name selector is rejected; ordinary option hashes (loader: { n: -1 }, saver: { quality: 80 }) keep working. apply needs no special case, being absent from the list.

Residual, out of scope for this PR

composite is on the upstream allowlist, and its ImageProcessing macro accepts a String path, which it opens with Vips::Image.new_from_file. So with a compromised key it still reads an arbitrary server-side file:

composite: "<a real image path>"   PROCESSED          # contents composited into the output
composite: "/etc/passwd"           Vips::Error: "is not a known file format"
composite: "/nonexistent/nope"     Vips::Error: "file ... does not exist"

Two distinguishable errors make that a file-existence oracle, and a readable image gets composited into the returned variant.

This is not a regression here and not something this guard introduces: composite is allowlisted upstream and behaves the same on the ImageMagick path, where validate_arg_string only screens for ImageMagick CLI flags and lets a plain path through. Closing it means departing from the upstream list, which is a separate decision from closing the dispatch primitive. Flagging it rather than folding it in.

Options considered

  1. Re-allow loader in the allowlist (what HEY does on its mini_magick path) — rejected: it re-opens the nested-selector dispatch it is meant to close.
  2. Reject only apply + nested loader:/saver: (this PR's first commit) — rejected: closes the three builder entry points but leaves direct dispatch to any Vips::Image (or Object) method wide open. Caught in review by Codex.
  3. Enforce the allowlist, re-permitting loader/saver as names (this PR) — closes the whole primitive, preserves loader: { n: -1 } and every immortal URL.
  4. Strip loader out of VARIANTS and internalize it (what bc3 did — "moved out of URLs into internal boilerplate") — cleaner long-term (stops putting loader options in immortal URLs) but materially more invasive, and orthogonal. Worth doing later; not required to fix this.

Holds across the pending image_processing 2.0 upgrade (#2922): 2.0 hardens apply against Kernel/Object methods but not against loader/saver dispatch or direct operation names, so this guard stays necessary.

Mutation tests

Each mutation was verified to have actually applied (marker grepped in the mutated file) before its run was trusted.

Mutation Expected Result
Neuter guard (validate_transformation → just super) all reject tests fail 7 failures, 5 non-vacuity pass
Reject everything (raise at top of guard) non-vacuity tests fail 5 errors (VARIANTS, loader:{n:-1}, saver:{quality:80}, resize_to_fill, resize)
Remove allowlist enforcement, keep nested-selector reject direct-dispatch + apply + unknown-name fail; nested pass 5 failures, nested loader/saver pass
Remove nested-selector reject, keep allowlist nested loader/saver fail; direct dispatch still caught 2 failures, rest pass

(Counts are for the final 14-test suite: neuter → 8, reject-all → 1 failure + 5 errors, no-allowlist → 6, no-nested-selector → 2.)

The last two are the load-bearing pair: they show neither half is redundant. The allowlist alone does not cover nested selectors (because loader/saver are permitted as names), and the nested-selector check alone does not cover direct dispatch.

With the fix in place: 14 tests, 23 assertions, 0 failures. Variant-adjacent suites (test/lib/rails_ext, avatar, comment, storage, exportable): 127 tests, 307 assertions, 0 failures. Full suite at f3f6dc4fe: 1556 tests, 5850 assertions, 0 failures, 0 errors, 3 skips.

Do not merge yet — coordinated with the bc3 and upstream Rails companions.

Active Storage's ImageMagick transformer enforces
ActiveStorage.supported_image_processing_methods, but the vips transformer we
run never reads that allowlist. So CVE-2025-24293's removal of apply, loader,
and saver is inert on our path, and a signed variation key can still carry them.

On vips those methods are not inert. ImageProcessing dispatches the loader and
saver by name -- Vips::Image.public_send(:"#{loader}load", ...) and
image.public_send(:"#{saver}save", ...) -- whenever the options carry a
nested loader:/saver: selector, and apply re-enters the builder to reach the
same selectors. config/initializers/vips.rb already calls
Vips.block_untrusted(true), which gates untrusted loaders, so
loader: { loader: "openslide" } is blocked -- but savers are not flagged
untrusted, so saver: { saver: "dz" } still runs dzsave (an unbounded on-disk
tile pyramid) and csv/matrix emit text served back as an image.

Prepend a guard onto ActiveStorage::Transformers::Vips that rejects apply and a
nested loader:/saver: selector, while still accepting ordinary option hashes --
notably loader: { n: -1 }, which Attachments::VARIANTS relies on to keep
animated GIF frames and which is signed into immortal variant URLs. The
mechanism holds at the locked image_processing 1.14.0 and survives the pending
2.0 bump (PR #2922), which hardens apply against Kernel methods but not against
loader/saver dispatch.
Copilot AI balanced review requested due to automatic review settings August 6, 2026 19:35

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f5638d55e2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/rails_ext/active_storage_vips_transformation_guard.rb Outdated
…/saver

Rejecting apply and the nested loader:/saver: selectors closed the three
builder entry points but left the wider primitive open: ImageProcessing's
Chainable#method_missing turns any *other* transformation name into an
operation, and Processor#apply_operation hands it to Vips::Image#public_send.
No allowlist is enforced anywhere on the vips path, so the name was free.

That is reachable with Vips.block_untrusted(true) on, and the argument is the
destination path rather than Active Storage's tempfile:

  dzsave: "<path>"        wrote a 36-entry tile pyramid where the key said
  csvsave/matrixsave      wrote image bytes out as text
  write_to_file: "<path>" wrote an arbitrary file
  instance_eval: "<ruby>" executed arbitrary Ruby on image_processing 1.14.0

Each raises afterwards, on the operation's nil return, so the side effect has
already landed by the time the pipeline fails. The exception is not a defense.

Enforce ActiveStorage.supported_image_processing_methods here the way the
ImageMagick transformer does. The list admits nothing dangerous on this path:
of its 284 names, 6 reach an ImageProcessing macro, 6 reach a pure Vips::Image
accessor, and 272 resolve to nothing. loader and saver stay permitted as names
because Attachments::VARIANTS signs loader: { n: -1 } into variant URLs that
never expire; only their nested same-name selector is rejected. apply needs no
special case now, being absent from the list.

Tests assert the absence of the filesystem side effect, not just the exception
class -- these operations raised before the guard too, after writing.
@jeremy

jeremy commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

🤖 @codex review

Pushed 2002650, which replaces the three-method rejection with allowlist enforcement per your P1. Please re-check the direct-dispatch surface in particular.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 2002650fe3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@jeremy
jeremy requested a balanced review from Copilot August 6, 2026 20:21
Vips::Image#method_missing resolves an unknown name against libvips operation
nicknames, so the surface an allowlisted name can reach is wider than
Vips::Image's own methods -- a fact the previous commit's comment missed. It
claimed 12 of the 284 allowlisted names resolve to anything; counting operation
dispatch too, it is 30: 6 ImageProcessing macros, 21 libvips operations, and a
few pure accessors.

The conclusion is unchanged, and now measured rather than assumed: none of the
30 writes to disk or takes a filename, and every *save/*load nickname is absent
from the list. Corrected the comment and added a test that a real operation
nickname (gaussblur, invert) is rejected despite libvips knowing it.

Also pin the super chain. The guard raises for its own cases but must keep
delegating, or the base transformer's combine_options check silently
disappears; nothing covered that.
@jeremy

jeremy commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

🤖 @codex review

Pushed f3f6dc4 on top of the commit you cleared. It corrects a measurement I got wrong (libvips operation nicknames are reachable via Vips::Image#method_missing, so 30 of the 284 allowlisted names resolve to something, not 12), adds a test that a real operation nickname is rejected, and pins the super chain so the base combine_options check can't silently vanish. No change to the guard's logic.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: f3f6dc4fe6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants