CVE-2026-45775
Description
Discourse backup path traversal allows authenticated admin in multisite to access another site's local backups, fixed in versions 2026.1.4, 2026.3.1, 2026.4.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Discourse backup path traversal allows authenticated admin in multisite to access another site's local backups, fixed in versions 2026.1.4, 2026.3.1, 2026.4.1.
Vulnerability
A path traversal vulnerability exists in Discourse backup handling for multisite deployments with locally stored backups (backup_location = local). Affected versions are 2026.1.0-latest to before 2026.1.4, 2026.3.0-latest to before 2026.3.1, and 2026.4.0-latest to before 2026.4.1. The vulnerability allows an authenticated administrator on one site to craft a backup download request with a traversal payload (e.g., ..%2F) and access backup files belonging to another site on the same host [1].
Exploitation
An attacker must be an authenticated administrator on Site A of a multisite Discourse deployment that stores backups locally. The attacker crafts a specially crafted backup download request containing path traversal sequences, such as encoded ..%2F patterns, to navigate the filesystem and retrieve backup files from Site B. No additional authentication is required beyond admin access to the attacker's own site [1].
Impact
Successful exploitation results in the disclosure of sensitive backup data from another site (Site B) on the same host. This compromises confidentiality, potentially exposing user data, site configuration, and other sensitive information. The attacker gains read access to backup files without authorization from the target site [1].
Mitigation
The vulnerability is patched in Discourse versions 2026.1.4, 2026.3.1, 2026.4.1, and 2026.5.0-latest.1. Users unable to upgrade immediately can switch backup storage to S3, restrict backup access to trusted operators, and monitor admin backup access logs for suspicious traversal-style requests [1].
AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2>=2026.1.0-0,<2026.1.4+ 1 more
- (no CPE)range: >=2026.1.0-0,<2026.1.4
- (no CPE)range: <2026.1.4 || >=2026.3.0 <2026.3.1 || >=2026.4.0 <2026.4.1
Patches
393b0fc608d12SECURITY: block cross-site backup traversal in multisite local storage [backport 2026.4]
6 files changed · +97 −2
app/controllers/admin/backups_controller.rb+6 −0 modified@@ -7,6 +7,7 @@ class Admin::BackupsController < Admin::AdminController include ExternalUploadHelpers before_action :ensure_backups_enabled + before_action :ensure_valid_backup_id, only: %i[show email destroy restore] skip_before_action :check_xhr, only: %i[index show logs check_backup_chunk upload_backup_chunk] skip_before_action :ensure_backups_enabled, only: %i[show status index email] @@ -271,6 +272,11 @@ def valid_filename?(filename) !!(/\A[a-zA-Z0-9\._\-]+\z/ =~ filename) end + def ensure_valid_backup_id + backup_id = params.fetch(:id) + raise Discourse::NotFound unless valid_filename?(backup_id) && valid_extension?(backup_id) + end + def render_error(message_key) render json: failed_json.merge(message: I18n.t(message_key)) end
lib/backup_restore/local_backup_store.rb+8 −1 modified@@ -55,7 +55,14 @@ def unsorted_files end def path_from_filename(filename) - File.join(@base_directory, filename) + path = File.expand_path(File.join(@base_directory, filename)) + base_directory = File.expand_path(@base_directory) + + if path.start_with?("#{base_directory}#{File::SEPARATOR}") + path + else + raise Discourse::InvalidParameters.new(:filename) + end end def create_file_from_path(path, include_download_source = false)
lib/route_format.rb+1 −1 modified@@ -6,6 +6,6 @@ def self.username end def self.backup - /.+\.(sql\.gz|tar\.gz|tgz)/i + /[a-zA-Z0-9._-]+\.(sql\.gz|tar\.gz|tgz)/i end end
spec/lib/backup_restore/local_backup_store_spec.rb+34 −0 modified@@ -19,6 +19,40 @@ expect(store.remote?).to eq(false) end + describe "path traversal protection" do + let(:filename) { "a.tgz" } + + before do + create_local_backup_file( + root_directory: @root_directory, + db_name: "default", + filename: filename, + last_modified: "2018-02-11T09:27:00Z", + size_in_bytes: 29, + ) + end + + it "raises an error when reading a backup outside the current site's directory" do + expect { store.file("../second/multi-1.tar.gz") }.to raise_error(Discourse::InvalidParameters) + end + + it "raises an error when deleting a backup outside the current site's directory" do + expect { store.delete_file("../second/multi-1.tar.gz") }.to raise_error( + Discourse::InvalidParameters, + ) + end + + it "raises an error when downloading a backup outside the current site's directory" do + Dir.mktmpdir do |path| + destination_path = File.join(path, filename) + + expect { store.download_file("../second/multi-1.tar.gz", destination_path) }.to raise_error( + Discourse::InvalidParameters, + ) + end + end + end + def create_backups create_local_backup_file( root_directory: @root_directory,
spec/lib/route_format_spec.rb+21 −0 added@@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.describe RouteFormat do + describe ".backup" do + def full_backup_match?(filename) + /\A#{described_class.backup}\z/i.match?(filename) + end + + it "matches valid backup filenames" do + expect(full_backup_match?("backup-2026-05-12.tar.gz")).to eq(true) + expect(full_backup_match?("backup-2026-05-12.tgz")).to eq(true) + expect(full_backup_match?("backup-2026-05-12.sql.gz")).to eq(true) + end + + it "does not match path traversal attempts" do + expect(full_backup_match?("../second/backup.tar.gz")).to eq(false) + expect(full_backup_match?("..%2Fsecond%2Fbackup.tar.gz")).to eq(false) + expect(full_backup_match?("nested/path/backup.tar.gz")).to eq(false) + end + end +end
spec/requests/admin/backups_controller_spec.rb+27 −0 modified@@ -237,6 +237,13 @@ def map_preloaded expect(response.status).to eq(404) end + + it "returns 404 for invalid backup ids" do + token = EmailBackupToken.set(admin.id) + get "/admin/backups/..%2Fsecond%2F#{backup_filename}.json", params: { token: token } + + expect(response.status).to eq(404) + end end shared_examples "backup inaccessible" do @@ -295,6 +302,11 @@ def map_preloaded expect(response.status).to eq(404) end + it "returns 404 for invalid backup ids" do + delete "/admin/backups/..%2Fsecond%2F#{backup_filename}.json" + expect(response.status).to eq(404) + end + context "when readonly mode is enabled" do before { Discourse.enable_readonly_mode } @@ -399,6 +411,15 @@ def map_preloaded expect(response.status).to eq(200) end end + + it "returns 404 for invalid backup ids" do + post "/admin/backups/..%2Fsecond%2F#{backup_filename}/restore.json", + params: { + client_id: "foo", + } + + expect(response.status).to eq(404) + end end shared_examples "backup restoration not allowed" do @@ -939,6 +960,12 @@ def map_preloaded expect(response).to be_not_found end + + it "returns 404 for invalid backup ids" do + put "/admin/backups/..%2Fsecond%2F#{backup_filename}.json" + + expect(response).to be_not_found + end end shared_examples "backup emails not allowed" do
4c0045a5a5c1SECURITY: block cross-site backup traversal in multisite local storage [backport 2026.3]
6 files changed · +97 −2
app/controllers/admin/backups_controller.rb+6 −0 modified@@ -7,6 +7,7 @@ class Admin::BackupsController < Admin::AdminController include ExternalUploadHelpers before_action :ensure_backups_enabled + before_action :ensure_valid_backup_id, only: %i[show email destroy restore] skip_before_action :check_xhr, only: %i[index show logs check_backup_chunk upload_backup_chunk] skip_before_action :ensure_backups_enabled, only: %i[show status index email] @@ -271,6 +272,11 @@ def valid_filename?(filename) !!(/\A[a-zA-Z0-9\._\-]+\z/ =~ filename) end + def ensure_valid_backup_id + backup_id = params.fetch(:id) + raise Discourse::NotFound unless valid_filename?(backup_id) && valid_extension?(backup_id) + end + def render_error(message_key) render json: failed_json.merge(message: I18n.t(message_key)) end
lib/backup_restore/local_backup_store.rb+8 −1 modified@@ -55,7 +55,14 @@ def unsorted_files end def path_from_filename(filename) - File.join(@base_directory, filename) + path = File.expand_path(File.join(@base_directory, filename)) + base_directory = File.expand_path(@base_directory) + + if path.start_with?("#{base_directory}#{File::SEPARATOR}") + path + else + raise Discourse::InvalidParameters.new(:filename) + end end def create_file_from_path(path, include_download_source = false)
lib/route_format.rb+1 −1 modified@@ -6,6 +6,6 @@ def self.username end def self.backup - /.+\.(sql\.gz|tar\.gz|tgz)/i + /[a-zA-Z0-9._-]+\.(sql\.gz|tar\.gz|tgz)/i end end
spec/lib/backup_restore/local_backup_store_spec.rb+34 −0 modified@@ -19,6 +19,40 @@ expect(store.remote?).to eq(false) end + describe "path traversal protection" do + let(:filename) { "a.tgz" } + + before do + create_local_backup_file( + root_directory: @root_directory, + db_name: "default", + filename: filename, + last_modified: "2018-02-11T09:27:00Z", + size_in_bytes: 29, + ) + end + + it "raises an error when reading a backup outside the current site's directory" do + expect { store.file("../second/multi-1.tar.gz") }.to raise_error(Discourse::InvalidParameters) + end + + it "raises an error when deleting a backup outside the current site's directory" do + expect { store.delete_file("../second/multi-1.tar.gz") }.to raise_error( + Discourse::InvalidParameters, + ) + end + + it "raises an error when downloading a backup outside the current site's directory" do + Dir.mktmpdir do |path| + destination_path = File.join(path, filename) + + expect { store.download_file("../second/multi-1.tar.gz", destination_path) }.to raise_error( + Discourse::InvalidParameters, + ) + end + end + end + def create_backups create_local_backup_file( root_directory: @root_directory,
spec/lib/route_format_spec.rb+21 −0 added@@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.describe RouteFormat do + describe ".backup" do + def full_backup_match?(filename) + /\A#{described_class.backup}\z/i.match?(filename) + end + + it "matches valid backup filenames" do + expect(full_backup_match?("backup-2026-05-12.tar.gz")).to eq(true) + expect(full_backup_match?("backup-2026-05-12.tgz")).to eq(true) + expect(full_backup_match?("backup-2026-05-12.sql.gz")).to eq(true) + end + + it "does not match path traversal attempts" do + expect(full_backup_match?("../second/backup.tar.gz")).to eq(false) + expect(full_backup_match?("..%2Fsecond%2Fbackup.tar.gz")).to eq(false) + expect(full_backup_match?("nested/path/backup.tar.gz")).to eq(false) + end + end +end
spec/requests/admin/backups_controller_spec.rb+27 −0 modified@@ -237,6 +237,13 @@ def map_preloaded expect(response.status).to eq(404) end + + it "returns 404 for invalid backup ids" do + token = EmailBackupToken.set(admin.id) + get "/admin/backups/..%2Fsecond%2F#{backup_filename}.json", params: { token: token } + + expect(response.status).to eq(404) + end end shared_examples "backup inaccessible" do @@ -295,6 +302,11 @@ def map_preloaded expect(response.status).to eq(404) end + it "returns 404 for invalid backup ids" do + delete "/admin/backups/..%2Fsecond%2F#{backup_filename}.json" + expect(response.status).to eq(404) + end + context "when readonly mode is enabled" do before { Discourse.enable_readonly_mode } @@ -399,6 +411,15 @@ def map_preloaded expect(response.status).to eq(200) end end + + it "returns 404 for invalid backup ids" do + post "/admin/backups/..%2Fsecond%2F#{backup_filename}/restore.json", + params: { + client_id: "foo", + } + + expect(response.status).to eq(404) + end end shared_examples "backup restoration not allowed" do @@ -939,6 +960,12 @@ def map_preloaded expect(response).to be_not_found end + + it "returns 404 for invalid backup ids" do + put "/admin/backups/..%2Fsecond%2F#{backup_filename}.json" + + expect(response).to be_not_found + end end shared_examples "backup emails not allowed" do
5bb48ec0390aSECURITY: block cross-site backup traversal in multisite local storage [backport 2026.1]
6 files changed · +97 −2
app/controllers/admin/backups_controller.rb+6 −0 modified@@ -7,6 +7,7 @@ class Admin::BackupsController < Admin::AdminController include ExternalUploadHelpers before_action :ensure_backups_enabled + before_action :ensure_valid_backup_id, only: %i[show email destroy restore] skip_before_action :check_xhr, only: %i[index show logs check_backup_chunk upload_backup_chunk] skip_before_action :ensure_backups_enabled, only: %i[show status index email] @@ -271,6 +272,11 @@ def valid_filename?(filename) !!(/\A[a-zA-Z0-9\._\-]+\z/ =~ filename) end + def ensure_valid_backup_id + backup_id = params.fetch(:id) + raise Discourse::NotFound unless valid_filename?(backup_id) && valid_extension?(backup_id) + end + def render_error(message_key) render json: failed_json.merge(message: I18n.t(message_key)) end
lib/backup_restore/local_backup_store.rb+8 −1 modified@@ -55,7 +55,14 @@ def unsorted_files end def path_from_filename(filename) - File.join(@base_directory, filename) + path = File.expand_path(File.join(@base_directory, filename)) + base_directory = File.expand_path(@base_directory) + + if path.start_with?("#{base_directory}#{File::SEPARATOR}") + path + else + raise Discourse::InvalidParameters.new(:filename) + end end def create_file_from_path(path, include_download_source = false)
lib/route_format.rb+1 −1 modified@@ -6,6 +6,6 @@ def self.username end def self.backup - /.+\.(sql\.gz|tar\.gz|tgz)/i + /[a-zA-Z0-9._-]+\.(sql\.gz|tar\.gz|tgz)/i end end
spec/lib/backup_restore/local_backup_store_spec.rb+34 −0 modified@@ -19,6 +19,40 @@ expect(store.remote?).to eq(false) end + describe "path traversal protection" do + let(:filename) { "a.tgz" } + + before do + create_local_backup_file( + root_directory: @root_directory, + db_name: "default", + filename: filename, + last_modified: "2018-02-11T09:27:00Z", + size_in_bytes: 29, + ) + end + + it "raises an error when reading a backup outside the current site's directory" do + expect { store.file("../second/multi-1.tar.gz") }.to raise_error(Discourse::InvalidParameters) + end + + it "raises an error when deleting a backup outside the current site's directory" do + expect { store.delete_file("../second/multi-1.tar.gz") }.to raise_error( + Discourse::InvalidParameters, + ) + end + + it "raises an error when downloading a backup outside the current site's directory" do + Dir.mktmpdir do |path| + destination_path = File.join(path, filename) + + expect { store.download_file("../second/multi-1.tar.gz", destination_path) }.to raise_error( + Discourse::InvalidParameters, + ) + end + end + end + def create_backups create_local_backup_file( root_directory: @root_directory,
spec/lib/route_format_spec.rb+21 −0 added@@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.describe RouteFormat do + describe ".backup" do + def full_backup_match?(filename) + /\A#{described_class.backup}\z/i.match?(filename) + end + + it "matches valid backup filenames" do + expect(full_backup_match?("backup-2026-05-12.tar.gz")).to eq(true) + expect(full_backup_match?("backup-2026-05-12.tgz")).to eq(true) + expect(full_backup_match?("backup-2026-05-12.sql.gz")).to eq(true) + end + + it "does not match path traversal attempts" do + expect(full_backup_match?("../second/backup.tar.gz")).to eq(false) + expect(full_backup_match?("..%2Fsecond%2Fbackup.tar.gz")).to eq(false) + expect(full_backup_match?("nested/path/backup.tar.gz")).to eq(false) + end + end +end
spec/requests/admin/backups_controller_spec.rb+27 −0 modified@@ -237,6 +237,13 @@ def map_preloaded expect(response.status).to eq(404) end + + it "returns 404 for invalid backup ids" do + token = EmailBackupToken.set(admin.id) + get "/admin/backups/..%2Fsecond%2F#{backup_filename}.json", params: { token: token } + + expect(response.status).to eq(404) + end end shared_examples "backup inaccessible" do @@ -295,6 +302,11 @@ def map_preloaded expect(response.status).to eq(404) end + it "returns 404 for invalid backup ids" do + delete "/admin/backups/..%2Fsecond%2F#{backup_filename}.json" + expect(response.status).to eq(404) + end + context "when readonly mode is enabled" do before { Discourse.enable_readonly_mode } @@ -399,6 +411,15 @@ def map_preloaded expect(response.status).to eq(200) end end + + it "returns 404 for invalid backup ids" do + post "/admin/backups/..%2Fsecond%2F#{backup_filename}/restore.json", + params: { + client_id: "foo", + } + + expect(response.status).to eq(404) + end end shared_examples "backup restoration not allowed" do @@ -939,6 +960,12 @@ def map_preloaded expect(response).to be_not_found end + + it "returns 404 for invalid backup ids" do + put "/admin/backups/..%2Fsecond%2F#{backup_filename}.json" + + expect(response).to be_not_found + end end shared_examples "backup emails not allowed" do
Vulnerability mechanics
Root cause
"Missing path validation and canonicalization in backup file path resolution allows directory traversal across multisite backup directories."
Attack vector
An authenticated administrator on one Discourse site in a multisite deployment crafts an HTTP request to `/admin/backups/../second/backup.tar.gz.json` (or using URL-encoded traversal like `..%2Fsecond%2F`) against the backup show, delete, restore, or email endpoints. Because the old route regex accepted any characters including `../` and the local store did not canonicalize the path, the request resolves to a backup file belonging to another site on the same host. The attack requires administrator privileges and a local backup storage configuration.
Affected code
The vulnerability involves the backup handling code in `app/controllers/admin/backups_controller.rb`, `lib/backup_restore/local_backup_store.rb`, and `lib/route_format.rb`. The backup route regex in `RouteFormat.backup` was too permissive (`/.+\.(sql\.gz|tar\.gz|tgz)/i`), and `LocalBackupStore#path_from_filename` did not canonicalize the joined path or check that it stayed within the site's backup directory. Both flaws allowed directory traversal via crafted backup IDs.
What the fix does
The patch applies three layers of defense. First, `lib/route_format.rb` tightens the backup route regex from `/.+\.(sql\.gz|tar\.gz|tgz)/i` to `/[a-zA-Z0-9._-]+\.(sql\.gz|tar\.gz|tgz)/i`, rejecting slashes and dots that could be used for traversal. Second, `Admin::BackupsController#ensure_valid_backup_id` validates the backup ID against a strict alphanumeric-plus-dot/underscore/dash pattern and a known extension list, returning 404 for invalid IDs. Third, `LocalBackupStore#path_from_filename` now calls `File.expand_path` on both the joined path and the base directory, then checks that the resolved path starts with the base directory separator — any traversal attempt raises `Discourse::InvalidParameters`.
Preconditions
- configLocal backup storage must be used (not cloud storage)
- configDiscourse must be running in multisite mode
- authAttacker must possess admin credentials on one of the sites
Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
1News mentions
0No linked articles in our index yet.