Fix FileNotFoundError when uploading an SVG icon
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

The sanitize_svg_icon before_validation callback called icon.download,
but Active Storage uploads pending blobs in before_save — so at
before_validation time the file only existed in the request tempfile,
not at the configured storage path. Read from the pending attachable
(UploadedFile / IO hash) instead. Guards against the recursive callback
that icon.attach would otherwise trigger by tracking the cleaned
attachable by object identity. Bumps to 0.13.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-06-07 16:43:24 +10:00
parent 03dfdbd83a
commit 5b41db2c6a
3 changed files with 68 additions and 9 deletions

View File

@@ -278,18 +278,52 @@ class Application < ApplicationRecord
end
def sanitize_svg_icon
return unless icon.content_type == "image/svg+xml"
# Runs in before_validation. The blob has NOT yet been uploaded to disk at
# this point (Active Storage uploads in before_save), so we cannot call
# icon.download — we must read from the pending attachable.
#
# icon.attach below re-sets attachment_changes and would re-fire this
# callback; we skip if the pending attachable is the cleaned hash we just
# installed (tracked by object identity).
change = attachment_changes["icon"]
return unless change
attachable = change.attachable
return if attachable.equal?(@svg_sanitized_attachable)
raw_svg, filename, content_type = read_pending_icon(attachable)
return unless raw_svg
return unless content_type == "image/svg+xml" || filename.to_s.downcase.end_with?(".svg")
raw_svg = icon.download
doc = Loofah.xml_document(raw_svg)
doc.scrub!(SvgScrubber.new)
clean_svg = doc.to_xml
icon.attach(
sanitized = {
io: StringIO.new(clean_svg),
filename: icon.filename.to_s,
filename: filename,
content_type: "image/svg+xml"
)
}
@svg_sanitized_attachable = sanitized
icon.attach(sanitized)
end
def read_pending_icon(attachable)
case attachable
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
content = attachable.read
attachable.rewind
[content, attachable.original_filename, attachable.content_type]
when Hash
io = attachable[:io] || attachable["io"]
return [nil, nil, nil] unless io
content = io.read
io.rewind if io.respond_to?(:rewind)
[content,
attachable[:filename] || attachable["filename"],
attachable[:content_type] || attachable["content_type"]]
else
[nil, nil, nil]
end
end
def icon_validation

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Clinch
VERSION = "0.13.0"
VERSION = "0.13.1"
end

View File

@@ -1,7 +1,32 @@
require "test_helper"
class ApplicationTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
test "sanitizes an SVG icon uploaded via UploadedFile (regression for FileNotFoundError)" do
app = applications(:kavita_app)
svg = %(<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><path d="M0 0"/></svg>)
tempfile = Tempfile.new(["icon", ".svg"]).tap do |t|
t.write(svg)
t.rewind
end
uploaded = ActionDispatch::Http::UploadedFile.new(
tempfile: tempfile,
filename: "icon.svg",
type: "image/svg+xml"
)
# Previously raised ActiveStorage::FileNotFoundError because the
# before_validation callback called icon.download before the blob was
# uploaded to disk.
assert_nothing_raised do
app.update!(icon: uploaded)
end
cleaned = app.icon.download
refute_match(/<script/i, cleaned)
assert_match(/<path/, cleaned)
ensure
tempfile&.close
tempfile&.unlink
end
end