VYPR
Medium severity6.8NVD Advisory· Published Jun 12, 2026

CVE-2026-45775

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

3
93b0fc608d12

SECURITY: block cross-site backup traversal in multisite local storage [backport 2026.4]

https://github.com/discourse/discourseRoman RizziMay 18, 2026Fixed in 2026.4.1via llm-release-walk
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
    
4c0045a5a5c1

SECURITY: block cross-site backup traversal in multisite local storage [backport 2026.3]

https://github.com/discourse/discourseRoman RizziMay 18, 2026Fixed in 2026.3.1via llm-release-walk
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
    
5bb48ec0390a

SECURITY: block cross-site backup traversal in multisite local storage [backport 2026.1]

https://github.com/discourse/discourseRoman RizziMay 18, 2026Fixed in 2026.1.4via llm-release-walk
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

1

News mentions

0

No linked articles in our index yet.