Glob injection / Path traversal
Description
This commit fixes a security vulnerability in ActiveStorage DiskService#delete_prefixed where a blob key containing glob metacharacters could be fed into Dir.glob, causing unintended file deletions. The fix escapes glob metacharacters in the resolved path and preserves a trailing separator when needed, preventing untrusted input from being treated as a glob pattern. It also adds tests demonstrating the escaped behavior and that only the intended files are deleted. The vulnerability aligns with CVE-2026-33202 (glob injection/path traversal).
Proof of Concept
# Proof-of-concept PoC illustrating the glob-injection risk and the mitigation
# This is a standalone Ruby demonstration showing the before/after behavior
# of Dir.glob when the path contains unescaped glob metacharacters in a key.
require 'tmpdir'
require 'fileutils'
Dir.mktmpdir("rails-glob-poc") do |root|
# Setup: create two distinct blob directories
FileUtils.mkdir_p(File.join(root, "blob[1]"))
File.write(File.join(root, "blob[1]", "danger.txt"), "delete me")
FileUtils.mkdir_p(File.join(root, "blobnormal"))
File.write(File.join(root, "blobnormal", "safe.txt"), "keep me")
# Vulnerable path usage (unescaped glob metacharacters in the key)
prefix = File.join(root, "blob[1]")
puts "[Before fix] Globbing with unescaped prefix: #{prefix}/*"
Dir.glob("#{prefix}/*").each { |p| FileUtils.rm_rf(p) }
puts "Remaining after vulnerable glob: #{Dir.glob("#{root}/**/*").sort.inspect}"
# Reset state for a safe demonstration of the fix
FileUtils.mkdir_p(File.join(root, "blob[1]"))
File.write(File.join(root, "blob[1]", "danger.txt"), "delete me")
# Escaped path usage (the fix escapes glob chars)
escaped_prefix = prefix.gsub(/[\[\]\*\?\{\}\\]/) { |c| "\\#{c}" }
puts "[After fix] Globbing with escaped prefix: #{escaped_prefix}/*"
Dir.glob("#{escaped_prefix}/*").each { |p| FileUtils.rm_rf(p) }
puts "Remaining after patched glob: #{Dir.glob("#{root}/**/*").sort.inspect}"
end
Commit Details
Author: Mike Dalessio
Date: 2026-03-13 14:59 UTC
Message:
Prevent glob injection in ActiveStorage DiskService#delete_prefixed
`Blob#delete` calls `DiskService#delete_prefixed` with a string that
includes the blob key. In turn, `DiskService#delete_prefixed` pass
that string to `Dir.glob`.
If a developer is generating custom blob keys (or has mistakenly
allowed untrusted input to be used as a blob key) and that key
contains glob metacharacters, then it may be possible to delete
unintended files. It may also be possible to delete unintended files
if `delete_prefixed` is called directly with a prefix containing glob
metacharacters.
Update `delete_prefixed` to:
- Update `delete_prefixed` to escape glob metacharacters in the
resolved path before passing to `Dir.glob`
- Extract a private method `escape_glob_metacharacters`.
Note that this change breaks any existing code that is relying on
`delete_prefixed` to expand glob metacharacters. This change presumes
that is unintended behavior (as other storage services do not respect
these metacharacters).
Also note that this is a defense-in-depth measure to limit the blast
radius of malicious keys, and is not a trust boundary.
[CVE-2026-33202]
[GHSA-73f9-jhhh-hr5m]
Triage Assessment
Vulnerability Type: Path Traversal / Glob Injection
Confidence: HIGH
Reasoning:
The commit fixes a security issue where Dir.glob could be fed untrusted blob keys containing glob metacharacters, potentially causing deletion of unintended files. It escapes glob metacharacters and adds tests, addressing a vulnerability (CVE-2026-33202) related to path/glob injection in ActiveStorage DiskService#delete_prefixed.
Verification Assessment
Vulnerability Type: Glob injection / Path traversal
Confidence: HIGH
Affected Versions: 8.1.0 through 8.1.2 (pre-8.1.3); fixed in 8.1.3
Code Diff
diff --git a/activestorage/lib/active_storage/service/disk_service.rb b/activestorage/lib/active_storage/service/disk_service.rb
index 163712cf01f09..10c9b27352245 100644
--- a/activestorage/lib/active_storage/service/disk_service.rb
+++ b/activestorage/lib/active_storage/service/disk_service.rb
@@ -60,7 +60,16 @@ def delete(key)
def delete_prefixed(prefix)
instrument :delete_prefixed, prefix: prefix do
- Dir.glob(path_for("#{prefix}*")).each do |path|
+ prefix_path = path_for(prefix)
+
+ # File.expand_path (called within path_for) strips trailing slashes.
+ # Restore trailing separator if the original prefix had one, so that
+ # the glob "prefix/*" matches files inside the directory, not siblings
+ # whose names start with the prefix string.
+ prefix_path += "/" if prefix.end_with?("/")
+
+ escaped = escape_glob_metacharacters(prefix_path)
+ Dir.glob("#{escaped}*").each do |path|
FileUtils.rm_rf(path)
end
end
@@ -187,6 +196,10 @@ def folder_for(key)
[ key[0..1], key[2..3] ].join("/")
end
+ def escape_glob_metacharacters(path)
+ path.gsub(/[\[\]*?{}\\]/) { |c| "\\#{c}" }
+ end
+
def make_path_for(key)
path_for(key).tap { |path| FileUtils.mkdir_p File.dirname(path) }
end
diff --git a/activestorage/test/service/disk_service_test.rb b/activestorage/test/service/disk_service_test.rb
index 305a728e05034..3663a08a168a1 100644
--- a/activestorage/test/service/disk_service_test.rb
+++ b/activestorage/test/service/disk_service_test.rb
@@ -161,6 +161,52 @@ class ActiveStorage::Service::DiskServiceTest < ActiveSupport::TestCase
end
end
+ test "path_for escapes all glob metacharacters" do
+ assert_equal "\\[", @service.send(:escape_glob_metacharacters, "[")
+ assert_equal "\\]", @service.send(:escape_glob_metacharacters, "]")
+ assert_equal "\\*", @service.send(:escape_glob_metacharacters, "*")
+ assert_equal "\\?", @service.send(:escape_glob_metacharacters, "?")
+ assert_equal "\\{", @service.send(:escape_glob_metacharacters, "{")
+ assert_equal "\\}", @service.send(:escape_glob_metacharacters, "}")
+ assert_equal "\\\\", @service.send(:escape_glob_metacharacters, "\\")
+ assert_equal "hello", @service.send(:escape_glob_metacharacters, "hello")
+ assert_equal "/path/to/\\[brackets\\]/file", @service.send(:escape_glob_metacharacters, "/path/to/[brackets]/file")
+ end
+
+ test "delete_prefixed with glob metacharacters only deletes matching files" do
+ base_key = SecureRandom.base58(24)
+ bracket_key = "#{base_key}[1]/file"
+ plain_key = "#{base_key}1/file"
+
+ @service.upload(bracket_key, StringIO.new("bracket"))
+ @service.upload(plain_key, StringIO.new("plain"))
+
+ @service.delete_prefixed("#{base_key}[1]/")
+
+ assert @service.exist?(plain_key), "file should not be deleted"
+ assert_not @service.exist?(bracket_key), "file should be deleted"
+ ensure
+ @service.delete(bracket_key) rescue nil
+ @service.delete(plain_key) rescue nil
+ end
+
+ test "delete_prefixed with trailing slash only deletes files inside the directory" do
+ base_key = SecureRandom.base58(24)
+ inside_key = "#{base_key}/file"
+ sibling_key = "#{base_key}_sibling"
+
+ @service.upload(inside_key, StringIO.new("inside"))
+ @service.upload(sibling_key, StringIO.new("sibling"))
+
+ @service.delete_prefixed("#{base_key}/")
+
+ assert @service.exist?(sibling_key), "sibling file should not be deleted"
+ assert_not @service.exist?(inside_key), "file inside directory should be deleted"
+ ensure
+ @service.delete(inside_key) rescue nil
+ @service.delete(sibling_key) rescue nil
+ end
+
test "can change root" do
tmp_path_2 = File.join(Dir.tmpdir, "active_storage_2")
@service.root = tmp_path_2